From 1f6bbd8f982ee69993aead2fd22b7eedb426539f Mon Sep 17 00:00:00 2001 From: QiuSW Date: Tue, 21 Jul 2026 09:59:53 +0800 Subject: [PATCH] feat(v1): add temporal fall state machine --- docs/04-architecture.md | 2 +- docs/06-tasks.md | 2 +- docs/07-v1-implementation-plan.md | 10 +-- docs/api.md | 16 +--- docs/current-state.md | 14 ++-- progress.md | 9 ++ v1/fall_state.py | 133 ++++++++++++++++++++++++++++++ v1/tests/test_fall_state.py | 71 ++++++++++++++++ 8 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 v1/fall_state.py create mode 100644 v1/tests/test_fall_state.py diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 7f92daf..d0bcf64 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -34,7 +34,7 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一 | Pose 适配器 | `v1/pose.py` | 校验锁定模型的 SHA-256、pose/person/17×3 契约,统一返回 box、关键点、置信度 | 跟踪、摔倒业务结论 | | 跟踪 | `v1/tracking.py` | 以归一化 box 中心距离为连续人员输出稳定 `track_id` | 根据姿态报警 | | 质量与证据 | `v1/evidence.py` | 拒绝缺失肩/髋/膝/踝的姿态,计算水平姿态和躯干归一化下移证据 | GUI 状态、确认事件 | -| 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING | 播放声音或存文件 | +| 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING,并在首次确认时产生一个带确认延迟的事件 | 播放声音或存文件 | | 报警工件 | `v1/alerts.py` | 对确认事件去重、播放声音、保存截图、写日志 | 推理或事件计算 | | PyQt UI | `v1/gui.py` | 渲染帧、骨架、状态、设置和弹窗 | 直接读 RTSP 或写判定规则 | | 回归工具 | `v1/tests/` 与 `v1/scripts/` | 回放录像、断言事件和延迟 | 生产 UI | diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 034aac6..e64db2f 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -18,7 +18,7 @@ | T-102 | 实现可重连的视频源与录像回放适配器 | T-101 | 有效本地录像可按时间戳产帧;无效源进入连接错误状态且不崩溃。 | DONE | | T-103 | 实现 Pose 适配器与模型来源校验 | T-102 | 输出 person box、17 点和置信度;错误模型或哈希不符时给出明确错误。 | DONE | | T-104 | 实现人员跟踪与姿态质量门控 | T-103 | 连续人员维持 ID;低质量、缺失膝踝或空帧不会产生倒地候选。 | DONE | -| T-105 | 实现按 ID 的时序摔倒状态机 | T-104 | 正例在配置秒数内确认;坐下、弯腰、短时低姿态回到 NORMAL;事件副作用只触发一次。 | DOING | +| T-105 | 实现按 ID 的时序摔倒状态机 | T-104 | 正例在配置秒数内确认;坐下、弯腰、短时低姿态回到 NORMAL;事件副作用只触发一次。 | DONE | ## Phase 2 · V1 演示闭环 diff --git a/docs/07-v1-implementation-plan.md b/docs/07-v1-implementation-plan.md index c824f79..c807db2 100644 --- a/docs/07-v1-implementation-plan.md +++ b/docs/07-v1-implementation-plan.md @@ -293,7 +293,7 @@ git commit -m "feat(v1): add pose quality evidence" - Create: `v1/fall_state.py` - Create: `v1/tests/test_fall_state.py` -- [ ] **Step 1: Write event timing tests** +- [x] **Step 1: Write event timing tests** ```python def test_confirmed_event_is_emitted_once_after_persistence(): @@ -318,12 +318,12 @@ def test_confirmed_person_recovers_before_new_event_is_allowed(): assert machine.state_of("7") is FallState.NORMAL ``` -- [ ] **Step 2: Run tests to verify failure** +- [x] **Step 2: Run tests to verify failure** Run: `python -m pytest v1/tests/test_fall_state.py -v` Expected: FAIL because `FallStateMachine` is missing. -- [ ] **Step 3: Implement the four-state contract** +- [x] **Step 3: Implement the four-state contract** ```python from dataclasses import dataclass @@ -399,12 +399,12 @@ class FallStateMachine: Use per-ID state, not global `frame_has_fall`. A source error and a rejected pose must not advance the machine. -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** Run: `python -m pytest v1/tests/test_fall_state.py -v` Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```powershell git add v1/fall_state.py v1/tests/test_fall_state.py docs progress.md diff --git a/docs/api.md b/docs/api.md index 42b2f9b..3f1e532 100644 --- a/docs/api.md +++ b/docs/api.md @@ -65,23 +65,15 @@ PoseEvidence = { } FallEvent = { event_id: string, - source_id: string, track_id: string, - started_at_utc: string, - confirmed_at_utc: string, + suspected_at_monotonic: float, + confirmed_at_monotonic: float, latency_seconds: float, - state: "CONFIRMED", - evidence: { - horizontal_pose: bool, - rapid_vertical_change: bool, - persistence_seconds: float - }, - screenshot_path: string, - config_version: string + state: "CONFIRMED" } ``` -`FallEvent` 只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。 +`FallEvent` 是 T-105 状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间、证据和配置版本。 `PersonPose` 是 T-103 的纯模型输出,不带人员 ID;T-104 的跟踪模块产生 `TrackedPersonPose` 后,才允许事件证据按人员连续积累。 diff --git a/docs/current-state.md b/docs/current-state.md index 70889e2..8313c86 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,17 +5,17 @@ ## 当前快照 - 日期:2026-07-21 -- 阶段:V1 工程化起步;T-105 进行中。 +- 阶段:V1 事件引擎基线已建立;T-105 已验收,等待 T-201。 - 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。 - 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。 -- V1 代码:已建立安全配置、视频源与 Pose 适配器,以及 `v1/tracking.py` 的稳定人员 ID 和 `v1/evidence.py` 的质量/几何证据;状态机和 GUI 尚未实现。 +- V1 代码:已建立安全配置、视频源、Pose、跟踪、质量/几何证据,以及 `v1/fall_state.py` 的按 ID 四态事件机;PyQt GUI、声音、弹窗、截图、JSONL 和真实 RTSP 接入尚未实现。 - V2 代码:`v2/` 目录存在但尚无实现。 - 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。 -- 测试:`python -m compileall -q demo` 已通过;`python -m pytest v1/tests -v` 当前有 14 项配置/视频源/Pose/跟踪/证据测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒,首帧 Pose smoke 得到 2 名人员、每人 17 点。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 +- 测试:`python -m compileall -q demo` 已通过;`python -m pytest v1/tests -v` 当前有 20 项配置/视频源/Pose/跟踪/证据/状态机测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒,首帧 Pose smoke 得到 2 名人员、每人 17 点。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 - 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。 - 当前标准启动:`./init.ps1`。 - 当前标准验证:`python -m compileall -q demo`。 -- 当前 blocker:回归录像及事件标签尚未创建;真实海康 RTSP 流尚未接入。 +- 当前 blocker:尚未把 Pose/跟踪/证据连接到状态机;回归录像及事件标签尚未创建;真实海康 RTSP 流尚未接入。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 @@ -32,9 +32,9 @@ ## 任务状态 -- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)。 -- 正在进行:T-105(实现按 ID 的时序摔倒状态机)。 -- 下一个可领取:完成 T-105 后为 T-201。 +- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)、T-105(按 ID 时序状态机)。 +- 正在进行:无。 +- 下一个可领取:T-201。 ## 当前可运行内容 diff --git a/progress.md b/progress.md index ad33c85..89bad4d 100644 --- a/progress.md +++ b/progress.md @@ -152,3 +152,12 @@ - 阻塞:无。 - 决策:状态机只接收按 ID 的可用证据与单调秒数;视频帧率、空帧、来源断流和拒绝姿态都不能缩短确认窗口或创建事件。 - 下一步:写入 T-105 失败测试。 + +## 【2026-07-21】T-105 实现按 ID 的时序摔倒状态机(完成) + +- 状态:DONE +- 变更:新增 `v1/fall_state.py`,为每个 `track_id` 独立维护 NORMAL、SUSPECT、CONFIRMED、RECOVERING。只有连续、可用的倒地候选达到 1–3 秒确认窗口才产生一个 `FallEvent`;确认后持续帧不会重复返回事件,拒绝证据会打断疑似持续时间,恢复证据持续后才回到 NORMAL。 +- 验证:先运行 `python -m pytest v1/tests/test_fall_state.py -v`,确认因缺少 `v1.fall_state` 导入失败;实现后状态机测试 6 passed,完整 V1 测试在本轮最后一次运行时为 20 passed,`python -m compileall -q v1 demo` 通过。 +- 阻塞:无任务内 blocker。当前尚未创建把 Pose/跟踪/证据喂给状态机的应用编排层,因此不能把这些单元测试误称为真实视频摔倒检测验收。 +- 决策:确认窗口在构造时强制限制为 1–3 秒;同一人员的时钟与事件序号隔离。声音、弹窗、截图与 JSONL 仍由 T-201/T-202 实现,状态机不执行这些副作用。 +- 下一步:T-201,实现顶部双 Tab 的 PyQt 监控/设置界面,并保持其不直接执行事件判断。 diff --git a/v1/fall_state.py b/v1/fall_state.py new file mode 100644 index 0000000..5b695e9 --- /dev/null +++ b/v1/fall_state.py @@ -0,0 +1,133 @@ +"""Deterministic per-person temporal fall event state machine.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional + + +class FallState(str, Enum): + NORMAL = "NORMAL" + SUSPECT = "SUSPECT" + CONFIRMED = "CONFIRMED" + RECOVERING = "RECOVERING" + + +@dataclass(frozen=True) +class Evidence: + accepted: bool + is_fall_candidate: bool + is_recovery_candidate: bool = False + + +@dataclass(frozen=True) +class FallEvent: + event_id: str + track_id: str + suspected_at_monotonic: float + confirmed_at_monotonic: float + latency_seconds: float + state: FallState = FallState.CONFIRMED + + +@dataclass +class _Record: + state: FallState + suspect_started_at: Optional[float] = None + recovery_started_at: Optional[float] = None + last_updated_at: Optional[float] = None + + +class FallStateMachine: + """Confirm only uninterrupted, accepted fall evidence for one tracked person.""" + + def __init__(self, confirm_window_seconds: float, recovery_window_seconds: float) -> None: + if not 1.0 <= confirm_window_seconds <= 3.0: + raise ValueError("confirm_window_seconds must be between 1 and 3 seconds") + if recovery_window_seconds <= 0: + raise ValueError("recovery_window_seconds must be positive") + self._confirm_window_seconds = float(confirm_window_seconds) + self._recovery_window_seconds = float(recovery_window_seconds) + self._records: Dict[str, _Record] = {} + self._next_event_number = 1 + + def state_of(self, track_id: str) -> FallState: + record = self._records.get(track_id) + return record.state if record is not None else FallState.NORMAL + + def update(self, track_id: str, evidence: Evidence, now: float) -> List[FallEvent]: + if not track_id: + raise ValueError("track_id must be non-empty") + timestamp = float(now) + record = self._records.setdefault(track_id, _Record(state=FallState.NORMAL)) + if record.last_updated_at is not None and timestamp < record.last_updated_at: + raise ValueError("timestamps must be monotonic per track") + record.last_updated_at = timestamp + + if not evidence.accepted: + self._reject_evidence(record) + return [] + + if record.state is FallState.NORMAL: + if evidence.is_fall_candidate: + record.state = FallState.SUSPECT + record.suspect_started_at = timestamp + return [] + + if record.state is FallState.SUSPECT: + if not evidence.is_fall_candidate: + self._set_normal(record) + return [] + if timestamp - record.suspect_started_at >= self._confirm_window_seconds: + record.state = FallState.CONFIRMED + event = self._new_event(track_id, record.suspect_started_at, timestamp) + record.suspect_started_at = None + return [event] + return [] + + if record.state is FallState.CONFIRMED: + if evidence.is_recovery_candidate: + record.state = FallState.RECOVERING + record.recovery_started_at = timestamp + return [] + + if record.state is FallState.RECOVERING: + if evidence.is_fall_candidate: + record.state = FallState.SUSPECT + record.suspect_started_at = timestamp + record.recovery_started_at = None + return [] + if not evidence.is_recovery_candidate: + record.state = FallState.CONFIRMED + record.recovery_started_at = None + return [] + if timestamp - record.recovery_started_at >= self._recovery_window_seconds: + self._set_normal(record) + return [] + + raise RuntimeError("unknown fall state") + + def _reject_evidence(self, record: _Record) -> None: + if record.state is FallState.SUSPECT: + self._set_normal(record) + elif record.state is FallState.RECOVERING: + record.state = FallState.CONFIRMED + record.recovery_started_at = None + + @staticmethod + def _set_normal(record: _Record) -> None: + record.state = FallState.NORMAL + record.suspect_started_at = None + record.recovery_started_at = None + + def _new_event( + self, track_id: str, suspected_at: float, confirmed_at: float + ) -> FallEvent: + event = FallEvent( + event_id="FALL-{0:06d}".format(self._next_event_number), + track_id=track_id, + suspected_at_monotonic=suspected_at, + confirmed_at_monotonic=confirmed_at, + latency_seconds=confirmed_at - suspected_at, + ) + self._next_event_number += 1 + return event diff --git a/v1/tests/test_fall_state.py b/v1/tests/test_fall_state.py new file mode 100644 index 0000000..5941d5e --- /dev/null +++ b/v1/tests/test_fall_state.py @@ -0,0 +1,71 @@ +import pytest + +from v1.fall_state import Evidence, FallState, FallStateMachine + + +def test_confirmed_event_is_emitted_once_after_persistent_evidence(): + machine = FallStateMachine(confirm_window_seconds=1.8, recovery_window_seconds=2.0) + + assert machine.update("P-0007", Evidence(True, True), now=0.0) == [] + events = machine.update("P-0007", Evidence(True, True), now=1.8) + + assert len(events) == 1 + assert events[0].track_id == "P-0007" + assert events[0].latency_seconds == 1.8 + assert machine.update("P-0007", Evidence(True, True), now=2.0) == [] + assert machine.state_of("P-0007") is FallState.CONFIRMED + + +def test_brief_low_posture_returns_to_normal_without_event(): + machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + + machine.update("P-0007", Evidence(True, True), now=0.0) + events = machine.update("P-0007", Evidence(True, False), now=0.3) + + assert events == [] + assert machine.state_of("P-0007") is FallState.NORMAL + + +def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window(): + machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + + machine.update("P-0007", Evidence(True, True), now=0.0) + machine.update("P-0007", Evidence(False, False), now=0.9) + events = machine.update("P-0007", Evidence(True, True), now=1.1) + + assert events == [] + assert machine.state_of("P-0007") is FallState.SUSPECT + + +def test_recovery_must_persist_before_new_event_is_allowed(): + machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + machine.update("P-0007", Evidence(True, True), now=0.0) + first_event = machine.update("P-0007", Evidence(True, True), now=1.0) + assert len(first_event) == 1 + + machine.update("P-0007", Evidence(True, False, True), now=1.1) + machine.update("P-0007", Evidence(True, False, True), now=3.1) + assert machine.state_of("P-0007") is FallState.NORMAL + + machine.update("P-0007", Evidence(True, True), now=4.0) + second_event = machine.update("P-0007", Evidence(True, True), now=5.0) + assert len(second_event) == 1 + assert first_event[0].event_id != second_event[0].event_id + + +def test_confirmation_window_must_remain_within_customer_target(): + with pytest.raises(ValueError, match="between 1 and 3"): + FallStateMachine(confirm_window_seconds=0.9, recovery_window_seconds=2.0) + + +def test_each_track_has_an_independent_confirmation_window(): + machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0) + + machine.update("P-0001", Evidence(True, True), now=0.0) + machine.update("P-0002", Evidence(True, True), now=0.6) + first_events = machine.update("P-0001", Evidence(True, True), now=1.0) + second_events = machine.update("P-0002", Evidence(True, True), now=1.6) + + assert first_events[0].track_id == "P-0001" + assert second_events[0].track_id == "P-0002" + assert first_events[0].event_id != second_events[0].event_id