fix(v1): wire model confidence, explicit source mode, unique event ids

A: PoseAdapter.set_confidence_threshold is applied on start, so the
   settings model-confidence field actually affects inference.
B: config source.mode ('stream'|'replay') is explicit; app no longer
   guesses the source type from the URL prefix.
C: FallStateMachine takes a session_id and from_config generates a
   unique one per run, so event ids never collide across restarts
   (no screenshot overwrite or duplicate JSONL identity in a day).

51 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-21 20:54:34 +08:00
co-authored by Claude Opus 4.8
parent 7443a8f031
commit 04422d9ca0
13 changed files with 188 additions and 7 deletions
+5
View File
@@ -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-<session>-NNNNNN`),跨监控重启全局唯一;因此同一天目录内的截图不会被覆盖,`events.jsonl` 也不会出现同 id 不同内容的记录。
T-202 的 `alerts` 按 `event_id` 去重,对首次 CONFIRMED 只触发一次副作用(声音、弹窗、截图、JSONL 一行)。JSONL 记录如下,文件名与字段不含 RTSP 地址、凭证或客户姓名:
```text
+1 -1
View File
@@ -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`。
+9
View File
@@ -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-<session>-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(受阻,等现场流)。
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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",
+6
View File
@@ -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,
)
+4 -1
View File
@@ -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,
+24 -3
View File
@@ -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,
)
+12
View File
@@ -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)
+34
View File
@@ -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(
+19
View File
@@ -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,
+15
View File
@@ -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
+55
View File
@@ -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")