File size: 13,056 Bytes
11fab85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
"""規則引擎:keypoint cache rows → 每 track 特徵管線 → 狀態機 → 跌倒事件。



職責:

1. 逐幀分流:cache 的每列依 track_id 分派到該 track 的特徵管線 + 狀態機;

   ``track_id = -1``(未確認偵測)不餵狀態機。

2. 特徵管線:平滑(滑動中位數)、以軀幹長 L̃ 正規化、固定「時間窗」差分

   算垂直速度與角速度(尺度與 fps 不變性都在這一層達成)。

3. keypoint dropout 容忍:無效幀 hold-last(TTL = ``max_kpt_gap_s``),

   逾時凍結;連續無有效觀測超過 ``track_lost_timeout_s`` 即終結該 track。

4. track 縫合:ByteTrack 在跌倒瞬間常斷 id——新 track 出現時,若與剛消失的

   舊 track 末 bbox 的 IoU 夠高,直接繼承舊狀態機(不魔改 tracker)。

   舊 track 消失當下若已在 FALLING/FALLEN,縫合與同 id 重現都改用加長版

   時間窗(``track_stitch_window_falling_s``):此時已有獨立的速度觸發證據,

   值得多等一下換回真實事件,而非被寫死的一般窗口攔截。

"""

from __future__ import annotations

import math

import numpy as np
import pandas as pd

from ..config import Config
from ..events.schema import FallEvent, postprocess_events
from .features import compute_frame_geometry
from .smoothing import RollingMedian, TimedBuffer
from .state_machine import FallStateMachine, State, TickInput

# L̃(軀幹長滑動中位數)的時窗:遠長於單次跌倒(~1s),
# 朝鏡頭跌倒造成的軀幹投影縮短不會即刻拉低尺度基準。
TORSO_MEDIAN_WINDOW_S = 2.0


def _iou(a: tuple, b: tuple) -> float:
    ax1, ay1, ax2, ay2 = a
    bx1, by1, bx2, by2 = b
    ix1, iy1 = max(ax1, bx1), max(ay1, by1)
    ix2, iy2 = min(ax2, bx2), min(ay2, by2)
    iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
    inter = iw * ih
    if inter <= 0:
        return 0.0
    area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
    area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
    union = area_a + area_b - inter
    return inter / union if union > 0 else 0.0


class _TrackRunner:
    """單一 track 的特徵管線(平滑/正規化/差分)+ 其狀態機。



    縫合時只繼承狀態機:平滑緩衝重新起算(幾幀內即填滿,

    代價遠小於把兩條 track 的緩衝硬接在一起的複雜度)。

    """

    def __init__(self, cfg: Config, fps: float, fsm: FallStateMachine):
        r = cfg.rules
        smooth_n = max(1, round(fps * r.smooth_s))
        self.theta_med = RollingMedian(smooth_n)
        self.aspect_med = RollingMedian(smooth_n)
        self.hhip_med = RollingMedian(smooth_n)
        self.hipy_med3 = RollingMedian(3)
        self.torso_med = RollingMedian(max(1, round(fps * TORSO_MEDIAN_WINDOW_S)))
        horizon = r.velocity_window_s * 1.5 + 0.2
        self.hipy_buf = TimedBuffer(horizon)
        self.theta_buf = TimedBuffer(horizon)
        self.fsm = fsm
        self.fps = fps
        self.last_t: float | None = None
        self.last_valid_t: float | None = None
        self.last_bbox: tuple | None = None
        self.held: TickInput | None = None
        self.last_ticked_t: float | None = None

    def _tick(self, t_s: float, tick: TickInput) -> None:
        """呼叫 fsm.tick 前,先把「這次 tick 距上次 tick 的空窗」橋接掉。



        一般逐幀呼叫時 gap ≈ 一幀時長,橋接量 ≈ 0(無感);只有 tick 之間

        真的隔了一段沒有任何觀測的空窗(見 ``FallStateMachine.bridge_gap``

        docstring)才會產生有意義的橋接量。

        """
        if self.last_ticked_t is not None:
            gap = (t_s - self.last_ticked_t) - (1.0 / self.fps)
            if gap > 1e-9:
                self.fsm.bridge_gap(gap)
        self.fsm.tick(tick)
        self.last_ticked_t = t_s

    def step(

        self,

        cfg: Config,

        track_id: int,

        frame_idx: int,

        t_s: float,

        bbox: tuple,

        kpts_xy: np.ndarray,

        kpts_conf: np.ndarray,

        debug: list | None,

    ) -> None:
        r = cfg.rules
        geo = compute_frame_geometry(kpts_xy, kpts_conf, np.asarray(bbox), cfg.model.kpt_conf_min)

        if geo.valid:
            l_tilde = self.torso_med.push(geo.torso_len)
            theta_s = self.theta_med.push(geo.theta_deg)
            aspect_s = (
                self.aspect_med.push(geo.bbox_aspect)
                if not math.isnan(geo.bbox_aspect)
                else float("nan")
            )
            h_hip = None
            if geo.ankle_valid and l_tilde > 0:
                h_hip = self.hhip_med.push(geo.hip_ankle_gap / l_tilde)
            hip_y_f = self.hipy_med3.push(geo.hip_y)
            # 差分先取歷史再 push 當前值(否則 Δ 內含自身)
            v_raw = self.hipy_buf.rate(t_s, hip_y_f, r.velocity_window_s)
            self.hipy_buf.push(t_s, hip_y_f)
            omega = self.theta_buf.rate(t_s, theta_s, r.velocity_window_s)
            self.theta_buf.push(t_s, theta_s)
            v_norm = (v_raw / l_tilde) if (v_raw is not None and l_tilde > 0) else None

            tick = TickInput(
                t_s=t_s,
                frame_idx=frame_idx,
                theta_deg=theta_s,
                bbox_aspect=aspect_s,
                h_hip=h_hip,
                v_norm=v_norm,
                omega=omega,
            )
            self.held = tick
            self.last_valid_t = t_s
            self._tick(t_s, tick)
        elif (
            self.held is not None
            and self.last_valid_t is not None
            and (t_s - self.last_valid_t) <= r.max_kpt_gap_s
        ):
            # hold-last:姿態沿用上次有效值,速度/角速度歸零(缺測不得觸發新事件,
            # 但已在 FALLEN/ALARM 的狀態得以維持,dropout 不會把一次跌倒切成兩段)
            tick = TickInput(
                t_s=t_s,
                frame_idx=frame_idx,
                theta_deg=self.held.theta_deg,
                bbox_aspect=self.held.bbox_aspect,
                h_hip=self.held.h_hip,
                v_norm=0.0,
                omega=0.0,
            )
            self._tick(t_s, tick)
        # else:逾 TTL → 凍結(不 tick);終結與否由引擎依 track_lost_timeout_s 決定

        self.last_t = t_s
        self.last_bbox = tuple(float(v) for v in bbox)
        if debug is not None:
            debug.append(
                {
                    "frame_idx": int(frame_idx),
                    "track_id": int(track_id),
                    "t_s": round(t_s, 4),
                    "valid": bool(geo.valid),
                    "theta_deg": round(self.held.theta_deg, 2) if self.held else None,
                    "bbox_aspect": (
                        round(self.held.bbox_aspect, 3)
                        if self.held and not math.isnan(self.held.bbox_aspect)
                        else None
                    ),
                    "h_hip": (
                        round(self.held.h_hip, 3)
                        if self.held and self.held.h_hip is not None
                        else None
                    ),
                    "v_norm": (
                        round(self.held.v_norm, 3)
                        if geo.valid and self.held and self.held.v_norm is not None
                        else None
                    ),
                    "state": self.fsm.state.value,
                }
            )


def _lost_window_for(ru: _TrackRunner, r, base: float) -> float:
    """該 runner 消失後可容忍的等待時間。



    一般情況用呼叫端各自的基準值(``base``);若消失當下已在 FALLING/FALLEN,

    一律換成加長版縫合窗(``track_stitch_window_falling_s``)——見 engine 模組

    docstring 第 4 點。

    """
    if ru.fsm.state in (State.FALLING, State.FALLEN):
        return r.track_stitch_window_falling_s
    return base


def _pop_stitch_source(

    runners: dict[int, _TrackRunner], bbox: tuple, t_s: float, cfg: Config

) -> _TrackRunner | None:
    """在「最近消失」的 runner 中找縫合對象;找到即自 runners 移除並回傳。



    只考慮本幀沒被更新的 runner(仍活躍的 track 不是縫合對象)。

    """
    r = cfg.rules
    best_tid, best_iou = None, 0.0
    for tid, ru in runners.items():
        if ru.last_t is None or ru.last_bbox is None:
            continue
        if ru.last_t >= t_s - 1e-9:  # 本幀已更新:仍活著
            continue
        if t_s - ru.last_t > _lost_window_for(ru, r, r.track_stitch_window_s):
            continue
        iou = _iou(ru.last_bbox, bbox)
        if iou >= r.track_stitch_iou and iou > best_iou:
            best_tid, best_iou = tid, iou
    if best_tid is None:
        return None
    return runners.pop(best_tid)


def run_engine(

    df: pd.DataFrame, fps: float, cfg: Config, collect_debug: bool = False

) -> tuple[list[FallEvent], list[dict]]:
    """對一支影片的 cache rows 執行規則引擎。



    Args:

        df: keypoint cache rows(欄位見 ``io.cache.CACHE_COLUMNS``)。

        fps: 影片幀率(取自 cache meta;所有時間計算的基準)。

        cfg: 完整設定。

        collect_debug: 是否回傳 per-frame per-track 特徵紀錄(失敗分析用)。



    Returns:

        (後處理完成的事件列表, debug 紀錄列表)

    """
    debug: list[dict] | None = [] if collect_debug else None
    if df.empty or fps <= 0:
        return [], (debug or [])

    r = cfg.rules
    runners: dict[int, _TrackRunner] = {}
    events_raw: list[FallEvent] = []

    df = df.sort_values(["frame_idx", "track_id"], kind="stable")
    for _, grp in df.groupby("frame_idx", sort=True):
        rows = list(grp.itertuples(index=False))
        t_frame = float(rows[0].t_ms) / 1000.0

        # 先更新既有 track,再處理新 track:避免把「本幀仍活著的 track」誤當縫合對象
        existing = [row for row in rows if int(row.track_id) in runners]
        newcomers = [
            row for row in rows if int(row.track_id) >= 0 and int(row.track_id) not in runners
        ]

        for row in existing:
            tid = int(row.track_id)
            t_s = float(row.t_ms) / 1000.0
            runner = runners[tid]
            if runner.last_valid_t is not None and (
                t_s - runner.last_valid_t
            ) > _lost_window_for(runner, r, r.track_lost_timeout_s):
                # 同一 id 長時間無有效觀測後重現:舊片段終結,重新起算
                # (FALLING/FALLEN 時用加長版時間窗,理由同縫合)
                events_raw.extend(runner.fsm.finalize())
                runner = _TrackRunner(cfg, fps, FallStateMachine(r, tid))
                runners[tid] = runner
            runner.step(cfg, tid, int(row.frame_idx), t_s, _bbox(row), row.kpts_xy, row.kpts_conf, debug)

        for row in newcomers:
            tid = int(row.track_id)
            t_s = float(row.t_ms) / 1000.0
            bbox = _bbox(row)
            src = _pop_stitch_source(runners, bbox, t_s, cfg)
            if src is not None:
                src.fsm.adopt(tid)
                runner = _TrackRunner(cfg, fps, src.fsm)
                # 縫合時繼承尺度基準與 hold-last 狀態:
                # L̃ 若在跌倒瞬間重新定錨到「投影縮短的軀幹」,正規化就失真
                runner.torso_med = src.torso_med
                runner.held = src.held
                runner.last_valid_t = src.last_valid_t
                # 也繼承上次 tick 時刻,讓縫合斷點的空窗被 _tick 正確橋接
                # (否則消失期間的時間差會被誤算成「觀察了這麼久還沒確認」)
                runner.last_ticked_t = src.last_ticked_t
            else:
                runner = _TrackRunner(cfg, fps, FallStateMachine(r, tid))
            runners[tid] = runner
            runner.step(cfg, tid, int(row.frame_idx), t_s, bbox, row.kpts_xy, row.kpts_conf, debug)

        # 清掃:超過各自可容忍等待時間的 runner 終結。FALLING/FALLEN 用加長版窗,
        # 否則會在縫合視窗生效前就被這裡提前終結(見 _lost_window_for)。
        base = max(r.track_lost_timeout_s, r.track_stitch_window_s)
        stale = [
            tid
            for tid, ru in runners.items()
            if ru.last_t is not None and ru.last_t < t_frame - _lost_window_for(ru, r, base)
        ]
        for tid in stale:
            events_raw.extend(runners.pop(tid).fsm.finalize())

    for runner in runners.values():
        events_raw.extend(runner.fsm.finalize())

    return postprocess_events(events_raw, cfg.events), (debug or [])


def _bbox(row) -> tuple:
    return (
        float(row.bbox_x1),
        float(row.bbox_y1),
        float(row.bbox_x2),
        float(row.bbox_y2),
    )