feat(v1): relax fall sensitivity for low overhead cameras
Adds configurable event.require_rapid_drop (default off), require_lower_body (default off) and horizontal_angle_threshold_degrees (default 45). With the lenient defaults a sustained horizontal pose alone enters SUSPECT and confirms after the confirm window, which is now the main false-positive guard; missing knees/ankles no longer reject the pose. Thresholds flow into config_version. End-to-end smoke confirms a lying pose; 70 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE |
|
| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE |
|
||||||
| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DONE |
|
| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DONE |
|
||||||
| T-208 | 每帧摔倒判定诊断叠加 | T-201 | 管线暴露每人策略证据;实时画面角落叠加每人 `accepted/horizontal/角度/rapid/candidate/state`(拒绝时显示原因),便于定位真实摔倒未确认卡在哪一步;诊断字符串构建 Qt-free 可测。 | DONE |
|
| T-208 | 每帧摔倒判定诊断叠加 | T-201 | 管线暴露每人策略证据;实时画面角落叠加每人 `accepted/horizontal/角度/rapid/candidate/state`(拒绝时显示原因),便于定位真实摔倒未确认卡在哪一步;诊断字符串构建 Qt-free 可测。 | DONE |
|
||||||
| T-209 | 放宽俯视场景摔倒判定灵敏度(可配置) | T-208 | 判定灵敏度参数化:`require_rapid_drop`(默认关)、`require_lower_body`(默认关)、`horizontal_angle_threshold_degrees`(默认放宽);默认下持续水平即可进入疑似,仅靠确认窗防误报;参数进 config 与 config_version,逻辑可测。 | DOING |
|
| T-209 | 放宽俯视场景摔倒判定灵敏度(可配置) | T-208 | 判定灵敏度参数化:`require_rapid_drop`(默认关)、`require_lower_body`(默认关)、`horizontal_angle_threshold_degrees`(默认放宽);默认下持续水平即可进入疑似,仅靠确认窗防误报;参数进 config 与 config_version,逻辑可测。 | DONE |
|
||||||
|
|
||||||
## Phase 3 · Go V2 迁移门槛与实现
|
## Phase 3 · Go V2 迁移门槛与实现
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
- 凭证(`host`/`username`/`password`/解析出的 URL)绝不进入 `runtime_config_version`、事件 JSONL、日志或截图文件名。
|
- 凭证(`host`/`username`/`password`/解析出的 URL)绝不进入 `runtime_config_version`、事件 JSONL、日志或截图文件名。
|
||||||
- `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。
|
- `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。
|
||||||
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
|
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
|
||||||
|
- 灵敏度参数(默认放宽,适配低位俯视):`require_rapid_drop`(默认 false,持续水平即可进入疑似,不再强制先检测到快速下移);`require_lower_body`(默认 false,质量门控只强制肩+髋,膝踝可缺);`horizontal_angle_threshold_degrees`(默认 45,躯干与水平夹角≤此值算水平)。放宽后由 `confirm_window_seconds` 作为主要防误报闸——弯腰、捡物不会持续水平到确认窗满。三者进入 `config_version`,可经设置调整并追溯。
|
||||||
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
|
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
|
||||||
|
|
||||||
## 核心数据
|
## 核心数据
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-21
|
- 日期:2026-07-21
|
||||||
- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件、结构化摄像头配置/连接测试、抓流低延迟参数与每帧判定诊断叠加已建立;T-202、T-206、T-207、T-208 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。
|
- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件、结构化摄像头配置/连接测试、抓流低延迟参数与每帧判定诊断叠加已建立;T-202、T-206、T-207、T-208、T-209 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。判定灵敏度默认已放宽(低位俯视:持续水平即可疑,膝踝可缺,水平阈值 45°),确认窗为主防误报闸;真实误报率待 T-204 验证。
|
||||||
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。
|
||||||
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
|
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
|
||||||
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。
|
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。
|
||||||
- V2 代码:`v2/` 目录存在但尚无实现。
|
- 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 任务顺序。
|
- 非代码设计工件: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 v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 67 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串和每帧诊断测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 70 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串、每帧诊断和灵敏度放宽测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||||
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
||||||
- 当前标准启动:`./init.ps1`。
|
- 当前标准启动:`./init.ps1`。
|
||||||
- 当前标准验证:`python -m compileall -q demo`。
|
- 当前标准验证:`python -m compileall -q demo`。
|
||||||
|
|||||||
@@ -307,3 +307,12 @@
|
|||||||
- 阻塞:无(纯判定逻辑与配置,本环境可测)。
|
- 阻塞:无(纯判定逻辑与配置,本环境可测)。
|
||||||
- 决策:把三处严格约束参数化并默认放宽——`require_rapid_drop`(默认 False,持续水平即可进入疑似)、`require_lower_body`(默认 False,质量门控只强制肩+髋,膝踝可缺)、`horizontal_angle_threshold_degrees`(默认 45,原 35)。确认窗 `confirm_window_seconds` 保持不变作为唯一防误报主闸(弯腰/捡物不会持续水平 1.8s)。参数进 `config` 与 `runtime_config_version`,可日后经设置页微调。
|
- 决策:把三处严格约束参数化并默认放宽——`require_rapid_drop`(默认 False,持续水平即可进入疑似)、`require_lower_body`(默认 False,质量门控只强制肩+髋,膝踝可缺)、`horizontal_angle_threshold_degrees`(默认 45,原 35)。确认窗 `confirm_window_seconds` 保持不变作为唯一防误报主闸(弯腰/捡物不会持续水平 1.8s)。参数进 `config` 与 `runtime_config_version`,可日后经设置页微调。
|
||||||
- 下一步:先写放宽后的失败测试(水平即候选、缺膝踝仍接受),再实现并接线。
|
- 下一步:先写放宽后的失败测试(水平即候选、缺膝踝仍接受),再实现并接线。
|
||||||
|
|
||||||
|
## 【2026-07-21】T-209 放宽俯视场景摔倒判定灵敏度(可配置)(完成)
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:`fall_policy.py` 的 `FallEvidencePolicy` 增 `require_rapid_drop`(构造默认 True 保持旧测试;关闭时持续水平即候选)。`evidence.py` 拆分 `_TORSO_JOINTS(肩髋)`/`_LOWER_JOINTS(膝踝)`,`assess_pose_quality` 增 `require_lower_body`(关闭时只强制肩+髋)。`pipeline.py` 增 `require_lower_body`/`horizontal_angle_threshold_degrees` 并在 `process` 传入,`from_config` 从 config 接线三者。`config.py` 的 `EventConfig` 增三字段(默认 `require_rapid_drop=false`/`require_lower_body=false`/`horizontal_angle_threshold_degrees=45`)、`load_config` 解析(新增 `_bool`)、`runtime_config_version` 纳入三者;`config.example.json` 加默认值。
|
||||||
|
- 验证:新增测试——fall_policy(关闭 rapid 门槛后水平即候选)、evidence(关闭下肢要求后缺膝踝仍接受)、config(灵敏度默认放宽);`python3 -m pytest v1/tests -q` 为 70 passed;`python3 -m compileall -q v1 demo` 退出码 0。端到端冒烟:以放宽默认 `from_config` 驱动横臥姿势(躯干 2°、膝踝低置信)——t=0 即进 SUSPECT(`horiz=1 cand=1`),持续到 t=1.9(≥confirm 1.8s)产生一个 `CONFIRMED` 事件;证明放宽后真实躺姿可确认。
|
||||||
|
- 阻塞:无。这些是默认放宽、可日后经设置微调的判定参数;真实误报率仍需 T-204 现场正反例录像验证。
|
||||||
|
- 决策:不删任何信号,只把 rapid/下肢/角度三处严格约束参数化并默认放宽;确认窗 1.8s 作为唯一主防误报闸。函数级默认保持严格(不破坏既有单测),生产默认由 config 放宽。
|
||||||
|
- 下一步:用户以放宽默认复测真实摔倒是否变红并出截图;若出现弯腰/捡物误报,用设置微调 `confirm_window_seconds` 或重开 `require_rapid_drop`;继续 T-203/T-204。
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
"suspect_window_seconds": 0.5,
|
"suspect_window_seconds": 0.5,
|
||||||
"confirm_window_seconds": 1.8,
|
"confirm_window_seconds": 1.8,
|
||||||
"recovery_window_seconds": 2.0,
|
"recovery_window_seconds": 2.0,
|
||||||
"cooldown_seconds": 10.0
|
"cooldown_seconds": 10.0,
|
||||||
|
"require_rapid_drop": false,
|
||||||
|
"require_lower_body": false,
|
||||||
|
"horizontal_angle_threshold_degrees": 45.0
|
||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"event_dir": "../artifacts/events"
|
"event_dir": "../artifacts/events"
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class EventConfig:
|
|||||||
confirm_window_seconds: float
|
confirm_window_seconds: float
|
||||||
recovery_window_seconds: float
|
recovery_window_seconds: float
|
||||||
cooldown_seconds: float
|
cooldown_seconds: float
|
||||||
|
require_rapid_drop: bool = False
|
||||||
|
require_lower_body: bool = False
|
||||||
|
horizontal_angle_threshold_degrees: float = 45.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -105,6 +108,9 @@ class AppConfig:
|
|||||||
"confirm_window_seconds": self.event.confirm_window_seconds,
|
"confirm_window_seconds": self.event.confirm_window_seconds,
|
||||||
"recovery_window_seconds": self.event.recovery_window_seconds,
|
"recovery_window_seconds": self.event.recovery_window_seconds,
|
||||||
"cooldown_seconds": self.event.cooldown_seconds,
|
"cooldown_seconds": self.event.cooldown_seconds,
|
||||||
|
"require_rapid_drop": self.event.require_rapid_drop,
|
||||||
|
"require_lower_body": self.event.require_lower_body,
|
||||||
|
"horizontal_angle_threshold_degrees": self.event.horizontal_angle_threshold_degrees,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
@@ -139,6 +145,12 @@ def _number(value: Any, field_name: str, minimum: float, maximum: float) -> floa
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(value: Any, field_name: str) -> bool:
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ConfigError("{0} must be a boolean".format(field_name))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path:
|
def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path:
|
||||||
raw_path = Path(_text(value, field_name))
|
raw_path = Path(_text(value, field_name))
|
||||||
if raw_path.is_absolute():
|
if raw_path.is_absolute():
|
||||||
@@ -274,6 +286,14 @@ def load_config(path: Path) -> AppConfig:
|
|||||||
0.0,
|
0.0,
|
||||||
3600.0,
|
3600.0,
|
||||||
),
|
),
|
||||||
|
require_rapid_drop=_bool(event_raw.get("require_rapid_drop", False), "event.require_rapid_drop"),
|
||||||
|
require_lower_body=_bool(event_raw.get("require_lower_body", False), "event.require_lower_body"),
|
||||||
|
horizontal_angle_threshold_degrees=_number(
|
||||||
|
event_raw.get("horizontal_angle_threshold_degrees", 45.0),
|
||||||
|
"event.horizontal_angle_threshold_degrees",
|
||||||
|
0.0,
|
||||||
|
90.0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
artifacts = _mapping(root.get("artifacts"), "artifacts")
|
artifacts = _mapping(root.get("artifacts"), "artifacts")
|
||||||
|
|||||||
+6
-3
@@ -7,7 +7,9 @@ from typing import Optional
|
|||||||
from v1.pose import PersonPose
|
from v1.pose import PersonPose
|
||||||
|
|
||||||
|
|
||||||
_REQUIRED_JOINTS = (5, 6, 11, 12, 13, 14, 15, 16)
|
_TORSO_JOINTS = (5, 6, 11, 12) # shoulders and hips (used by the geometry)
|
||||||
|
_LOWER_JOINTS = (13, 14, 15, 16) # knees and ankles (often occluded in a fall)
|
||||||
|
_REQUIRED_JOINTS = _TORSO_JOINTS + _LOWER_JOINTS
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -29,7 +31,7 @@ class PoseEvidence:
|
|||||||
|
|
||||||
|
|
||||||
def assess_pose_quality(
|
def assess_pose_quality(
|
||||||
pose: Optional[PersonPose], threshold: float
|
pose: Optional[PersonPose], threshold: float, require_lower_body: bool = True
|
||||||
) -> PoseQuality:
|
) -> PoseQuality:
|
||||||
if not 0.0 <= threshold <= 1.0:
|
if not 0.0 <= threshold <= 1.0:
|
||||||
raise ValueError("threshold must be between 0 and 1")
|
raise ValueError("threshold must be between 0 and 1")
|
||||||
@@ -40,7 +42,8 @@ def assess_pose_quality(
|
|||||||
visible_count = sum(
|
visible_count = sum(
|
||||||
point.confidence >= threshold for point in pose.keypoints
|
point.confidence >= threshold for point in pose.keypoints
|
||||||
)
|
)
|
||||||
if any(pose.keypoints[index].confidence < threshold for index in _REQUIRED_JOINTS):
|
required = _REQUIRED_JOINTS if require_lower_body else _TORSO_JOINTS
|
||||||
|
if any(pose.keypoints[index].confidence < threshold for index in required):
|
||||||
return PoseQuality(False, "required_joint_low_confidence", visible_count)
|
return PoseQuality(False, "required_joint_low_confidence", visible_count)
|
||||||
return PoseQuality(True, "accepted", visible_count)
|
return PoseQuality(True, "accepted", visible_count)
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -9,8 +9,11 @@ from v1.fall_state import Evidence, FallState
|
|||||||
class FallEvidencePolicy:
|
class FallEvidencePolicy:
|
||||||
"""Require a recent rapid drop before a horizontal pose becomes a candidate."""
|
"""Require a recent rapid drop before a horizontal pose becomes a candidate."""
|
||||||
|
|
||||||
def __init__(self, suspect_window_seconds: float) -> None:
|
def __init__(
|
||||||
|
self, suspect_window_seconds: float, require_rapid_drop: bool = True
|
||||||
|
) -> None:
|
||||||
self._suspect_window_seconds = float(suspect_window_seconds)
|
self._suspect_window_seconds = float(suspect_window_seconds)
|
||||||
|
self._require_rapid_drop = bool(require_rapid_drop)
|
||||||
self._rapid_drop_at: Dict[str, float] = {}
|
self._rapid_drop_at: Dict[str, float] = {}
|
||||||
|
|
||||||
def evaluate(
|
def evaluate(
|
||||||
@@ -30,8 +33,13 @@ class FallEvidencePolicy:
|
|||||||
candidate = False
|
candidate = False
|
||||||
if state is FallState.SUSPECT:
|
if state is FallState.SUSPECT:
|
||||||
candidate = pose_evidence.horizontal_pose
|
candidate = pose_evidence.horizontal_pose
|
||||||
elif pose_evidence.horizontal_pose and track_id in self._rapid_drop_at:
|
elif pose_evidence.horizontal_pose:
|
||||||
candidate = timestamp - self._rapid_drop_at[track_id] <= self._suspect_window_seconds
|
if not self._require_rapid_drop:
|
||||||
|
candidate = True
|
||||||
|
elif track_id in self._rapid_drop_at:
|
||||||
|
candidate = (
|
||||||
|
timestamp - self._rapid_drop_at[track_id] <= self._suspect_window_seconds
|
||||||
|
)
|
||||||
|
|
||||||
recovery = (
|
recovery = (
|
||||||
state in (FallState.CONFIRMED, FallState.RECOVERING)
|
state in (FallState.CONFIRMED, FallState.RECOVERING)
|
||||||
|
|||||||
+14
-2
@@ -49,6 +49,8 @@ class FallPipeline:
|
|||||||
policy: FallEvidencePolicy,
|
policy: FallEvidencePolicy,
|
||||||
state_machine: FallStateMachine,
|
state_machine: FallStateMachine,
|
||||||
keypoint_confidence_threshold: float,
|
keypoint_confidence_threshold: float,
|
||||||
|
require_lower_body: bool = True,
|
||||||
|
horizontal_angle_threshold_degrees: float = 35.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not 0.0 <= keypoint_confidence_threshold <= 1.0:
|
if not 0.0 <= keypoint_confidence_threshold <= 1.0:
|
||||||
raise ValueError("keypoint_confidence_threshold must be between 0 and 1")
|
raise ValueError("keypoint_confidence_threshold must be between 0 and 1")
|
||||||
@@ -57,6 +59,8 @@ class FallPipeline:
|
|||||||
self._policy = policy
|
self._policy = policy
|
||||||
self._state_machine = state_machine
|
self._state_machine = state_machine
|
||||||
self._keypoint_confidence_threshold = float(keypoint_confidence_threshold)
|
self._keypoint_confidence_threshold = float(keypoint_confidence_threshold)
|
||||||
|
self._require_lower_body = bool(require_lower_body)
|
||||||
|
self._horizontal_angle_threshold_degrees = float(horizontal_angle_threshold_degrees)
|
||||||
self._previous_evidence: Dict[str, PoseEvidence] = {}
|
self._previous_evidence: Dict[str, PoseEvidence] = {}
|
||||||
self._active_track_ids = set()
|
self._active_track_ids = set()
|
||||||
|
|
||||||
@@ -74,7 +78,10 @@ class FallPipeline:
|
|||||||
return cls(
|
return cls(
|
||||||
pose_adapter=pose_adapter,
|
pose_adapter=pose_adapter,
|
||||||
tracker=PersonTracker(),
|
tracker=PersonTracker(),
|
||||||
policy=FallEvidencePolicy(config.event.suspect_window_seconds),
|
policy=FallEvidencePolicy(
|
||||||
|
config.event.suspect_window_seconds,
|
||||||
|
require_rapid_drop=config.event.require_rapid_drop,
|
||||||
|
),
|
||||||
state_machine=FallStateMachine(
|
state_machine=FallStateMachine(
|
||||||
confirm_window_seconds=config.event.confirm_window_seconds,
|
confirm_window_seconds=config.event.confirm_window_seconds,
|
||||||
recovery_window_seconds=config.event.recovery_window_seconds,
|
recovery_window_seconds=config.event.recovery_window_seconds,
|
||||||
@@ -83,6 +90,8 @@ class FallPipeline:
|
|||||||
session_id=session_id or new_session_id(),
|
session_id=session_id or new_session_id(),
|
||||||
),
|
),
|
||||||
keypoint_confidence_threshold=config.event.keypoint_confidence_threshold,
|
keypoint_confidence_threshold=config.event.keypoint_confidence_threshold,
|
||||||
|
require_lower_body=config.event.require_lower_body,
|
||||||
|
horizontal_angle_threshold_degrees=config.event.horizontal_angle_threshold_degrees,
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(self, packet: FramePacket) -> FrameAnalysis:
|
def process(self, packet: FramePacket) -> FrameAnalysis:
|
||||||
@@ -102,12 +111,15 @@ class FallPipeline:
|
|||||||
people = []
|
people = []
|
||||||
for tracked in tracked_poses:
|
for tracked in tracked_poses:
|
||||||
quality = assess_pose_quality(
|
quality = assess_pose_quality(
|
||||||
tracked.pose, threshold=self._keypoint_confidence_threshold
|
tracked.pose,
|
||||||
|
threshold=self._keypoint_confidence_threshold,
|
||||||
|
require_lower_body=self._require_lower_body,
|
||||||
)
|
)
|
||||||
pose_evidence = extract_evidence(
|
pose_evidence = extract_evidence(
|
||||||
tracked.pose,
|
tracked.pose,
|
||||||
quality=quality,
|
quality=quality,
|
||||||
previous=self._previous_evidence.get(tracked.track_id),
|
previous=self._previous_evidence.get(tracked.track_id),
|
||||||
|
horizontal_angle_threshold_degrees=self._horizontal_angle_threshold_degrees,
|
||||||
)
|
)
|
||||||
state_before = self._state_machine.state_of(tracked.track_id)
|
state_before = self._state_machine.state_of(tracked.track_id)
|
||||||
evidence = self._policy.evaluate(
|
evidence = self._policy.evaluate(
|
||||||
|
|||||||
@@ -140,6 +140,20 @@ def test_public_example_config_has_no_embedded_credentials():
|
|||||||
assert "rtsp_url_env" in source
|
assert "rtsp_url_env" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitivity_fields_default_to_lenient(tmp_path):
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
_write_config(
|
||||||
|
config_file,
|
||||||
|
{"id": "c", "host": "192.0.2.10", "username": "u", "password": "p"},
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_file)
|
||||||
|
|
||||||
|
assert config.event.require_rapid_drop is False
|
||||||
|
assert config.event.require_lower_body is False
|
||||||
|
assert config.event.horizontal_angle_threshold_degrees == 45.0
|
||||||
|
|
||||||
|
|
||||||
def test_build_ffmpeg_options_variants():
|
def test_build_ffmpeg_options_variants():
|
||||||
assert build_ffmpeg_options() == "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay"
|
assert build_ffmpeg_options() == "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay"
|
||||||
assert build_ffmpeg_options("udp", 3, False) == "rtsp_transport;udp|stimeout;3000000"
|
assert build_ffmpeg_options("udp", 3, False) == "rtsp_transport;udp|stimeout;3000000"
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ def test_missing_ankles_rejects_pose():
|
|||||||
assert quality.reason == "required_joint_low_confidence"
|
assert quality.reason == "required_joint_low_confidence"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_ankles_accepted_when_lower_body_not_required():
|
||||||
|
quality = assess_pose_quality(
|
||||||
|
_pose(missing_ankles=True), threshold=0.4, require_lower_body=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert quality.accepted is True
|
||||||
|
|
||||||
|
|
||||||
def test_horizontal_body_is_evidence_not_event():
|
def test_horizontal_body_is_evidence_not_event():
|
||||||
pose = _pose(horizontal=True)
|
pose = _pose(horizontal=True)
|
||||||
quality = assess_pose_quality(pose, threshold=0.4)
|
quality = assess_pose_quality(pose, threshold=0.4)
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ def test_rejected_pose_clears_pending_drop_before_the_next_horizontal_pose():
|
|||||||
assert candidate.is_fall_candidate is False
|
assert candidate.is_fall_candidate is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_horizontal_pose_alone_starts_candidate_when_rapid_drop_not_required():
|
||||||
|
policy = FallEvidencePolicy(suspect_window_seconds=0.5, require_rapid_drop=False)
|
||||||
|
|
||||||
|
candidate = policy.evaluate(
|
||||||
|
"P-0001", _evidence(horizontal=True), now=0.0, state=FallState.NORMAL
|
||||||
|
)
|
||||||
|
upright = policy.evaluate(
|
||||||
|
"P-0002", _evidence(horizontal=False), now=0.0, state=FallState.NORMAL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert candidate.is_fall_candidate is True
|
||||||
|
assert upright.is_fall_candidate is False
|
||||||
|
|
||||||
|
|
||||||
def test_upright_pose_is_recovery_evidence_only_after_confirmation():
|
def test_upright_pose_is_recovery_evidence_only_after_confirmation():
|
||||||
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
|
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user