diff --git a/docs/06-tasks.md b/docs/06-tasks.md index b1a368c..247204b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -33,7 +33,7 @@ | T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | 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-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 迁移门槛与实现 diff --git a/docs/api.md b/docs/api.md index 21c1501..af07e68 100644 --- a/docs/api.md +++ b/docs/api.md @@ -39,6 +39,7 @@ - 凭证(`host`/`username`/`password`/解析出的 URL)绝不进入 `runtime_config_version`、事件 JSONL、日志或截图文件名。 - `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 必须以受控模型的真实哈希替换并验证后才能启动推理。 +- 灵敏度参数(默认放宽,适配低位俯视):`require_rapid_drop`(默认 false,持续水平即可进入疑似,不再强制先检测到快速下移);`require_lower_body`(默认 false,质量门控只强制肩+髋,膝踝可缺);`horizontal_angle_threshold_degrees`(默认 45,躯干与水平夹角≤此值算水平)。放宽后由 `confirm_window_seconds` 作为主要防误报闸——弯腰、捡物不会持续水平到确认窗满。三者进入 `config_version`,可经设置调整并追溯。 - 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。 ## 核心数据 diff --git a/docs/current-state.md b/docs/current-state.md index 0c41a17..1ec25ff 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,13 +5,13 @@ ## 当前快照 - 日期: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` 单元测试与假适配器管线冒烟覆盖视图逻辑。 - 旧生产基线:`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)。 - 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 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` 哈希一致。 - 当前标准启动:`./init.ps1`。 - 当前标准验证:`python -m compileall -q demo`。 diff --git a/progress.md b/progress.md index 1993028..2fa0749 100644 --- a/progress.md +++ b/progress.md @@ -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`,可日后经设置页微调。 - 下一步:先写放宽后的失败测试(水平即候选、缺膝踝仍接受),再实现并接线。 + +## 【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。 diff --git a/v1/config.example.json b/v1/config.example.json index b3a5c24..c46f67a 100644 --- a/v1/config.example.json +++ b/v1/config.example.json @@ -17,7 +17,10 @@ "suspect_window_seconds": 0.5, "confirm_window_seconds": 1.8, "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": { "event_dir": "../artifacts/events" diff --git a/v1/config.py b/v1/config.py index 9d343e2..683a345 100644 --- a/v1/config.py +++ b/v1/config.py @@ -69,6 +69,9 @@ class EventConfig: confirm_window_seconds: float recovery_window_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) @@ -105,6 +108,9 @@ class AppConfig: "confirm_window_seconds": self.event.confirm_window_seconds, "recovery_window_seconds": self.event.recovery_window_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=(",", ":")) @@ -139,6 +145,12 @@ def _number(value: Any, field_name: str, minimum: float, maximum: float) -> floa 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: raw_path = Path(_text(value, field_name)) if raw_path.is_absolute(): @@ -274,6 +286,14 @@ def load_config(path: Path) -> AppConfig: 0.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") diff --git a/v1/evidence.py b/v1/evidence.py index 8c125db..23b0e92 100644 --- a/v1/evidence.py +++ b/v1/evidence.py @@ -7,7 +7,9 @@ from typing import Optional 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) @@ -29,7 +31,7 @@ class PoseEvidence: def assess_pose_quality( - pose: Optional[PersonPose], threshold: float + pose: Optional[PersonPose], threshold: float, require_lower_body: bool = True ) -> PoseQuality: if not 0.0 <= threshold <= 1.0: raise ValueError("threshold must be between 0 and 1") @@ -40,7 +42,8 @@ def assess_pose_quality( visible_count = sum( 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(True, "accepted", visible_count) diff --git a/v1/fall_policy.py b/v1/fall_policy.py index f49ec33..6347091 100644 --- a/v1/fall_policy.py +++ b/v1/fall_policy.py @@ -9,8 +9,11 @@ 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: + def __init__( + self, suspect_window_seconds: float, require_rapid_drop: bool = True + ) -> None: self._suspect_window_seconds = float(suspect_window_seconds) + self._require_rapid_drop = bool(require_rapid_drop) self._rapid_drop_at: Dict[str, float] = {} def evaluate( @@ -30,8 +33,13 @@ class FallEvidencePolicy: 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 + elif pose_evidence.horizontal_pose: + 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 = ( state in (FallState.CONFIRMED, FallState.RECOVERING) diff --git a/v1/pipeline.py b/v1/pipeline.py index f7b8cde..6869e47 100644 --- a/v1/pipeline.py +++ b/v1/pipeline.py @@ -49,6 +49,8 @@ class FallPipeline: policy: FallEvidencePolicy, state_machine: FallStateMachine, keypoint_confidence_threshold: float, + require_lower_body: bool = True, + horizontal_angle_threshold_degrees: float = 35.0, ) -> None: if not 0.0 <= keypoint_confidence_threshold <= 1.0: raise ValueError("keypoint_confidence_threshold must be between 0 and 1") @@ -57,6 +59,8 @@ class FallPipeline: self._policy = policy self._state_machine = state_machine 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._active_track_ids = set() @@ -74,7 +78,10 @@ class FallPipeline: return cls( pose_adapter=pose_adapter, 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( confirm_window_seconds=config.event.confirm_window_seconds, recovery_window_seconds=config.event.recovery_window_seconds, @@ -83,6 +90,8 @@ class FallPipeline: session_id=session_id or new_session_id(), ), 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: @@ -102,12 +111,15 @@ class FallPipeline: people = [] for tracked in tracked_poses: 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( tracked.pose, quality=quality, 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) evidence = self._policy.evaluate( diff --git a/v1/tests/test_config.py b/v1/tests/test_config.py index 9c3fc79..9046ae3 100644 --- a/v1/tests/test_config.py +++ b/v1/tests/test_config.py @@ -140,6 +140,20 @@ def test_public_example_config_has_no_embedded_credentials(): 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(): 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" diff --git a/v1/tests/test_evidence.py b/v1/tests/test_evidence.py index 0020a2e..b39092f 100644 --- a/v1/tests/test_evidence.py +++ b/v1/tests/test_evidence.py @@ -36,6 +36,14 @@ def test_missing_ankles_rejects_pose(): 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(): pose = _pose(horizontal=True) quality = assess_pose_quality(pose, threshold=0.4) diff --git a/v1/tests/test_fall_policy.py b/v1/tests/test_fall_policy.py index e123a5c..26f96e2 100644 --- a/v1/tests/test_fall_policy.py +++ b/v1/tests/test_fall_policy.py @@ -42,6 +42,20 @@ def test_rejected_pose_clears_pending_drop_before_the_next_horizontal_pose(): 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(): policy = FallEvidencePolicy(suspect_window_seconds=0.5)