diff --git a/docs/api.md b/docs/api.md index 1870e2c..f3fd75b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,6 +11,7 @@ "source": { "id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", + "mode": "stream", "roi_normalized": [0.0, 0.0, 1.0, 1.0] }, "model": { @@ -32,6 +33,8 @@ ``` - `rtsp_url_env` 必填;应用从同名环境变量读取真实 URL。 +- `source.mode` 显式声明来源类型,取值 `stream`(默认,实时 RTSP,允许有界重连)或 `replay`(本地录像,EOF 不重放);由配置决定,不再按 URL 前缀猜测。 +- `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 必须以受控模型的真实哈希替换并验证后才能启动推理。 - 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。 @@ -76,6 +79,8 @@ FallEvent = { `FallEvent` 是状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。`config_version` 是由运行配置快照计算的非敏感版本标识;T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间和证据。 +`event_id` 带每次监控运行的会话前缀(`FALL--NNNNNN`),跨监控重启全局唯一;因此同一天目录内的截图不会被覆盖,`events.jsonl` 也不会出现同 id 不同内容的记录。 + T-202 的 `alerts` 按 `event_id` 去重,对首次 CONFIRMED 只触发一次副作用(声音、弹窗、截图、JSONL 一行)。JSONL 记录如下,文件名与字段不含 RTSP 地址、凭证或客户姓名: ```text diff --git a/docs/current-state.md b/docs/current-state.md index d81700f..321098c 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,7 +11,7 @@ - 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` 提供声音与一次性弹窗)触发一次声音/弹窗。真实海康 RTSP 接入(T-203)尚未实现。 - 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` 语法);`python -m pytest v1/tests -v` 当前有 44 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型和报警工件测试并已通过。`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` 语法);`python -m pytest v1/tests -v` 当前有 51 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型和报警工件测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一的复核修复)。`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 7082848..ac99185 100644 --- a/progress.md +++ b/progress.md @@ -226,3 +226,12 @@ - 阻塞:现场海康摄像头、网络与真实 RTSP 凭证;经同意成年人的摔倒/反例录像与现场安全措施。决策人:用户/现场负责人。 - 决策:不以合成数据冒充现场 RTSP 预览或摔倒/反例录像验收(AGENTS.md:任务完成的证据是可运行命令与可观察结果,安全模拟摔倒必须使用经同意成年人与现场安全措施)。代码侧的重连、状态与幂等逻辑已就绪,解除阻塞后可直接进入现场验证。 - 下一步:待用户提供现场流/录像后领取 T-203;期间可按需在 Windows 执行 T-201/T-202 的 GUI、声音与弹窗可视化冒烟。 + +## 【2026-07-21】FIX T-201/T-202 复核问题 A/B/C + +- 状态:DONE +- 变更:修复三处复核问题。A:`PoseAdapter` 新增 `set_confidence_threshold`/`confidence_threshold`,`app.py` 在开始监控时按运行配置调用,使设置里的模型检测置信度真正生效(此前 adapter 置信度构造后固定、草稿字段为死旋钮)。B:`config.py` 新增显式 `source.mode`(`stream` 默认/`replay`,校验取值),`AppConfig.source_mode` 由配置决定,`app.py` 不再按 `source_url.startswith("rtsp")` 猜测来源模式;`config.example.json` 与 `docs/api.md` 同步。C:`FallStateMachine` 新增 `session_id`,`event_id` 变为 `FALL--NNNNNN`;`FallPipeline.from_config` 每次运行经 `new_session_id()` 生成进程内唯一会话,避免同一天内重启监控时截图被覆盖、JSONL 出现同 id 不同内容。 +- 验证:新增/更新测试——config 三项(默认 stream、解析 replay、非法 mode 被拒)、pose 两项(置信度转发到推理、越界被拒)、fall_state 一项(不同会话 event_id 唯一)、pipeline 一项(两次 from_config 的确认事件 id 不同);`python3 -m pytest v1/tests -q` 为 51 passed;`python3 -m compileall -q v1 demo` 退出码 0。 +- 阻塞:无。声音/弹窗/GUI 可视化冒烟仍需 Windows。 +- 决策:来源模式由配置显式声明而非猜测;模型置信度经适配器方法在下次启动生效,保持“运行配置快照”语义;事件身份以会话前缀保证跨轮唯一,dedup 基于该持久标识。 +- 下一步:T-203(受阻,等现场流)。 diff --git a/v1/app.py b/v1/app.py index 88d17fa..924ed59 100644 --- a/v1/app.py +++ b/v1/app.py @@ -34,7 +34,7 @@ class FrameWorker(QtCore.QThread): self._stop = False def run(self) -> None: - mode = SourceMode.STREAM if self._config.source_url.startswith("rtsp") else SourceMode.REPLAY + mode = SourceMode.STREAM if self._config.source_mode == "stream" else SourceMode.REPLAY source = VideoSource(self._config.source_url, mode=mode) pipeline = FallPipeline.from_config(self._config, self._pose_adapter) try: @@ -103,6 +103,7 @@ class ApplicationController: self._draft.start_monitoring() running = _running_config(self._config, self._draft) + self._pose_adapter.set_confidence_threshold(running.confidence_threshold) writer = EventArtifactWriter(running.event_dir, running.source_id) self._dispatcher = AlertDispatcher(writer, QtAlertSink(self.window)) worker = FrameWorker(running, self._pose_adapter) diff --git a/v1/config.example.json b/v1/config.example.json index 99dbfe6..83f2b84 100644 --- a/v1/config.example.json +++ b/v1/config.example.json @@ -1,7 +1,8 @@ { "source": { "id": "lobby-camera-01", - "rtsp_url_env": "SILVER_POSE_RTSP_URL" + "rtsp_url_env": "SILVER_POSE_RTSP_URL", + "mode": "stream" }, "model": { "path": "models/best.pt", diff --git a/v1/config.py b/v1/config.py index 405c493..0dcbae2 100644 --- a/v1/config.py +++ b/v1/config.py @@ -31,6 +31,7 @@ class AppConfig: confidence_threshold: float event: EventConfig event_dir: Path + source_mode: str = "stream" @property def runtime_config_version(self) -> str: @@ -113,6 +114,10 @@ def load_config(path: Path) -> AppConfig: if not source_url: raise ConfigError("missing RTSP environment variable: {0}".format(environment_name)) + source_mode = source.get("mode", "stream") + if source_mode not in ("replay", "stream"): + raise ConfigError("source.mode must be 'replay' or 'stream'") + model = _mapping(root.get("model"), "model") model_sha256 = _text(model.get("sha256"), "model.sha256").lower() if not _SHA256.match(model_sha256): @@ -163,4 +168,5 @@ def load_config(path: Path) -> AppConfig: ), event=event, event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"), + source_mode=source_mode, ) diff --git a/v1/fall_state.py b/v1/fall_state.py index 1f12954..81e373e 100644 --- a/v1/fall_state.py +++ b/v1/fall_state.py @@ -48,6 +48,7 @@ class FallStateMachine: recovery_window_seconds: float, config_version: str, cooldown_seconds: float = 0.0, + session_id: str = "", ) -> None: if not 1.0 <= confirm_window_seconds <= 3.0: raise ValueError("confirm_window_seconds must be between 1 and 3 seconds") @@ -61,6 +62,7 @@ class FallStateMachine: self._recovery_window_seconds = float(recovery_window_seconds) self._cooldown_seconds = float(cooldown_seconds) self._config_version = config_version.strip() + self._session_id = str(session_id).strip() self._records: Dict[str, _Record] = {} self._next_event_number = 1 @@ -141,8 +143,9 @@ class FallStateMachine: def _new_event( self, track_id: str, suspected_at: float, confirmed_at: float ) -> FallEvent: + prefix = "FALL-{0}-".format(self._session_id) if self._session_id else "FALL-" event = FallEvent( - event_id="FALL-{0:06d}".format(self._next_event_number), + event_id="{0}{1:06d}".format(prefix, self._next_event_number), track_id=track_id, config_version=self._config_version, suspected_at_monotonic=suspected_at, diff --git a/v1/pipeline.py b/v1/pipeline.py index 223546d..27d519a 100644 --- a/v1/pipeline.py +++ b/v1/pipeline.py @@ -1,7 +1,9 @@ """Compose Pose, tracking, evidence policy, and temporal fall state.""" +import itertools from dataclasses import dataclass -from typing import Dict, Sequence, Tuple +from datetime import datetime +from typing import Dict, Optional, Sequence, Tuple from v1.config import AppConfig from v1.evidence import PoseEvidence, assess_pose_quality, extract_evidence @@ -11,6 +13,17 @@ from v1.tracking import PersonTracker, TrackedPersonPose from v1.video_source import FramePacket, SourceStatus +_SESSION_COUNTER = itertools.count(1) + + +def new_session_id() -> str: + """Return a process-unique, human-readable run id for event traceability.""" + + return "{0}-{1:03d}".format( + datetime.now().strftime("%Y%m%d-%H%M%S"), next(_SESSION_COUNTER) + ) + + @dataclass(frozen=True) class PersonAnalysis: tracked_pose: TrackedPersonPose @@ -47,8 +60,15 @@ class FallPipeline: self._active_track_ids = set() @classmethod - def from_config(cls, config: AppConfig, pose_adapter) -> "FallPipeline": - """Create one immutable runtime decision flow from validated config.""" + def from_config( + cls, config: AppConfig, pose_adapter, session_id: Optional[str] = None + ) -> "FallPipeline": + """Create one immutable runtime decision flow from validated config. + + Each run gets a unique ``session_id`` so confirmed-event IDs never collide + across monitoring restarts within the same day (no screenshot overwrite or + duplicate JSONL identity). + """ return cls( pose_adapter=pose_adapter, @@ -59,6 +79,7 @@ class FallPipeline: recovery_window_seconds=config.event.recovery_window_seconds, cooldown_seconds=config.event.cooldown_seconds, config_version=config.runtime_config_version, + session_id=session_id or new_session_id(), ), keypoint_confidence_threshold=config.event.keypoint_confidence_threshold, ) diff --git a/v1/pose.py b/v1/pose.py index c1ca5b0..25f8821 100644 --- a/v1/pose.py +++ b/v1/pose.py @@ -66,6 +66,18 @@ class PoseAdapter: def model_path(self) -> Path: return self._model_path + @property + def confidence_threshold(self) -> float: + return self._confidence + + def set_confidence_threshold(self, value: float) -> None: + """Update the inference confidence so settings changes take effect.""" + + confidence = float(value) + if not 0.0 <= confidence <= 1.0: + raise ModelValidationError("confidence threshold must be between 0 and 1") + self._confidence = confidence + def infer(self, image: np.ndarray) -> Sequence[PersonPose]: results = self._model(image, conf=self._confidence, verbose=False) return self.from_results(results, self._person_class_ids) diff --git a/v1/tests/test_config.py b/v1/tests/test_config.py index 341cd8a..6ce568f 100644 --- a/v1/tests/test_config.py +++ b/v1/tests/test_config.py @@ -56,6 +56,40 @@ def test_load_config_rejects_embedded_source_address(tmp_path): load_config(config_file) +def test_source_mode_defaults_to_stream(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://demo.invalid/live") + + assert load_config(config_file).source_mode == "stream" + + +def test_source_mode_replay_is_parsed(tmp_path, monkeypatch): + config_file = tmp_path / "config.json" + _write_config( + config_file, + {"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", "mode": "replay"}, + ) + monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live") + + assert load_config(config_file).source_mode == "replay" + + +def test_invalid_source_mode_is_rejected(tmp_path, monkeypatch): + config_file = tmp_path / "config.json" + _write_config( + config_file, + {"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", "mode": "loop"}, + ) + monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live") + + with pytest.raises(ConfigError, match="mode"): + 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( diff --git a/v1/tests/test_fall_state.py b/v1/tests/test_fall_state.py index 521bda8..699f252 100644 --- a/v1/tests/test_fall_state.py +++ b/v1/tests/test_fall_state.py @@ -82,6 +82,25 @@ def test_confirmation_window_must_remain_within_customer_target(): ) +def test_session_id_makes_event_ids_unique_across_runs(): + run_a = FallStateMachine( + confirm_window_seconds=1.0, recovery_window_seconds=2.0, + config_version="cfg", session_id="run-a", + ) + run_b = FallStateMachine( + confirm_window_seconds=1.0, recovery_window_seconds=2.0, + config_version="cfg", session_id="run-b", + ) + run_a.update("P-0001", Evidence(True, True), now=0.0) + event_a = run_a.update("P-0001", Evidence(True, True), now=1.0) + run_b.update("P-0001", Evidence(True, True), now=0.0) + event_b = run_b.update("P-0001", Evidence(True, True), now=1.0) + + assert event_a[0].event_id != event_b[0].event_id + assert "run-a" in event_a[0].event_id + assert "run-b" in event_b[0].event_id + + def test_each_track_has_an_independent_confirmation_window(): machine = FallStateMachine( confirm_window_seconds=1.0, diff --git a/v1/tests/test_pipeline.py b/v1/tests/test_pipeline.py index d451824..329033d 100644 --- a/v1/tests/test_pipeline.py +++ b/v1/tests/test_pipeline.py @@ -119,3 +119,18 @@ def test_pipeline_from_config_uses_runtime_version_for_confirmed_event(): result = pipeline.process(_packet(1.1)) assert result.events[0].config_version == config.runtime_config_version + + +def test_from_config_gives_each_run_a_unique_event_id(): + config = _config() + + def confirm(pipeline): + pipeline.process(_packet(0.0)) + pipeline.process(_packet(0.1)) + return pipeline.process(_packet(1.1)).events[0].event_id + + frames = [(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)] + first = confirm(FallPipeline.from_config(config, _SequencePoseAdapter(list(frames)))) + second = confirm(FallPipeline.from_config(config, _SequencePoseAdapter(list(frames)))) + + assert first != second diff --git a/v1/tests/test_pose.py b/v1/tests/test_pose.py index 271ae42..236cd19 100644 --- a/v1/tests/test_pose.py +++ b/v1/tests/test_pose.py @@ -46,6 +46,61 @@ def test_from_results_extracts_person_box_confidence_and_seventeen_keypoints(): assert poses[0].keypoints[5].confidence == 0.9 +class _RecordingPoseModel: + task = "pose" + names = {0: "person"} + + class model: + kpt_shape = (17, 3) + + def __init__(self): + self.calls = [] + + def __call__(self, image, conf, verbose): + self.calls.append(conf) + + class _Empty: + names = {0: "person"} + + class boxes: + xyxy = [] + conf = [] + cls = [] + + class keypoints: + data = [] + + return _Empty() + + +def test_set_confidence_threshold_is_forwarded_to_inference(tmp_path): + weights = tmp_path / "pose.pt" + weights.write_bytes(b"fake-weights") + expected_sha256 = hashlib.sha256(weights.read_bytes()).hexdigest() + model = _RecordingPoseModel() + adapter = PoseAdapter( + weights, expected_sha256, confidence_threshold=0.25, model_factory=lambda _p: model + ) + + adapter.set_confidence_threshold(0.6) + adapter.infer(np.zeros((10, 10, 3), dtype=np.uint8)) + + assert adapter.confidence_threshold == 0.6 + assert model.calls == [0.6] + + +def test_set_confidence_threshold_rejects_out_of_range(tmp_path): + weights = tmp_path / "pose.pt" + weights.write_bytes(b"fake-weights") + expected_sha256 = hashlib.sha256(weights.read_bytes()).hexdigest() + adapter = PoseAdapter( + weights, expected_sha256, model_factory=lambda _p: _RecordingPoseModel() + ) + + with pytest.raises(ModelValidationError): + adapter.set_confidence_threshold(1.5) + + def test_pose_adapter_rejects_non_pose_model_after_hash_validation(tmp_path): model_path = tmp_path / "model.pt" model_path.write_bytes(b"model bytes")