feat(v1): compose temporal fall event pipeline

This commit is contained in:
ila
2026-07-21 11:57:22 +08:00
parent 906e06565e
commit f03662bf5a
16 changed files with 554 additions and 34 deletions
+15
View File
@@ -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-")
+53
View File
@@ -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
+51 -6
View File
@@ -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
+121
View File
@@ -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
+34 -5
View File
@@ -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