feat(v1): compose temporal fall event pipeline
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Validated, credential-safe configuration loading for Silver Pose V1."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -31,6 +32,25 @@ class AppConfig:
|
||||
event: EventConfig
|
||||
event_dir: Path
|
||||
|
||||
@property
|
||||
def runtime_config_version(self) -> str:
|
||||
"""Return a stable, non-secret identifier for the active event settings."""
|
||||
|
||||
payload = {
|
||||
"source_id": self.source_id,
|
||||
"model_sha256": self.model_sha256,
|
||||
"confidence_threshold": self.confidence_threshold,
|
||||
"event": {
|
||||
"keypoint_confidence_threshold": self.event.keypoint_confidence_threshold,
|
||||
"suspect_window_seconds": self.event.suspect_window_seconds,
|
||||
"confirm_window_seconds": self.event.confirm_window_seconds,
|
||||
"recovery_window_seconds": self.event.recovery_window_seconds,
|
||||
"cooldown_seconds": self.event.cooldown_seconds,
|
||||
},
|
||||
}
|
||||
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return "cfg-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Translate per-track pose facts into state-machine evidence."""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from v1.evidence import PoseEvidence
|
||||
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:
|
||||
self._suspect_window_seconds = float(suspect_window_seconds)
|
||||
self._rapid_drop_at: Dict[str, float] = {}
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
track_id: str,
|
||||
pose_evidence: PoseEvidence,
|
||||
now: float,
|
||||
state: FallState,
|
||||
) -> Evidence:
|
||||
timestamp = float(now)
|
||||
if not pose_evidence.accepted:
|
||||
self._rapid_drop_at.pop(track_id, None)
|
||||
return Evidence(accepted=False, is_fall_candidate=False)
|
||||
if pose_evidence.rapid_vertical_change:
|
||||
self._rapid_drop_at[track_id] = timestamp
|
||||
|
||||
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
|
||||
|
||||
recovery = (
|
||||
state in (FallState.CONFIRMED, FallState.RECOVERING)
|
||||
and not pose_evidence.horizontal_pose
|
||||
and not pose_evidence.rapid_vertical_change
|
||||
)
|
||||
return Evidence(
|
||||
accepted=pose_evidence.accepted,
|
||||
is_fall_candidate=candidate,
|
||||
is_recovery_candidate=recovery,
|
||||
)
|
||||
+22
-2
@@ -23,6 +23,7 @@ class Evidence:
|
||||
class FallEvent:
|
||||
event_id: str
|
||||
track_id: str
|
||||
config_version: str
|
||||
suspected_at_monotonic: float
|
||||
confirmed_at_monotonic: float
|
||||
latency_seconds: float
|
||||
@@ -33,6 +34,7 @@ class FallEvent:
|
||||
class _Record:
|
||||
state: FallState
|
||||
suspect_started_at: Optional[float] = None
|
||||
confirmed_at: Optional[float] = None
|
||||
recovery_started_at: Optional[float] = None
|
||||
last_updated_at: Optional[float] = None
|
||||
|
||||
@@ -40,13 +42,25 @@ class _Record:
|
||||
class FallStateMachine:
|
||||
"""Confirm only uninterrupted, accepted fall evidence for one tracked person."""
|
||||
|
||||
def __init__(self, confirm_window_seconds: float, recovery_window_seconds: float) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
confirm_window_seconds: float,
|
||||
recovery_window_seconds: float,
|
||||
config_version: str,
|
||||
cooldown_seconds: float = 0.0,
|
||||
) -> None:
|
||||
if not 1.0 <= confirm_window_seconds <= 3.0:
|
||||
raise ValueError("confirm_window_seconds must be between 1 and 3 seconds")
|
||||
if recovery_window_seconds <= 0:
|
||||
raise ValueError("recovery_window_seconds must be positive")
|
||||
if cooldown_seconds < 0:
|
||||
raise ValueError("cooldown_seconds must be non-negative")
|
||||
if not isinstance(config_version, str) or not config_version.strip():
|
||||
raise ValueError("config_version must be a non-empty string")
|
||||
self._confirm_window_seconds = float(confirm_window_seconds)
|
||||
self._recovery_window_seconds = float(recovery_window_seconds)
|
||||
self._cooldown_seconds = float(cooldown_seconds)
|
||||
self._config_version = config_version.strip()
|
||||
self._records: Dict[str, _Record] = {}
|
||||
self._next_event_number = 1
|
||||
|
||||
@@ -79,13 +93,17 @@ class FallStateMachine:
|
||||
return []
|
||||
if timestamp - record.suspect_started_at >= self._confirm_window_seconds:
|
||||
record.state = FallState.CONFIRMED
|
||||
record.confirmed_at = timestamp
|
||||
event = self._new_event(track_id, record.suspect_started_at, timestamp)
|
||||
record.suspect_started_at = None
|
||||
return [event]
|
||||
return []
|
||||
|
||||
if record.state is FallState.CONFIRMED:
|
||||
if evidence.is_recovery_candidate:
|
||||
if (
|
||||
evidence.is_recovery_candidate
|
||||
and timestamp - record.confirmed_at >= self._cooldown_seconds
|
||||
):
|
||||
record.state = FallState.RECOVERING
|
||||
record.recovery_started_at = timestamp
|
||||
return []
|
||||
@@ -117,6 +135,7 @@ class FallStateMachine:
|
||||
def _set_normal(record: _Record) -> None:
|
||||
record.state = FallState.NORMAL
|
||||
record.suspect_started_at = None
|
||||
record.confirmed_at = None
|
||||
record.recovery_started_at = None
|
||||
|
||||
def _new_event(
|
||||
@@ -125,6 +144,7 @@ class FallStateMachine:
|
||||
event = FallEvent(
|
||||
event_id="FALL-{0:06d}".format(self._next_event_number),
|
||||
track_id=track_id,
|
||||
config_version=self._config_version,
|
||||
suspected_at_monotonic=suspected_at,
|
||||
confirmed_at_monotonic=confirmed_at,
|
||||
latency_seconds=confirmed_at - suspected_at,
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"""Compose Pose, tracking, evidence policy, and temporal fall state."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Sequence, Tuple
|
||||
|
||||
from v1.config import AppConfig
|
||||
from v1.evidence import PoseEvidence, assess_pose_quality, extract_evidence
|
||||
from v1.fall_policy import FallEvidencePolicy
|
||||
from v1.fall_state import Evidence, FallEvent, FallState, FallStateMachine
|
||||
from v1.tracking import PersonTracker, TrackedPersonPose
|
||||
from v1.video_source import FramePacket, SourceStatus
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonAnalysis:
|
||||
tracked_pose: TrackedPersonPose
|
||||
pose_evidence: PoseEvidence
|
||||
state: FallState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameAnalysis:
|
||||
packet: FramePacket
|
||||
people: Tuple[PersonAnalysis, ...]
|
||||
events: Tuple[FallEvent, ...]
|
||||
|
||||
|
||||
class FallPipeline:
|
||||
"""Run one source frame through the V1 event decision flow."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pose_adapter,
|
||||
tracker: PersonTracker,
|
||||
policy: FallEvidencePolicy,
|
||||
state_machine: FallStateMachine,
|
||||
keypoint_confidence_threshold: float,
|
||||
) -> None:
|
||||
if not 0.0 <= keypoint_confidence_threshold <= 1.0:
|
||||
raise ValueError("keypoint_confidence_threshold must be between 0 and 1")
|
||||
self._pose_adapter = pose_adapter
|
||||
self._tracker = tracker
|
||||
self._policy = policy
|
||||
self._state_machine = state_machine
|
||||
self._keypoint_confidence_threshold = float(keypoint_confidence_threshold)
|
||||
self._previous_evidence: Dict[str, PoseEvidence] = {}
|
||||
self._active_track_ids = set()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: AppConfig, pose_adapter) -> "FallPipeline":
|
||||
"""Create one immutable runtime decision flow from validated config."""
|
||||
|
||||
return cls(
|
||||
pose_adapter=pose_adapter,
|
||||
tracker=PersonTracker(),
|
||||
policy=FallEvidencePolicy(config.event.suspect_window_seconds),
|
||||
state_machine=FallStateMachine(
|
||||
confirm_window_seconds=config.event.confirm_window_seconds,
|
||||
recovery_window_seconds=config.event.recovery_window_seconds,
|
||||
cooldown_seconds=config.event.cooldown_seconds,
|
||||
config_version=config.runtime_config_version,
|
||||
),
|
||||
keypoint_confidence_threshold=config.event.keypoint_confidence_threshold,
|
||||
)
|
||||
|
||||
def process(self, packet: FramePacket) -> FrameAnalysis:
|
||||
if packet.status is not SourceStatus.CONNECTED or packet.image is None:
|
||||
events = self._reject_active_tracks(packet.timestamp_monotonic)
|
||||
return FrameAnalysis(packet=packet, people=(), events=tuple(events))
|
||||
|
||||
height, width = packet.image.shape[:2]
|
||||
poses = self._pose_adapter.infer(packet.image)
|
||||
tracked_poses = self._tracker.update(
|
||||
poses,
|
||||
detected_at_monotonic=packet.timestamp_monotonic,
|
||||
frame_size=(width, height),
|
||||
)
|
||||
current_ids = {tracked.track_id for tracked in tracked_poses}
|
||||
events = self._reject_missing_tracks(current_ids, packet.timestamp_monotonic)
|
||||
people = []
|
||||
for tracked in tracked_poses:
|
||||
quality = assess_pose_quality(
|
||||
tracked.pose, threshold=self._keypoint_confidence_threshold
|
||||
)
|
||||
pose_evidence = extract_evidence(
|
||||
tracked.pose,
|
||||
quality=quality,
|
||||
previous=self._previous_evidence.get(tracked.track_id),
|
||||
)
|
||||
state_before = self._state_machine.state_of(tracked.track_id)
|
||||
evidence = self._policy.evaluate(
|
||||
tracked.track_id,
|
||||
pose_evidence,
|
||||
now=packet.timestamp_monotonic,
|
||||
state=state_before,
|
||||
)
|
||||
events.extend(
|
||||
self._state_machine.update(
|
||||
tracked.track_id, evidence, now=packet.timestamp_monotonic
|
||||
)
|
||||
)
|
||||
if pose_evidence.accepted:
|
||||
self._previous_evidence[tracked.track_id] = pose_evidence
|
||||
else:
|
||||
self._previous_evidence.pop(tracked.track_id, None)
|
||||
people.append(
|
||||
PersonAnalysis(
|
||||
tracked_pose=tracked,
|
||||
pose_evidence=pose_evidence,
|
||||
state=self._state_machine.state_of(tracked.track_id),
|
||||
)
|
||||
)
|
||||
self._active_track_ids = current_ids
|
||||
return FrameAnalysis(packet=packet, people=tuple(people), events=tuple(events))
|
||||
|
||||
def _reject_missing_tracks(self, current_ids: set, now: float) -> list:
|
||||
missing_ids = self._active_track_ids - current_ids
|
||||
events = []
|
||||
for track_id in sorted(missing_ids):
|
||||
events.extend(self._reject_track(track_id, now))
|
||||
return events
|
||||
|
||||
def _reject_active_tracks(self, now: float) -> list:
|
||||
events = []
|
||||
for track_id in sorted(self._active_track_ids):
|
||||
events.extend(self._reject_track(track_id, now))
|
||||
self._active_track_ids = set()
|
||||
return events
|
||||
|
||||
def _reject_track(self, track_id: str, now: float) -> Sequence[FallEvent]:
|
||||
rejected = Evidence(accepted=False, is_fall_candidate=False)
|
||||
self._previous_evidence.pop(track_id, None)
|
||||
self._policy.evaluate(
|
||||
track_id,
|
||||
PoseEvidence(False, False, False, None, None, None, "missing_pose"),
|
||||
now=now,
|
||||
state=self._state_machine.state_of(track_id),
|
||||
)
|
||||
return self._state_machine.update(track_id, rejected, now=now)
|
||||
@@ -54,3 +54,18 @@ def test_load_config_rejects_embedded_source_address(tmp_path):
|
||||
|
||||
with pytest.raises(ConfigError, match="rtsp_url_env"):
|
||||
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(
|
||||
config_file,
|
||||
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL"},
|
||||
)
|
||||
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@camera-a/live")
|
||||
first = load_config(config_file)
|
||||
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:other-secret@camera-b/live")
|
||||
second = load_config(config_file)
|
||||
|
||||
assert first.runtime_config_version == second.runtime_config_version
|
||||
assert first.runtime_config_version.startswith("cfg-")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from v1.evidence import PoseEvidence
|
||||
from v1.fall_policy import FallEvidencePolicy
|
||||
from v1.fall_state import FallState
|
||||
|
||||
|
||||
def _evidence(horizontal=False, rapid_drop=False, accepted=True):
|
||||
return PoseEvidence(
|
||||
accepted=accepted,
|
||||
horizontal_pose=horizontal,
|
||||
rapid_vertical_change=rapid_drop,
|
||||
horizontal_angle_degrees=10.0 if horizontal else 80.0,
|
||||
hip_center_y=100.0,
|
||||
torso_length=50.0,
|
||||
reason="accepted" if accepted else "missing_pose",
|
||||
)
|
||||
|
||||
|
||||
def test_rapid_drop_followed_by_horizontal_pose_within_suspect_window_starts_candidate():
|
||||
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
|
||||
|
||||
onset = policy.evaluate(
|
||||
"P-0001", _evidence(rapid_drop=True), now=0.0, state=FallState.NORMAL
|
||||
)
|
||||
candidate = policy.evaluate(
|
||||
"P-0001", _evidence(horizontal=True), now=0.5, state=FallState.NORMAL
|
||||
)
|
||||
|
||||
assert onset.is_fall_candidate is False
|
||||
assert candidate.is_fall_candidate is True
|
||||
|
||||
|
||||
def test_rejected_pose_clears_pending_drop_before_the_next_horizontal_pose():
|
||||
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
|
||||
|
||||
policy.evaluate("P-0001", _evidence(rapid_drop=True), now=0.0, state=FallState.NORMAL)
|
||||
policy.evaluate("P-0001", _evidence(accepted=False), now=0.1, state=FallState.NORMAL)
|
||||
candidate = policy.evaluate(
|
||||
"P-0001", _evidence(horizontal=True), now=0.2, state=FallState.NORMAL
|
||||
)
|
||||
|
||||
assert candidate.accepted is True
|
||||
assert candidate.is_fall_candidate is False
|
||||
|
||||
|
||||
def test_upright_pose_is_recovery_evidence_only_after_confirmation():
|
||||
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
|
||||
|
||||
recovery = policy.evaluate(
|
||||
"P-0001", _evidence(horizontal=False), now=4.0, state=FallState.CONFIRMED
|
||||
)
|
||||
|
||||
assert recovery.accepted is True
|
||||
assert recovery.is_recovery_candidate is True
|
||||
@@ -3,8 +3,15 @@ import pytest
|
||||
from v1.fall_state import Evidence, FallState, FallStateMachine
|
||||
|
||||
|
||||
CONFIG_VERSION = "cfg-test-20260721"
|
||||
|
||||
|
||||
def test_confirmed_event_is_emitted_once_after_persistent_evidence():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.8, recovery_window_seconds=2.0)
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.8,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version=CONFIG_VERSION,
|
||||
)
|
||||
|
||||
assert machine.update("P-0007", Evidence(True, True), now=0.0) == []
|
||||
events = machine.update("P-0007", Evidence(True, True), now=1.8)
|
||||
@@ -12,12 +19,17 @@ def test_confirmed_event_is_emitted_once_after_persistent_evidence():
|
||||
assert len(events) == 1
|
||||
assert events[0].track_id == "P-0007"
|
||||
assert events[0].latency_seconds == 1.8
|
||||
assert events[0].config_version == CONFIG_VERSION
|
||||
assert machine.update("P-0007", Evidence(True, True), now=2.0) == []
|
||||
assert machine.state_of("P-0007") is FallState.CONFIRMED
|
||||
|
||||
|
||||
def test_brief_low_posture_returns_to_normal_without_event():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.0,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version=CONFIG_VERSION,
|
||||
)
|
||||
|
||||
machine.update("P-0007", Evidence(True, True), now=0.0)
|
||||
events = machine.update("P-0007", Evidence(True, False), now=0.3)
|
||||
@@ -27,7 +39,11 @@ def test_brief_low_posture_returns_to_normal_without_event():
|
||||
|
||||
|
||||
def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.0,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version=CONFIG_VERSION,
|
||||
)
|
||||
|
||||
machine.update("P-0007", Evidence(True, True), now=0.0)
|
||||
machine.update("P-0007", Evidence(False, False), now=0.9)
|
||||
@@ -38,7 +54,11 @@ def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window():
|
||||
|
||||
|
||||
def test_recovery_must_persist_before_new_event_is_allowed():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.0,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version=CONFIG_VERSION,
|
||||
)
|
||||
machine.update("P-0007", Evidence(True, True), now=0.0)
|
||||
first_event = machine.update("P-0007", Evidence(True, True), now=1.0)
|
||||
assert len(first_event) == 1
|
||||
@@ -55,11 +75,19 @@ def test_recovery_must_persist_before_new_event_is_allowed():
|
||||
|
||||
def test_confirmation_window_must_remain_within_customer_target():
|
||||
with pytest.raises(ValueError, match="between 1 and 3"):
|
||||
FallStateMachine(confirm_window_seconds=0.9, recovery_window_seconds=2.0)
|
||||
FallStateMachine(
|
||||
confirm_window_seconds=0.9,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version=CONFIG_VERSION,
|
||||
)
|
||||
|
||||
|
||||
def test_each_track_has_an_independent_confirmation_window():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.0,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version=CONFIG_VERSION,
|
||||
)
|
||||
|
||||
machine.update("P-0001", Evidence(True, True), now=0.0)
|
||||
machine.update("P-0002", Evidence(True, True), now=0.6)
|
||||
@@ -69,3 +97,20 @@ def test_each_track_has_an_independent_confirmation_window():
|
||||
assert first_events[0].track_id == "P-0001"
|
||||
assert second_events[0].track_id == "P-0002"
|
||||
assert first_events[0].event_id != second_events[0].event_id
|
||||
|
||||
|
||||
def test_cooldown_delays_recovery_after_confirmation():
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.0,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version=CONFIG_VERSION,
|
||||
cooldown_seconds=3.0,
|
||||
)
|
||||
machine.update("P-0007", Evidence(True, True), now=0.0)
|
||||
machine.update("P-0007", Evidence(True, True), now=1.0)
|
||||
|
||||
machine.update("P-0007", Evidence(True, False, True), now=1.1)
|
||||
assert machine.state_of("P-0007") is FallState.CONFIRMED
|
||||
|
||||
machine.update("P-0007", Evidence(True, False, True), now=4.0)
|
||||
assert machine.state_of("P-0007") is FallState.RECOVERING
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from v1.config import AppConfig, EventConfig
|
||||
from v1.fall_policy import FallEvidencePolicy
|
||||
from v1.fall_state import FallState, FallStateMachine
|
||||
from v1.pipeline import FallPipeline
|
||||
from v1.pose import Keypoint, PersonPose
|
||||
from v1.tracking import PersonTracker
|
||||
from v1.video_source import FramePacket, SourceStatus
|
||||
|
||||
|
||||
class _SequencePoseAdapter:
|
||||
def __init__(self, frames):
|
||||
self._frames = iter(frames)
|
||||
|
||||
def infer(self, _image):
|
||||
return next(self._frames)
|
||||
|
||||
|
||||
def _pose(horizontal=False):
|
||||
points = [Keypoint(float(index), float(index), 0.9) for index in range(17)]
|
||||
if horizontal:
|
||||
points[5] = Keypoint(20.0, 80.0, 0.9)
|
||||
points[6] = Keypoint(30.0, 80.0, 0.9)
|
||||
points[11] = Keypoint(70.0, 100.0, 0.9)
|
||||
points[12] = Keypoint(80.0, 100.0, 0.9)
|
||||
else:
|
||||
points[5] = Keypoint(30.0, 10.0, 0.9)
|
||||
points[6] = Keypoint(40.0, 10.0, 0.9)
|
||||
points[11] = Keypoint(30.0, 30.0, 0.9)
|
||||
points[12] = Keypoint(40.0, 30.0, 0.9)
|
||||
return PersonPose(
|
||||
box_xyxy=(20.0, 20.0, 160.0, 160.0),
|
||||
box_confidence=0.9,
|
||||
keypoints=tuple(points),
|
||||
)
|
||||
|
||||
|
||||
def _packet(timestamp):
|
||||
return FramePacket(
|
||||
image=np.zeros((180, 180, 3), dtype=np.uint8),
|
||||
timestamp_monotonic=timestamp,
|
||||
status=SourceStatus.CONNECTED,
|
||||
)
|
||||
|
||||
|
||||
def _pipeline(frames):
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.0,
|
||||
recovery_window_seconds=2.0,
|
||||
config_version="cfg-test-pipeline",
|
||||
)
|
||||
return (
|
||||
FallPipeline(
|
||||
pose_adapter=_SequencePoseAdapter(frames),
|
||||
tracker=PersonTracker(),
|
||||
policy=FallEvidencePolicy(suspect_window_seconds=0.5),
|
||||
state_machine=machine,
|
||||
keypoint_confidence_threshold=0.4,
|
||||
),
|
||||
machine,
|
||||
)
|
||||
|
||||
|
||||
def _config():
|
||||
return AppConfig(
|
||||
source_id="lobby-camera-01",
|
||||
source_url="rtsp://not-written-to-disk/live",
|
||||
model_path=Path("models/best.pt"),
|
||||
model_sha256="a" * 64,
|
||||
confidence_threshold=0.25,
|
||||
event=EventConfig(
|
||||
keypoint_confidence_threshold=0.4,
|
||||
suspect_window_seconds=0.5,
|
||||
confirm_window_seconds=1.0,
|
||||
recovery_window_seconds=2.0,
|
||||
cooldown_seconds=3.0,
|
||||
),
|
||||
event_dir=Path("artifacts/events"),
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_confirms_a_recent_drop_that_remains_horizontal():
|
||||
pipeline, machine = _pipeline([(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)])
|
||||
|
||||
pipeline.process(_packet(0.0))
|
||||
pipeline.process(_packet(0.1))
|
||||
result = pipeline.process(_packet(1.1))
|
||||
|
||||
assert len(result.events) == 1
|
||||
assert result.events[0].config_version == "cfg-test-pipeline"
|
||||
assert machine.state_of("P-0001") is FallState.CONFIRMED
|
||||
|
||||
|
||||
def test_pipeline_rejects_a_suspect_when_the_track_is_missing_for_one_frame():
|
||||
pipeline, machine = _pipeline([(_pose(),), (_pose(horizontal=True),), (), (_pose(horizontal=True),)])
|
||||
|
||||
pipeline.process(_packet(0.0))
|
||||
pipeline.process(_packet(0.1))
|
||||
pipeline.process(_packet(0.2))
|
||||
result = pipeline.process(_packet(1.1))
|
||||
|
||||
assert result.events == ()
|
||||
assert machine.state_of("P-0001") is FallState.NORMAL
|
||||
|
||||
|
||||
def test_pipeline_from_config_uses_runtime_version_for_confirmed_event():
|
||||
config = _config()
|
||||
pipeline = FallPipeline.from_config(
|
||||
config,
|
||||
pose_adapter=_SequencePoseAdapter(
|
||||
[(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)]
|
||||
),
|
||||
)
|
||||
|
||||
pipeline.process(_packet(0.0))
|
||||
pipeline.process(_packet(0.1))
|
||||
result = pipeline.process(_packet(1.1))
|
||||
|
||||
assert result.events[0].config_version == config.runtime_config_version
|
||||
@@ -2,7 +2,7 @@ import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from v1.video_source import SourceStatus, VideoSource
|
||||
from v1.video_source import SourceMode, SourceStatus, VideoSource
|
||||
|
||||
|
||||
def _write_sample_video(path):
|
||||
@@ -18,7 +18,7 @@ def _write_sample_video(path):
|
||||
def test_file_source_emits_monotonic_timestamps(tmp_path):
|
||||
sample_video = tmp_path / "sample.avi"
|
||||
_write_sample_video(sample_video)
|
||||
source = VideoSource(sample_video, reconnect=False)
|
||||
source = VideoSource(sample_video, mode=SourceMode.REPLAY)
|
||||
|
||||
first = source.read(now=10.0)
|
||||
second = source.read(now=10.1)
|
||||
@@ -31,7 +31,7 @@ def test_file_source_emits_monotonic_timestamps(tmp_path):
|
||||
|
||||
|
||||
def test_missing_source_returns_error_state_without_frame(tmp_path):
|
||||
source = VideoSource(tmp_path / "missing.avi", reconnect=False)
|
||||
source = VideoSource(tmp_path / "missing.avi", mode=SourceMode.REPLAY)
|
||||
|
||||
packet = source.read(now=1.0)
|
||||
|
||||
@@ -77,7 +77,7 @@ def test_reconnect_waits_then_reopens_with_bounded_backoff():
|
||||
|
||||
source = VideoSource(
|
||||
"demo-source",
|
||||
reconnect=True,
|
||||
mode=SourceMode.STREAM,
|
||||
retry_initial_seconds=2.0,
|
||||
retry_max_seconds=2.0,
|
||||
capture_factory=lambda _source: captures.pop(0),
|
||||
@@ -119,7 +119,9 @@ class _NegativeFirstTimestampCapture:
|
||||
|
||||
def test_negative_first_timestamp_falls_back_to_frame_index_and_fps():
|
||||
source = VideoSource(
|
||||
"demo-source", reconnect=False, capture_factory=lambda _source: _NegativeFirstTimestampCapture()
|
||||
"demo-source",
|
||||
mode=SourceMode.REPLAY,
|
||||
capture_factory=lambda _source: _NegativeFirstTimestampCapture(),
|
||||
)
|
||||
|
||||
first = source.read(now=10.0)
|
||||
@@ -127,3 +129,30 @@ def test_negative_first_timestamp_falls_back_to_frame_index_and_fps():
|
||||
|
||||
assert first.timestamp_monotonic == 0.0
|
||||
assert second.timestamp_monotonic == pytest.approx(1.0 / 30.0)
|
||||
|
||||
|
||||
def test_replay_source_reports_eof_without_restarting(tmp_path):
|
||||
sample_video = tmp_path / "sample.avi"
|
||||
_write_sample_video(sample_video)
|
||||
source = VideoSource(sample_video, mode=SourceMode.REPLAY)
|
||||
|
||||
for timestamp in (0.0, 0.1, 0.2):
|
||||
assert source.read(now=timestamp).status is SourceStatus.CONNECTED
|
||||
eof = source.read(now=0.3)
|
||||
still_eof = source.read(now=0.4)
|
||||
|
||||
assert eof.status is SourceStatus.EOF
|
||||
assert still_eof.status is SourceStatus.EOF
|
||||
|
||||
|
||||
def test_stream_source_uses_read_clock_instead_of_capture_timestamp():
|
||||
source = VideoSource(
|
||||
"rtsp://not-a-real-address",
|
||||
mode=SourceMode.STREAM,
|
||||
capture_factory=lambda _source: _OpenCapture(),
|
||||
)
|
||||
|
||||
packet = source.read(now=42.0)
|
||||
|
||||
assert packet.status is SourceStatus.CONNECTED
|
||||
assert packet.timestamp_monotonic == 42.0
|
||||
|
||||
+18
-5
@@ -18,6 +18,11 @@ class SourceStatus(str, Enum):
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class SourceMode(str, Enum):
|
||||
REPLAY = "replay"
|
||||
STREAM = "stream"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FramePacket:
|
||||
image: Optional[np.ndarray]
|
||||
@@ -35,7 +40,7 @@ class VideoSource:
|
||||
def __init__(
|
||||
self,
|
||||
source: Union[str, Path],
|
||||
reconnect: bool = True,
|
||||
mode: SourceMode,
|
||||
retry_initial_seconds: float = 1.0,
|
||||
retry_max_seconds: float = 16.0,
|
||||
capture_factory: Optional[CaptureFactory] = None,
|
||||
@@ -44,13 +49,15 @@ class VideoSource:
|
||||
raise ValueError("retry_initial_seconds must be positive")
|
||||
if retry_max_seconds < retry_initial_seconds:
|
||||
raise ValueError("retry_max_seconds must not be smaller than retry_initial_seconds")
|
||||
if not isinstance(mode, SourceMode):
|
||||
raise ValueError("mode must be a SourceMode")
|
||||
self._source = str(source)
|
||||
self._reconnect = reconnect
|
||||
self._mode = mode
|
||||
self._retry_initial_seconds = retry_initial_seconds
|
||||
self._retry_max_seconds = retry_max_seconds
|
||||
self._capture_factory = capture_factory or cv2.VideoCapture
|
||||
self._capture = None
|
||||
self._status = SourceStatus.RETRYING if reconnect else SourceStatus.ERROR
|
||||
self._status = SourceStatus.RETRYING if mode is SourceMode.STREAM else SourceStatus.ERROR
|
||||
self._retry_delay_seconds = retry_initial_seconds
|
||||
self._next_retry_at = 0.0
|
||||
self._last_timestamp: Optional[float] = None
|
||||
@@ -77,6 +84,9 @@ class VideoSource:
|
||||
if self._closed:
|
||||
return self._packet(timestamp, SourceStatus.CLOSED, "source is closed")
|
||||
|
||||
if self._mode is SourceMode.REPLAY and self._status is SourceStatus.EOF:
|
||||
return self._packet(timestamp, SourceStatus.EOF, self._last_error)
|
||||
|
||||
if self._capture is None:
|
||||
if self._status is SourceStatus.RETRYING and timestamp < self._next_retry_at:
|
||||
return self._packet(timestamp, SourceStatus.RETRYING, self._last_error)
|
||||
@@ -86,7 +96,7 @@ class VideoSource:
|
||||
success, image = self._capture.read()
|
||||
if not success or image is None:
|
||||
self._release_capture()
|
||||
if self._reconnect:
|
||||
if self._mode is SourceMode.STREAM:
|
||||
self._schedule_retry(timestamp, "frame read failed; retry scheduled")
|
||||
return self._packet(timestamp, SourceStatus.RETRYING, self._last_error)
|
||||
self._status = SourceStatus.EOF
|
||||
@@ -104,7 +114,7 @@ class VideoSource:
|
||||
if capture is None or not capture.isOpened():
|
||||
if capture is not None:
|
||||
capture.release()
|
||||
if self._reconnect:
|
||||
if self._mode is SourceMode.STREAM:
|
||||
self._schedule_retry(now, "unable to open source; retry scheduled")
|
||||
else:
|
||||
self._status = SourceStatus.ERROR
|
||||
@@ -115,6 +125,9 @@ class VideoSource:
|
||||
return True
|
||||
|
||||
def _frame_timestamp(self, fallback_now: float) -> float:
|
||||
if self._mode is SourceMode.STREAM:
|
||||
self._last_timestamp = fallback_now
|
||||
return fallback_now
|
||||
source_seconds = float(self._capture.get(cv2.CAP_PROP_POS_MSEC)) / 1000.0
|
||||
frame_seconds = self._timestamp_from_frame_index()
|
||||
if source_seconds < 0:
|
||||
|
||||
Reference in New Issue
Block a user