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
+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")