move_detect/tests/test_app.py
유창욱 752afc3f00 feat(edge): heartbeat telemetry + monotonic timers + queue/camera robustness
- Split clocks: detector timers/cooldowns run on a MONOTONIC clock (immune to an
  NTP step on an RTC-less Pi); wall time is used only for the HMAC X-Timestamp
  and Event.occurred_at. Fixes false IMMOBILITY / dropped tracks after a clock jump.
- Heartbeat is time-based (30s, predictable regardless of load) and now carries
  temp/throttle/queue_pending/config_version telemetry.
- A dead (no-frame) camera counts as erroring instead of a healthy frame, so a
  silent monitoring outage no longer looks green.
- Offline queue: isolate one undecodable row instead of wedging every event behind
  it; classify 401 (stale clock) as transient, never a poison-drop.
- New edge/health.py: vcgencmd temp/throttle parsing, None off-Pi (no fake 0).
2026-07-25 19:20:23 +09:00

120 lines
5.1 KiB
Python

import numpy as np
from edge.models import Person, BBox, Keypoint
from edge.tracking import IouTracker
from edge.smoothing import TrackSmootherPool
from edge.detectors.fall import FallDetector, FallConfig
from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig
from edge.detectors.ghost import GhostIncidentDetector, GhostConfig
from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig
from edge.engine import EventEngine, EngineConfig
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.events import EventType
from edge.app import run_once
def _lying_person():
kps = [Keypoint(150, 300, 0.9) for _ in range(17)]
kps[5] = Keypoint(100, 300, 0.9); kps[6] = Keypoint(100, 310, 0.9)
kps[11] = Keypoint(200, 300, 0.9); kps[12] = Keypoint(200, 310, 0.9)
kps[0] = Keypoint(90, 320, 0.9)
return Person(BBox(90, 290, 210, 320), kps)
class _Source:
def read(self): return True, np.full((360, 640, 3), 128, np.uint8)
class _Pose:
def infer(self, frame): return [_lying_person()]
class _Sink:
def __init__(self): self.events = []
def try_send(self, event, snapshots):
self.events.append(event); return True
def test_run_once_pipeline_emits_fall_after_prone():
src, pose = _Source(), _Pose()
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=0.5)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.6])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
assert any(e.event_type == EventType.FALL for e in sink.events)
def test_run_once_stamps_events_with_wall_clock_not_monotonic():
# run_once must thread a separate wall clock through to occurred_at: the daemon runs the loop on
# a monotonic clock (NTP-step-immune timers) but events have to carry real wall time.
src, pose = _Source(), _Pose()
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=0.5)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
mono = iter([0.0, 0.6]) # monotonic loop clock (drives prone_confirm_s)
wallt = iter([1_700_000_000.0, 1_700_000_000.6]) # wall clock (stamps occurred_at)
run_once(src, pose, tracker, pool, engine, ring, snap, sink,
now_fn=lambda: next(mono), wall_fn=lambda: next(wallt))
run_once(src, pose, tracker, pool, engine, ring, snap, sink,
now_fn=lambda: next(mono), wall_fn=lambda: next(wallt))
falls = [e for e in sink.events if e.event_type == EventType.FALL]
assert falls and falls[0].occurred_at == 1_700_000_000.6
def _person_at(cx):
# standing person centered at cx; nose at (cx, 60)
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
kps[5] = Keypoint(cx - 10, 100, 0.9); kps[6] = Keypoint(cx + 10, 100, 0.9)
kps[11] = Keypoint(cx - 10, 200, 0.9); kps[12] = Keypoint(cx + 10, 200, 0.9)
kps[0] = Keypoint(cx, 60, 0.9)
return Person(BBox(cx - 20, 60, cx + 20, 210), kps)
class _TwoPersonPose:
def __init__(self, cx_a, cx_b):
self.cx_a, self.cx_b = cx_a, cx_b
def infer(self, frame):
return [_person_at(self.cx_a), _person_at(self.cx_b)]
def test_two_persons_get_independent_smoothing():
# person A far on the left, person B far on the right; small per-frame
# jitter so IoU tracking stays continuous across frames.
src = _Source()
pose = _TwoPersonPose(cx_a=100, cx_b=900)
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.1])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
tracks = {tr.track_id: tr for tr in tracker._tracks.values()}
assert len(tracks) == 2
xs = sorted(tr.latest.keypoints[0].x for tr in tracks.values())
left_nose_x, right_nose_x = xs
# each track's smoothed nose must stay on its own side, nowhere near
# the midpoint between the two persons (500) — proves no shared filter
# state leaked between tracks.
assert left_nose_x < 300
assert right_nose_x > 700