Spaces:
Running
Running
File size: 12,218 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 | """每個 track 一台跌倒狀態機。
純邏輯:輸入為已平滑、已正規化的特徵(見 ``engine``),輸出狀態與事件。
UPRIGHT ─(v>v_fall_enter 或 ω>omega_enter)→ FALLING
FALLING ─(躺姿 m-of-n 投票在時窗內達 vote_ratio)→ FALLEN
FALLING ─(t_falling_timeout_s 內未確認)→ UPRIGHT(回退,不出事件:擋快速坐下/蹲下)
FALLEN ─(躺姿連續 t_confirm_fallen_s)→ ALARM(此刻才「確認」一次跌倒)
FALLEN/ALARM ─(回正持續 t_recover_s;遲滯出口閾值)→ UPRIGHT
(ALARM 時關閉事件;FALLEN 未達確認,不出事件)
FALLEN ─(track 消失/finalize,未撐到 ALARM)→ 仍收尾為一次事件:
躺姿已通過投票確認,消失後多半是持續倒地不起
(pose 模型對躺姿本身較弱,常見整段掉偵測),寧可觸發不可漏判
FALLING ─(track 消失/finalize,最後一次觀察已符合躺姿 m-of-n)→ 仍收尾為一次事件:
躺姿投票需要在時窗內累積夠多樣本才能「確認」,但 track 常常
恰好在姿態剛轉為躺姿、視窗還沒累積完就整個消失(同一個 pose
模型弱點);最後一次平滑後的觀測已經符合躺姿,比空手回去更可信
事件的起點是「進入 FALLING 的幀」(跌倒開始),而非 ALARM 的幀——
告警需要去抖動延遲,但事件時間軸要對齊真實跌倒,評估才公平。
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from enum import Enum
from ..config import RulesConfig
from ..events.schema import FallEvent
class State(str, Enum):
UPRIGHT = "UPRIGHT"
FALLING = "FALLING"
FALLEN = "FALLEN"
ALARM = "ALARM"
@dataclass
class TickInput:
"""一次狀態機更新的輸入(皆為平滑/正規化後的值)。
``h_hip``/``v_norm``/``omega`` 為 None 表示該值本幀不可得
(踝不可見、歷史不足):None 不觸發任何條件,也不投票。
"""
t_s: float
frame_idx: int
theta_deg: float
bbox_aspect: float
h_hip: float | None
v_norm: float | None
omega: float | None
@dataclass
class _FallContext:
"""從 FALLING 進入開始累積的事件上下文;回退時整包丟棄。"""
start_frame: int
start_t: float
rules_fired: set
max_v: float = float("-inf")
max_theta: float = float("-inf")
class FallStateMachine:
def __init__(self, cfg: RulesConfig, track_id: int):
self.cfg = cfg
self.track_ids: list[int] = [int(track_id)]
self.state: State = State.UPRIGHT
self.completed: list[FallEvent] = []
self._vote_win: deque[tuple[float, bool]] = deque()
self._ctx: _FallContext | None = None
self._falling_since: float | None = None
self._lying_since: float | None = None
self._not_lying_since: float | None = None
self._recover_since: float | None = None
self._alarm_open: bool = False
self._last_t: float | None = None
self._last_frame: int | None = None
self._last_lying: bool = False
# ---------- 對外 ----------
def adopt(self, track_id: int) -> None:
"""track 縫合:新 id 繼承本狀態機(事件會記錄整條 id 鏈)。"""
tid = int(track_id)
if tid not in self.track_ids:
self.track_ids.append(tid)
def bridge_gap(self, gap_s: float) -> None:
"""跨越一段完全沒有 tick 的空窗(track 消失後縫合重連、或同一 id 在
hold-last TTL 用盡後才恢復偵測):把空窗時長從「已經過多久」的單一時間戳
判斷基準(_falling_since 等)往後平移,不計入去抖動時長。
``t_falling_timeout_s``/``t_confirm_fallen_s``/``t_recover_s`` 都是靠單一
時間戳算「已經過多久」,假設連續觀察;空窗期間根本沒有任何觀測,不該被
當成「觀察了這麼久仍未確認」而誤觸發回退——那樣縫合視窗放得越寬,反而
越容易被這裡打回原形。
``_vote_win`` 刻意不做位移:它是離散樣本的滑動窗,不是單一時間戳;
位移只會讓空窗前的舊樣本看起來「剛剛才觀察到」,污染縫合後的投票比例。
舊樣本本來就會被下一次 ``_push_vote`` 依真實時間差自然淘汰,不需要特殊處理。
"""
if gap_s <= 0:
return
if self._falling_since is not None:
self._falling_since += gap_s
if self._lying_since is not None:
self._lying_since += gap_s
if self._not_lying_since is not None:
self._not_lying_since += gap_s
if self._recover_since is not None:
self._recover_since += gap_s
def tick(self, x: TickInput) -> None:
cfg = self.cfg
self._last_t, self._last_frame = x.t_s, x.frame_idx
lying_now = self._posture_lying(x)
self._last_lying = lying_now
self._push_vote(x.t_s, lying_now)
self._update_recover(x, lying_now)
if self._ctx is not None:
if x.v_norm is not None:
self._ctx.max_v = max(self._ctx.max_v, x.v_norm)
self._ctx.max_theta = max(self._ctx.max_theta, x.theta_deg)
if self.state is State.UPRIGHT:
trig = []
if x.v_norm is not None and x.v_norm > cfg.v_fall_enter:
trig.append("v>v_fall_enter")
if x.omega is not None and x.omega > cfg.omega_enter:
trig.append("omega>omega_enter")
if trig:
self.state = State.FALLING
self._falling_since = x.t_s
self._ctx = _FallContext(
start_frame=x.frame_idx,
start_t=x.t_s,
rules_fired=set(trig),
max_v=x.v_norm if x.v_norm is not None else float("-inf"),
max_theta=x.theta_deg,
)
elif self.state is State.FALLING:
if self._vote_confirmed(x.t_s):
self.state = State.FALLEN
self._ctx.rules_fired.add("posture_vote_confirmed")
self._lying_since = x.t_s
self._not_lying_since = None
elif x.t_s - self._falling_since > cfg.t_falling_timeout_s:
# 未確認躺姿:視為快速坐下/蹲下,回退且不出事件
self._rollback()
elif self.state is State.FALLEN:
if lying_now:
self._lying_since = self._lying_since if self._lying_since is not None else x.t_s
self._not_lying_since = None
else:
self._lying_since = None
self._not_lying_since = (
self._not_lying_since if self._not_lying_since is not None else x.t_s
)
if (
self._lying_since is not None
and x.t_s - self._lying_since >= cfg.t_confirm_fallen_s
):
self.state = State.ALARM
self._alarm_open = True
self._ctx.rules_fired.add("lying_persisted")
elif self._recover_sustained(x.t_s):
self._rollback() # 未達 ALARM 即回正:不出事件
elif (
self._not_lying_since is not None
and x.t_s - self._not_lying_since > cfg.t_falling_timeout_s
):
self._rollback() # 軟重置:半躺不躺(如跌成坐姿)久滯,不告警也不卡死
elif self.state is State.ALARM:
if self._recover_sustained(x.t_s):
self._close_event(x.frame_idx, x.t_s)
self._rollback()
def finalize(self) -> list[FallEvent]:
"""track 結束(消失逾時或影片結尾):關閉進行中的事件並回傳全部事件。
ALARM 中結束自然收尾;FALLEN 中結束(躺姿已投票確認,但尚未撐滿
t_confirm_fallen_s)也視為一次事件收尾;FALLING 中結束但最後一次
平滑觀測已符合躺姿 m-of-n(只是時窗投票還沒累積足夠樣本)同樣收尾
——見上方 FSM 圖說明,三者都是同一個道理:track 消失的當下已有夠強的
單幀證據,不該因為視窗化的去抖動機制來不及跑完就整個丟棄。
"""
if self._alarm_open and self._last_frame is not None:
self._close_event(self._last_frame, self._last_t)
elif self.state is State.FALLEN and self._last_frame is not None:
self._ctx.rules_fired.add("track_lost_while_fallen")
self._close_event(self._last_frame, self._last_t)
elif self.state is State.FALLING and self._last_lying and self._last_frame is not None:
self._ctx.rules_fired.add("track_lost_while_falling_with_lying_posture")
self._close_event(self._last_frame, self._last_t)
self._ctx = None
self._alarm_open = False
out, self.completed = self.completed, []
return out
# ---------- 內部 ----------
def _posture_lying(self, x: TickInput) -> bool:
"""躺姿 m-of-n 投票;踝不可見時 h_hip 不投票(不硬猜)。"""
cfg = self.cfg
votes = [
x.theta_deg > cfg.theta_lying_enter,
x.bbox_aspect > cfg.r_lying,
]
if x.h_hip is not None:
votes.append(x.h_hip < cfg.h_hip_lying)
return sum(votes) >= cfg.posture_votes_required
def _push_vote(self, t: float, lying: bool) -> None:
self._vote_win.append((t, lying))
cutoff = t - self.cfg.window_confirm_s
while self._vote_win and self._vote_win[0][0] < cutoff - 1e-9:
self._vote_win.popleft()
def _vote_confirmed(self, t: float) -> bool:
if len(self._vote_win) < 2:
return False
span = t - self._vote_win[0][0]
if span < 0.5 * self.cfg.window_confirm_s:
return False # 窗內樣本太少,單幀雜訊也能過票——先不確認
ratio = sum(1 for _, ly in self._vote_win if ly) / len(self._vote_win)
return ratio >= self.cfg.vote_ratio
def _update_recover(self, x: TickInput, lying_now: bool) -> None:
cfg = self.cfg
recovered_now = (
not lying_now
and x.theta_deg < cfg.theta_upright_exit
and (x.h_hip is None or x.h_hip > cfg.h_hip_upright_exit)
)
if recovered_now:
if self._recover_since is None:
self._recover_since = x.t_s
else:
self._recover_since = None
def _recover_sustained(self, t: float) -> bool:
return (
self._recover_since is not None
and t - self._recover_since >= self.cfg.t_recover_s
)
def _close_event(self, end_frame: int, end_t: float) -> None:
ctx = self._ctx
peaks = {}
if ctx.max_v != float("-inf"):
peaks["max_v_torso_per_s"] = round(ctx.max_v, 3)
if ctx.max_theta != float("-inf"):
peaks["max_theta_deg"] = round(ctx.max_theta, 1)
self.completed.append(
FallEvent(
track_ids=sorted(self.track_ids),
start_frame=ctx.start_frame,
end_frame=int(end_frame),
start_time_s=round(ctx.start_t, 3),
end_time_s=round(float(end_t), 3),
peak_features=peaks,
rules_fired=sorted(ctx.rules_fired),
)
)
self._alarm_open = False
def _rollback(self) -> None:
"""回到 UPRIGHT 並清空事件上下文(已關閉的事件保留在 completed)。"""
self.state = State.UPRIGHT
self._ctx = None
self._falling_since = None
self._lying_since = None
self._not_lying_since = None
self._alarm_open = False
|