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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user