feat(v1): compose temporal fall event pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user