feat(v1): compose temporal fall event pipeline

This commit is contained in:
ila
2026-07-21 11:57:22 +08:00
parent 906e06565e
commit f03662bf5a
16 changed files with 554 additions and 34 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
| 桌面 UI | PyQt5 | 已验证 | 现有 GUI 已使用,适合实时画面、弹窗和信号线程。 |
| 视频与图像 | OpenCV(含 FFmpeg) | 已验证 | 读取 RTSP、绘制叠加层和保存截图。 |
| Pose 推理 | Ultralytics 8.3.205 + YOLOv8 Pose | 已验证 | 当前 `best.pt` 能加载为 17 点 person Pose 模型。 |
| 跟踪 | Ultralytics ByteTrack | 已定 | 为时序事件提供人员 ID;实现前需用现场录像验证 ID 稳定性。 |
| 跟踪 | 轻量归一化框中心最近邻 | 已实现基线 | 为时序事件提供人员 ID;固定机位、稀疏人员适用;多人交叉可能换 ID,待有回归素材后再评估 ByteTrack 或全局匹配。 |
| 事件逻辑 | 纯 Python 模块 + pytest | 已定 | 与 GUI 解耦,便于正反例录像回归。 |
| 本地配置 | JSON 示例文件 + 未跟踪的 `config.local.json` | 已定 | 不把 RTSP 凭证提交仓库。 |
| 日志与截图 | JSONL + PNG/JPG 文件 | 已定 | 无数据库的可审计演示证据。 |
+14 -5
View File
@@ -13,6 +13,8 @@ Pose 推理适配器(person box + 17 keypoints)
↓
姿态质量门控 + 倒地证据提取
↓
倒地领域规则(突发下移 → 水平姿态)
↓
每人摔倒事件状态机
↓
确认事件
@@ -34,7 +36,9 @@ 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_policy.py` | 将每人连续的 `PoseEvidence` 映射为状态机 `Evidence`;要求快速下移后在 suspect 窗口内转为水平,确认后才接受恢复证据 | GUI、报警副作用、模型推理 |
| 事件管线 | `v1/pipeline.py` | 按一帧顺序装配 Pose、跟踪、质量/证据、领域规则和状态机;对缺帧、低质量和断流输入拒绝证据 | 读取视频、加载模型、GUI、报警副作用 |
| 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING,并在首次确认时产生带确认延迟和 `config_version` 的事件 | 播放声音或存文件 |
| 报警工件 | `v1/alerts.py` | 对确认事件去重、播放声音、保存截图、写日志 | 推理或事件计算 |
| PyQt UI | `v1/gui.py` | 渲染帧、骨架、状态、设置和弹窗 | 直接读 RTSP 或写判定规则 |
| 回归工具 | `v1/tests/` 与 `v1/scripts/` | 回放录像、断言事件和延迟 | 生产 UI |
@@ -43,7 +47,7 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一
PyQt 主窗口只包含实时监控和设置两个顶部 Tab。实时监控 Tab 保持视频画面优先;设置 Tab 不能直接调用视频源、Pose 或状态机。
设置页面产生的是已校验的草稿配置。用户点击开始监控时,应用创建不可变的运行配置快照与配置版本,并将该快照传给视频源、Pose、证据和状态机。运行期间编辑设置不会修改该快照;保存后的草稿在下一次开始监控时才会成为新的运行配置。FallEvent 继续记录 config_version,使截图和 JSONL 可以追溯到实际阈值。
设置页面产生的是已校验的草稿配置。用户点击开始监控时,应用创建不可变的运行配置快照与配置版本,并将该快照传给视频源、Pose、证据和状态机。运行期间编辑设置不会修改该快照;保存后的草稿在下一次开始监控时才会成为新的运行配置。`AppConfig.runtime_config_version` 从非敏感来源 ID、锁定模型与事件参数计算,不含 RTSP 地址或凭证;`FallEvent` 继续记录它,使截图和 JSONL 可以追溯到实际阈值。
真实 RTSP 凭证仍只由环境变量或未跟踪本地配置提供。UI 只显示环境变量是否就绪,不能回显或记录具体值。
@@ -53,10 +57,10 @@ PyQt 主窗口只包含实时监控和设置两个顶部 Tab。实时监控 Tab
```text
NORMAL
└─ 高质量证据显示快速下移或倒地姿态 → SUSPECT
└─ 高质量证据显示快速下移,且在 suspect 窗口内转为水平姿态 → SUSPECT
SUSPECT
├─ 倒地证据在配置时间窗内持续 → CONFIRMED(产生一次 FallEvent)
└─ 证据消失 → NORMAL
├─ 水平倒地证据在 confirm 时间窗内持续 → CONFIRMED(产生一次 FallEvent)
└─ 缺失、低质量或非倒地证据 → NORMAL
CONFIRMED
└─ 经配置冷却与恢复稳定站立 → RECOVERING
RECOVERING
@@ -66,6 +70,8 @@ RECOVERING
状态机只以秒和单调时间为准,不以固定帧数为准。这样 15 FPS、30 FPS、丢帧或录像回放速度变化不会改变 1–3 秒业务目标。
录像源和实时流使用不同的计时策略:回放优先容器 PTS、再回退到帧序号/FPS;实时流以成功读帧时的 `time.monotonic()` 计时。任意非连接帧、人员缺帧或低质量姿态都会向该人员输入拒绝证据,不能被计入连续倒地确认时间。
## 数据和文件
| 数据 | 位置 | 规则 |
@@ -86,6 +92,7 @@ RECOVERING
| 单帧误报 | 当前 `demo/` 任一规则命中即报警 | V1 用质量门控、跟踪和时序状态机;反例录像必测。 |
| 俯视关键点不稳 | 1.6 米俯视、遮挡或远距离会影响膝踝 | 全身可见前提、ROI、质量拒绝和现场回归;必要时再采集数据。 |
| RTSP 断流 | 网络抖动或摄像头重连 | 视频源显式状态、指数退避重连、断流不报警。 |
| 多人交叉换 ID | 当前 V1 使用贪心中心点匹配,未做全局最优匹配 | 固定机位、稀疏人员演示中记录为已知限制;多人密集场景在有回归素材后再评估升级。 |
| 同一事件重复报警 | CONFIRMED 状态持续多帧 | 每个事件 ID 仅执行一次报警副作用,恢复后才允许新事件。 |
| Go 行为漂移 | ONNX 预后处理与 Python 不同 | 导出后跑同一录像,比较关键点、事件数量、确认时间和截图。 |
| 模型误解 | Pose 指标被误当摔倒指标 | 文案仅说明姿态模型;事件级指标单独记录。 |
@@ -111,7 +118,9 @@ v1/
├── pose.py
├── tracking.py
├── evidence.py
├── fall_policy.py
├── fall_state.py
├── pipeline.py
├── alerts.py
├── models/
├── scripts/
+1 -1
View File
@@ -19,7 +19,7 @@
| T-103 | 实现 Pose 适配器与模型来源校验 | T-102 | 输出 person box、17 点和置信度;错误模型或哈希不符时给出明确错误。 | DONE |
| T-104 | 实现人员跟踪与姿态质量门控 | T-103 | 连续人员维持 ID;低质量、缺失膝踝或空帧不会产生倒地候选。 | DONE |
| T-105 | 实现按 ID 的时序摔倒状态机 | T-104 | 正例在配置秒数内确认;坐下、弯腰、短时低姿态回到 NORMAL;事件副作用只触发一次。 | DONE |
| T-106 | 装配事件管线与固化事件契约 | T-105 | Pose → 跟踪 → 质量/几何证据 → 领域规则 → 按 ID 状态机可回放运行;缺失/低质量证据会中断确认;全部公开事件参数生效;事件可追溯运行配置版本;录像 EOF 不会重放。 | DOING |
| T-106 | 装配事件管线与固化事件契约 | T-105 | Pose → 跟踪 → 质量/几何证据 → 领域规则 → 按 ID 状态机可回放运行;缺失/低质量证据会中断确认;全部公开事件参数生效;事件可追溯运行配置版本;录像 EOF 不会重放。 | DONE |
## Phase 2 · V1 演示闭环
+5 -3
View File
@@ -32,7 +32,7 @@
```
- `rtsp_url_env` 必填;应用从同名环境变量读取真实 URL。
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
## 核心数据
@@ -66,6 +66,7 @@ PoseEvidence = {
FallEvent = {
event_id: string,
track_id: string,
config_version: string,
suspected_at_monotonic: float,
confirmed_at_monotonic: float,
latency_seconds: float,
@@ -73,7 +74,7 @@ FallEvent = {
}
```
`FallEvent` 是 T-105 状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间、证据和配置版本。
`FallEvent` 是状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。`config_version` 是由运行配置快照计算的非敏感版本标识;T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间和证据。
`PersonPose` 是 T-103 的纯模型输出,不带人员 ID;T-104 的跟踪模块产生 `TrackedPersonPose` 后,才允许事件证据按人员连续积累。
@@ -88,7 +89,8 @@ FramePacket = {
}
```
- 录像优先使用容器时间戳;首帧时间戳无效或倒退时,回退为帧序号/FPS,保证回放时间单调。
- `SourceMode.REPLAY` 的录像优先使用容器时间戳;首帧时间戳无效或倒退时,回退为帧序号/FPS,保证回放时间单调;到达 EOF 后保持 EOF,不重放。
- `SourceMode.STREAM` 的实时流以成功读帧时的单调时钟计时;读取失败进入有界指数退避重连,不使用 `CAP_PROP_POS_MSEC` 作为事件时间。
- `retrying`、`error`、`eof` 和 `closed` 都没有图像,且绝不伪造人员、姿态或摔倒事件。
- 可重连来源以有界指数退避重新打开;断流不推进状态机的证据时间。
+6 -6
View File
@@ -5,17 +5,17 @@
## 当前快照
- 日期:2026-07-21
- 阶段:V1 事件引擎基线已建立;T-106(事件管线与事件契约)进行中。
- 阶段:V1 事件管线与事件契约已建立;T-106 已验收,等待 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/fall_state.py` 的按 ID 四态事件机;Pose/跟踪/证据到状态机的事件管线与事件契约正在由 T-106 装配。PyQt GUI、声音、弹窗、截图、JSONL 和真实 RTSP 接入尚未实现。
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。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` 当前有 20 项配置/视频源/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` 当前有 30 项配置、视频源、Pose、跟踪、证据、领域规则、状态机和管线测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件,这只验证管线可运行,不表示摔倒识别准确率。`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:T-106 尚未完成 Pose/跟踪/证据到状态机的事件管线、缺失证据中断语义与运行配置版本契约;回归录像及事件标签尚未创建;真实海康 RTSP 流尚未接入。
- 当前 blocker:尚无带标签的正反例回归录像及事件标签;真实海康 RTSP 流尚未接入,实时流单调计时策略尚待 T-203 在现场验证;PyQt 与本地报警闭环尚未实现。
全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。
@@ -33,8 +33,8 @@
## 任务状态
- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)、T-105(按 ID 时序状态机)。
- 正在进行:T-106(装配事件管线与固化事件契约)。
- 下一个可领取:无;完成 T-106 后为 T-201。
- 正在进行:无。
- 下一个可领取:T-201(顶部双 Tab 的 PyQt 监控与设置界面)。
## 当前可运行内容
+9
View File
@@ -170,3 +170,12 @@
- 阻塞:无。
- 决策:不把事件判断规则混入 T-201 的 PyQt UI;T-106 先提供真实、可测试的管线输出。`demo/` 保持未跟踪旧基线,不纳入本任务提交。
- 下一步:先为领域规则、缺失证据、配置版本和来源模式写失败测试,再作最小实现。
## 【2026-07-21】T-106 装配事件管线与固化事件契约(完成)
- 状态:DONE
- 变更:新增 `v1/fall_policy.py`,将快速下移后在 suspect 窗口内形成的水平姿态映射为候选,并只在确认/恢复状态处理恢复证据;新增 `v1/pipeline.py`,将 Pose、跟踪、质量、几何证据、领域规则和按 ID 状态机连接。缺失人员、低质量姿态和非连接帧均向状态机输入拒绝证据,不能跨空档确认。`FallEvent` 现在包含运行配置的非敏感 `config_version`;`cooldown_seconds` 在确认后延迟恢复判断。视频源新增显式 `SourceMode.REPLAY`/`STREAM`:录像 EOF 不重放,实时流以收帧单调时钟计时并重连。
- 验证:以失败测试先后覆盖领域规则、配置版本、cooldown、Replay EOF、Stream 计时、完整管线确认及缺帧中断;最终 `python -m pytest v1/tests -v` 为 30 passed,`python -m compileall -q v1 demo` 退出码 0。只读真实 smoke 使用 `demo/best.pt` 与 `demo/1.mp4` 驱动 T-106 管线两帧,结果为首帧 `connected/2 people/0 events`、第二帧 `connected/0 people/0 events`。
- 阻塞:无任务内 blocker。该 smoke 未使用带标签摔倒/反例录像,不能作为事件准确率、1–3 秒延迟或海康 RTSP 现场验收证据。
- 决策:`suspect_window_seconds` 定义为“快速下移到水平姿态”的最大间隔,`cooldown_seconds` 定义为确认后开始恢复判断的最短等待时间;运行配置版本排除 RTSP 地址和凭证。不会仅为延长确认而增大 tracker 的身份寿命,证据连续性由管线的拒绝证据保证。当前轻量跟踪多人交叉换 ID 记录为已知限制。
- 下一步:T-201,实现顶部双 Tab 的 PyQt 监控与设置界面,只消费 T-106 的管线输出,不执行事件判定。
+20
View File
@@ -1,5 +1,6 @@
"""Validated, credential-safe configuration loading for Silver Pose V1."""
import hashlib
import json
import os
import re
@@ -31,6 +32,25 @@ class AppConfig:
event: EventConfig
event_dir: Path
@property
def runtime_config_version(self) -> str:
"""Return a stable, non-secret identifier for the active event settings."""
payload = {
"source_id": self.source_id,
"model_sha256": self.model_sha256,
"confidence_threshold": self.confidence_threshold,
"event": {
"keypoint_confidence_threshold": self.event.keypoint_confidence_threshold,
"suspect_window_seconds": self.event.suspect_window_seconds,
"confirm_window_seconds": self.event.confirm_window_seconds,
"recovery_window_seconds": self.event.recovery_window_seconds,
"cooldown_seconds": self.event.cooldown_seconds,
},
}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return "cfg-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
+45
View File
@@ -0,0 +1,45 @@
"""Translate per-track pose facts into state-machine evidence."""
from typing import Dict
from v1.evidence import PoseEvidence
from v1.fall_state import Evidence, FallState
class FallEvidencePolicy:
"""Require a recent rapid drop before a horizontal pose becomes a candidate."""
def __init__(self, suspect_window_seconds: float) -> None:
self._suspect_window_seconds = float(suspect_window_seconds)
self._rapid_drop_at: Dict[str, float] = {}
def evaluate(
self,
track_id: str,
pose_evidence: PoseEvidence,
now: float,
state: FallState,
) -> Evidence:
timestamp = float(now)
if not pose_evidence.accepted:
self._rapid_drop_at.pop(track_id, None)
return Evidence(accepted=False, is_fall_candidate=False)
if pose_evidence.rapid_vertical_change:
self._rapid_drop_at[track_id] = timestamp
candidate = False
if state is FallState.SUSPECT:
candidate = pose_evidence.horizontal_pose
elif pose_evidence.horizontal_pose and track_id in self._rapid_drop_at:
candidate = timestamp - self._rapid_drop_at[track_id] <= self._suspect_window_seconds
recovery = (
state in (FallState.CONFIRMED, FallState.RECOVERING)
and not pose_evidence.horizontal_pose
and not pose_evidence.rapid_vertical_change
)
return Evidence(
accepted=pose_evidence.accepted,
is_fall_candidate=candidate,
is_recovery_candidate=recovery,
)
+22 -2
View File
@@ -23,6 +23,7 @@ class Evidence:
class FallEvent:
event_id: str
track_id: str
config_version: str
suspected_at_monotonic: float
confirmed_at_monotonic: float
latency_seconds: float
@@ -33,6 +34,7 @@ class FallEvent:
class _Record:
state: FallState
suspect_started_at: Optional[float] = None
confirmed_at: Optional[float] = None
recovery_started_at: Optional[float] = None
last_updated_at: Optional[float] = None
@@ -40,13 +42,25 @@ class _Record:
class FallStateMachine:
"""Confirm only uninterrupted, accepted fall evidence for one tracked person."""
def __init__(self, confirm_window_seconds: float, recovery_window_seconds: float) -> None:
def __init__(
self,
confirm_window_seconds: float,
recovery_window_seconds: float,
config_version: str,
cooldown_seconds: float = 0.0,
) -> 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")
if cooldown_seconds < 0:
raise ValueError("cooldown_seconds must be non-negative")
if not isinstance(config_version, str) or not config_version.strip():
raise ValueError("config_version must be a non-empty string")
self._confirm_window_seconds = float(confirm_window_seconds)
self._recovery_window_seconds = float(recovery_window_seconds)
self._cooldown_seconds = float(cooldown_seconds)
self._config_version = config_version.strip()
self._records: Dict[str, _Record] = {}
self._next_event_number = 1
@@ -79,13 +93,17 @@ class FallStateMachine:
return []
if timestamp - record.suspect_started_at >= self._confirm_window_seconds:
record.state = FallState.CONFIRMED
record.confirmed_at = timestamp
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:
if (
evidence.is_recovery_candidate
and timestamp - record.confirmed_at >= self._cooldown_seconds
):
record.state = FallState.RECOVERING
record.recovery_started_at = timestamp
return []
@@ -117,6 +135,7 @@ class FallStateMachine:
def _set_normal(record: _Record) -> None:
record.state = FallState.NORMAL
record.suspect_started_at = None
record.confirmed_at = None
record.recovery_started_at = None
def _new_event(
@@ -125,6 +144,7 @@ class FallStateMachine:
event = FallEvent(
event_id="FALL-{0:06d}".format(self._next_event_number),
track_id=track_id,
config_version=self._config_version,
suspected_at_monotonic=suspected_at,
confirmed_at_monotonic=confirmed_at,
latency_seconds=confirmed_at - suspected_at,
+139
View File
@@ -0,0 +1,139 @@
"""Compose Pose, tracking, evidence policy, and temporal fall state."""
from dataclasses import dataclass
from typing import Dict, Sequence, Tuple
from v1.config import AppConfig
from v1.evidence import PoseEvidence, assess_pose_quality, extract_evidence
from v1.fall_policy import FallEvidencePolicy
from v1.fall_state import Evidence, FallEvent, FallState, FallStateMachine
from v1.tracking import PersonTracker, TrackedPersonPose
from v1.video_source import FramePacket, SourceStatus
@dataclass(frozen=True)
class PersonAnalysis:
tracked_pose: TrackedPersonPose
pose_evidence: PoseEvidence
state: FallState
@dataclass(frozen=True)
class FrameAnalysis:
packet: FramePacket
people: Tuple[PersonAnalysis, ...]
events: Tuple[FallEvent, ...]
class FallPipeline:
"""Run one source frame through the V1 event decision flow."""
def __init__(
self,
pose_adapter,
tracker: PersonTracker,
policy: FallEvidencePolicy,
state_machine: FallStateMachine,
keypoint_confidence_threshold: float,
) -> None:
if not 0.0 <= keypoint_confidence_threshold <= 1.0:
raise ValueError("keypoint_confidence_threshold must be between 0 and 1")
self._pose_adapter = pose_adapter
self._tracker = tracker
self._policy = policy
self._state_machine = state_machine
self._keypoint_confidence_threshold = float(keypoint_confidence_threshold)
self._previous_evidence: Dict[str, PoseEvidence] = {}
self._active_track_ids = set()
@classmethod
def from_config(cls, config: AppConfig, pose_adapter) -> "FallPipeline":
"""Create one immutable runtime decision flow from validated config."""
return cls(
pose_adapter=pose_adapter,
tracker=PersonTracker(),
policy=FallEvidencePolicy(config.event.suspect_window_seconds),
state_machine=FallStateMachine(
confirm_window_seconds=config.event.confirm_window_seconds,
recovery_window_seconds=config.event.recovery_window_seconds,
cooldown_seconds=config.event.cooldown_seconds,
config_version=config.runtime_config_version,
),
keypoint_confidence_threshold=config.event.keypoint_confidence_threshold,
)
def process(self, packet: FramePacket) -> FrameAnalysis:
if packet.status is not SourceStatus.CONNECTED or packet.image is None:
events = self._reject_active_tracks(packet.timestamp_monotonic)
return FrameAnalysis(packet=packet, people=(), events=tuple(events))
height, width = packet.image.shape[:2]
poses = self._pose_adapter.infer(packet.image)
tracked_poses = self._tracker.update(
poses,
detected_at_monotonic=packet.timestamp_monotonic,
frame_size=(width, height),
)
current_ids = {tracked.track_id for tracked in tracked_poses}
events = self._reject_missing_tracks(current_ids, packet.timestamp_monotonic)
people = []
for tracked in tracked_poses:
quality = assess_pose_quality(
tracked.pose, threshold=self._keypoint_confidence_threshold
)
pose_evidence = extract_evidence(
tracked.pose,
quality=quality,
previous=self._previous_evidence.get(tracked.track_id),
)
state_before = self._state_machine.state_of(tracked.track_id)
evidence = self._policy.evaluate(
tracked.track_id,
pose_evidence,
now=packet.timestamp_monotonic,
state=state_before,
)
events.extend(
self._state_machine.update(
tracked.track_id, evidence, now=packet.timestamp_monotonic
)
)
if pose_evidence.accepted:
self._previous_evidence[tracked.track_id] = pose_evidence
else:
self._previous_evidence.pop(tracked.track_id, None)
people.append(
PersonAnalysis(
tracked_pose=tracked,
pose_evidence=pose_evidence,
state=self._state_machine.state_of(tracked.track_id),
)
)
self._active_track_ids = current_ids
return FrameAnalysis(packet=packet, people=tuple(people), events=tuple(events))
def _reject_missing_tracks(self, current_ids: set, now: float) -> list:
missing_ids = self._active_track_ids - current_ids
events = []
for track_id in sorted(missing_ids):
events.extend(self._reject_track(track_id, now))
return events
def _reject_active_tracks(self, now: float) -> list:
events = []
for track_id in sorted(self._active_track_ids):
events.extend(self._reject_track(track_id, now))
self._active_track_ids = set()
return events
def _reject_track(self, track_id: str, now: float) -> Sequence[FallEvent]:
rejected = Evidence(accepted=False, is_fall_candidate=False)
self._previous_evidence.pop(track_id, None)
self._policy.evaluate(
track_id,
PoseEvidence(False, False, False, None, None, None, "missing_pose"),
now=now,
state=self._state_machine.state_of(track_id),
)
return self._state_machine.update(track_id, rejected, now=now)
+15
View File
@@ -54,3 +54,18 @@ def test_load_config_rejects_embedded_source_address(tmp_path):
with pytest.raises(ConfigError, match="rtsp_url_env"):
load_config(config_file)
def test_runtime_config_version_is_stable_and_excludes_rtsp_address(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL"},
)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@camera-a/live")
first = load_config(config_file)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:other-secret@camera-b/live")
second = load_config(config_file)
assert first.runtime_config_version == second.runtime_config_version
assert first.runtime_config_version.startswith("cfg-")
+53
View File
@@ -0,0 +1,53 @@
from v1.evidence import PoseEvidence
from v1.fall_policy import FallEvidencePolicy
from v1.fall_state import FallState
def _evidence(horizontal=False, rapid_drop=False, accepted=True):
return PoseEvidence(
accepted=accepted,
horizontal_pose=horizontal,
rapid_vertical_change=rapid_drop,
horizontal_angle_degrees=10.0 if horizontal else 80.0,
hip_center_y=100.0,
torso_length=50.0,
reason="accepted" if accepted else "missing_pose",
)
def test_rapid_drop_followed_by_horizontal_pose_within_suspect_window_starts_candidate():
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
onset = policy.evaluate(
"P-0001", _evidence(rapid_drop=True), now=0.0, state=FallState.NORMAL
)
candidate = policy.evaluate(
"P-0001", _evidence(horizontal=True), now=0.5, state=FallState.NORMAL
)
assert onset.is_fall_candidate is False
assert candidate.is_fall_candidate is True
def test_rejected_pose_clears_pending_drop_before_the_next_horizontal_pose():
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
policy.evaluate("P-0001", _evidence(rapid_drop=True), now=0.0, state=FallState.NORMAL)
policy.evaluate("P-0001", _evidence(accepted=False), now=0.1, state=FallState.NORMAL)
candidate = policy.evaluate(
"P-0001", _evidence(horizontal=True), now=0.2, state=FallState.NORMAL
)
assert candidate.accepted is True
assert candidate.is_fall_candidate is False
def test_upright_pose_is_recovery_evidence_only_after_confirmation():
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
recovery = policy.evaluate(
"P-0001", _evidence(horizontal=False), now=4.0, state=FallState.CONFIRMED
)
assert recovery.accepted is True
assert recovery.is_recovery_candidate is True
+51 -6
View File
@@ -3,8 +3,15 @@ import pytest
from v1.fall_state import Evidence, FallState, FallStateMachine
CONFIG_VERSION = "cfg-test-20260721"
def test_confirmed_event_is_emitted_once_after_persistent_evidence():
machine = FallStateMachine(confirm_window_seconds=1.8, recovery_window_seconds=2.0)
machine = FallStateMachine(
confirm_window_seconds=1.8,
recovery_window_seconds=2.0,
config_version=CONFIG_VERSION,
)
assert machine.update("P-0007", Evidence(True, True), now=0.0) == []
events = machine.update("P-0007", Evidence(True, True), now=1.8)
@@ -12,12 +19,17 @@ def test_confirmed_event_is_emitted_once_after_persistent_evidence():
assert len(events) == 1
assert events[0].track_id == "P-0007"
assert events[0].latency_seconds == 1.8
assert events[0].config_version == CONFIG_VERSION
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 = FallStateMachine(
confirm_window_seconds=1.0,
recovery_window_seconds=2.0,
config_version=CONFIG_VERSION,
)
machine.update("P-0007", Evidence(True, True), now=0.0)
events = machine.update("P-0007", Evidence(True, False), now=0.3)
@@ -27,7 +39,11 @@ def test_brief_low_posture_returns_to_normal_without_event():
def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window():
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
machine = FallStateMachine(
confirm_window_seconds=1.0,
recovery_window_seconds=2.0,
config_version=CONFIG_VERSION,
)
machine.update("P-0007", Evidence(True, True), now=0.0)
machine.update("P-0007", Evidence(False, False), now=0.9)
@@ -38,7 +54,11 @@ def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window():
def test_recovery_must_persist_before_new_event_is_allowed():
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
machine = FallStateMachine(
confirm_window_seconds=1.0,
recovery_window_seconds=2.0,
config_version=CONFIG_VERSION,
)
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
@@ -55,11 +75,19 @@ def test_recovery_must_persist_before_new_event_is_allowed():
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)
FallStateMachine(
confirm_window_seconds=0.9,
recovery_window_seconds=2.0,
config_version=CONFIG_VERSION,
)
def test_each_track_has_an_independent_confirmation_window():
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
machine = FallStateMachine(
confirm_window_seconds=1.0,
recovery_window_seconds=2.0,
config_version=CONFIG_VERSION,
)
machine.update("P-0001", Evidence(True, True), now=0.0)
machine.update("P-0002", Evidence(True, True), now=0.6)
@@ -69,3 +97,20 @@ def test_each_track_has_an_independent_confirmation_window():
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
def test_cooldown_delays_recovery_after_confirmation():
machine = FallStateMachine(
confirm_window_seconds=1.0,
recovery_window_seconds=2.0,
config_version=CONFIG_VERSION,
cooldown_seconds=3.0,
)
machine.update("P-0007", Evidence(True, True), now=0.0)
machine.update("P-0007", Evidence(True, True), now=1.0)
machine.update("P-0007", Evidence(True, False, True), now=1.1)
assert machine.state_of("P-0007") is FallState.CONFIRMED
machine.update("P-0007", Evidence(True, False, True), now=4.0)
assert machine.state_of("P-0007") is FallState.RECOVERING
+121
View File
@@ -0,0 +1,121 @@
import numpy as np
from pathlib import Path
from v1.config import AppConfig, EventConfig
from v1.fall_policy import FallEvidencePolicy
from v1.fall_state import FallState, FallStateMachine
from v1.pipeline import FallPipeline
from v1.pose import Keypoint, PersonPose
from v1.tracking import PersonTracker
from v1.video_source import FramePacket, SourceStatus
class _SequencePoseAdapter:
def __init__(self, frames):
self._frames = iter(frames)
def infer(self, _image):
return next(self._frames)
def _pose(horizontal=False):
points = [Keypoint(float(index), float(index), 0.9) for index in range(17)]
if horizontal:
points[5] = Keypoint(20.0, 80.0, 0.9)
points[6] = Keypoint(30.0, 80.0, 0.9)
points[11] = Keypoint(70.0, 100.0, 0.9)
points[12] = Keypoint(80.0, 100.0, 0.9)
else:
points[5] = Keypoint(30.0, 10.0, 0.9)
points[6] = Keypoint(40.0, 10.0, 0.9)
points[11] = Keypoint(30.0, 30.0, 0.9)
points[12] = Keypoint(40.0, 30.0, 0.9)
return PersonPose(
box_xyxy=(20.0, 20.0, 160.0, 160.0),
box_confidence=0.9,
keypoints=tuple(points),
)
def _packet(timestamp):
return FramePacket(
image=np.zeros((180, 180, 3), dtype=np.uint8),
timestamp_monotonic=timestamp,
status=SourceStatus.CONNECTED,
)
def _pipeline(frames):
machine = FallStateMachine(
confirm_window_seconds=1.0,
recovery_window_seconds=2.0,
config_version="cfg-test-pipeline",
)
return (
FallPipeline(
pose_adapter=_SequencePoseAdapter(frames),
tracker=PersonTracker(),
policy=FallEvidencePolicy(suspect_window_seconds=0.5),
state_machine=machine,
keypoint_confidence_threshold=0.4,
),
machine,
)
def _config():
return AppConfig(
source_id="lobby-camera-01",
source_url="rtsp://not-written-to-disk/live",
model_path=Path("models/best.pt"),
model_sha256="a" * 64,
confidence_threshold=0.25,
event=EventConfig(
keypoint_confidence_threshold=0.4,
suspect_window_seconds=0.5,
confirm_window_seconds=1.0,
recovery_window_seconds=2.0,
cooldown_seconds=3.0,
),
event_dir=Path("artifacts/events"),
)
def test_pipeline_confirms_a_recent_drop_that_remains_horizontal():
pipeline, machine = _pipeline([(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)])
pipeline.process(_packet(0.0))
pipeline.process(_packet(0.1))
result = pipeline.process(_packet(1.1))
assert len(result.events) == 1
assert result.events[0].config_version == "cfg-test-pipeline"
assert machine.state_of("P-0001") is FallState.CONFIRMED
def test_pipeline_rejects_a_suspect_when_the_track_is_missing_for_one_frame():
pipeline, machine = _pipeline([(_pose(),), (_pose(horizontal=True),), (), (_pose(horizontal=True),)])
pipeline.process(_packet(0.0))
pipeline.process(_packet(0.1))
pipeline.process(_packet(0.2))
result = pipeline.process(_packet(1.1))
assert result.events == ()
assert machine.state_of("P-0001") is FallState.NORMAL
def test_pipeline_from_config_uses_runtime_version_for_confirmed_event():
config = _config()
pipeline = FallPipeline.from_config(
config,
pose_adapter=_SequencePoseAdapter(
[(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)]
),
)
pipeline.process(_packet(0.0))
pipeline.process(_packet(0.1))
result = pipeline.process(_packet(1.1))
assert result.events[0].config_version == config.runtime_config_version
+34 -5
View File
@@ -2,7 +2,7 @@ import cv2
import numpy as np
import pytest
from v1.video_source import SourceStatus, VideoSource
from v1.video_source import SourceMode, SourceStatus, VideoSource
def _write_sample_video(path):
@@ -18,7 +18,7 @@ def _write_sample_video(path):
def test_file_source_emits_monotonic_timestamps(tmp_path):
sample_video = tmp_path / "sample.avi"
_write_sample_video(sample_video)
source = VideoSource(sample_video, reconnect=False)
source = VideoSource(sample_video, mode=SourceMode.REPLAY)
first = source.read(now=10.0)
second = source.read(now=10.1)
@@ -31,7 +31,7 @@ def test_file_source_emits_monotonic_timestamps(tmp_path):
def test_missing_source_returns_error_state_without_frame(tmp_path):
source = VideoSource(tmp_path / "missing.avi", reconnect=False)
source = VideoSource(tmp_path / "missing.avi", mode=SourceMode.REPLAY)
packet = source.read(now=1.0)
@@ -77,7 +77,7 @@ def test_reconnect_waits_then_reopens_with_bounded_backoff():
source = VideoSource(
"demo-source",
reconnect=True,
mode=SourceMode.STREAM,
retry_initial_seconds=2.0,
retry_max_seconds=2.0,
capture_factory=lambda _source: captures.pop(0),
@@ -119,7 +119,9 @@ class _NegativeFirstTimestampCapture:
def test_negative_first_timestamp_falls_back_to_frame_index_and_fps():
source = VideoSource(
"demo-source", reconnect=False, capture_factory=lambda _source: _NegativeFirstTimestampCapture()
"demo-source",
mode=SourceMode.REPLAY,
capture_factory=lambda _source: _NegativeFirstTimestampCapture(),
)
first = source.read(now=10.0)
@@ -127,3 +129,30 @@ def test_negative_first_timestamp_falls_back_to_frame_index_and_fps():
assert first.timestamp_monotonic == 0.0
assert second.timestamp_monotonic == pytest.approx(1.0 / 30.0)
def test_replay_source_reports_eof_without_restarting(tmp_path):
sample_video = tmp_path / "sample.avi"
_write_sample_video(sample_video)
source = VideoSource(sample_video, mode=SourceMode.REPLAY)
for timestamp in (0.0, 0.1, 0.2):
assert source.read(now=timestamp).status is SourceStatus.CONNECTED
eof = source.read(now=0.3)
still_eof = source.read(now=0.4)
assert eof.status is SourceStatus.EOF
assert still_eof.status is SourceStatus.EOF
def test_stream_source_uses_read_clock_instead_of_capture_timestamp():
source = VideoSource(
"rtsp://not-a-real-address",
mode=SourceMode.STREAM,
capture_factory=lambda _source: _OpenCapture(),
)
packet = source.read(now=42.0)
assert packet.status is SourceStatus.CONNECTED
assert packet.timestamp_monotonic == 42.0
+18 -5
View File
@@ -18,6 +18,11 @@ class SourceStatus(str, Enum):
CLOSED = "closed"
class SourceMode(str, Enum):
REPLAY = "replay"
STREAM = "stream"
@dataclass(frozen=True)
class FramePacket:
image: Optional[np.ndarray]
@@ -35,7 +40,7 @@ class VideoSource:
def __init__(
self,
source: Union[str, Path],
reconnect: bool = True,
mode: SourceMode,
retry_initial_seconds: float = 1.0,
retry_max_seconds: float = 16.0,
capture_factory: Optional[CaptureFactory] = None,
@@ -44,13 +49,15 @@ class VideoSource:
raise ValueError("retry_initial_seconds must be positive")
if retry_max_seconds < retry_initial_seconds:
raise ValueError("retry_max_seconds must not be smaller than retry_initial_seconds")
if not isinstance(mode, SourceMode):
raise ValueError("mode must be a SourceMode")
self._source = str(source)
self._reconnect = reconnect
self._mode = mode
self._retry_initial_seconds = retry_initial_seconds
self._retry_max_seconds = retry_max_seconds
self._capture_factory = capture_factory or cv2.VideoCapture
self._capture = None
self._status = SourceStatus.RETRYING if reconnect else SourceStatus.ERROR
self._status = SourceStatus.RETRYING if mode is SourceMode.STREAM else SourceStatus.ERROR
self._retry_delay_seconds = retry_initial_seconds
self._next_retry_at = 0.0
self._last_timestamp: Optional[float] = None
@@ -77,6 +84,9 @@ class VideoSource:
if self._closed:
return self._packet(timestamp, SourceStatus.CLOSED, "source is closed")
if self._mode is SourceMode.REPLAY and self._status is SourceStatus.EOF:
return self._packet(timestamp, SourceStatus.EOF, self._last_error)
if self._capture is None:
if self._status is SourceStatus.RETRYING and timestamp < self._next_retry_at:
return self._packet(timestamp, SourceStatus.RETRYING, self._last_error)
@@ -86,7 +96,7 @@ class VideoSource:
success, image = self._capture.read()
if not success or image is None:
self._release_capture()
if self._reconnect:
if self._mode is SourceMode.STREAM:
self._schedule_retry(timestamp, "frame read failed; retry scheduled")
return self._packet(timestamp, SourceStatus.RETRYING, self._last_error)
self._status = SourceStatus.EOF
@@ -104,7 +114,7 @@ class VideoSource:
if capture is None or not capture.isOpened():
if capture is not None:
capture.release()
if self._reconnect:
if self._mode is SourceMode.STREAM:
self._schedule_retry(now, "unable to open source; retry scheduled")
else:
self._status = SourceStatus.ERROR
@@ -115,6 +125,9 @@ class VideoSource:
return True
def _frame_timestamp(self, fallback_now: float) -> float:
if self._mode is SourceMode.STREAM:
self._last_timestamp = fallback_now
return fallback_now
source_seconds = float(self._capture.get(cv2.CAP_PROP_POS_MSEC)) / 1000.0
frame_seconds = self._timestamp_from_frame_index()
if source_seconds < 0: