betty0's picture
Deploy fall-detection-pose Gradio demo
11fab85 verified
Raw
History Blame Contribute Delete
12.2 kB
"""每個 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