move_detect/tests/test_app.py
유창욱 51eed139cf feat(evidence): pre-event snapshot timeline + 24h catch-up brief
Two operator-value features: evidence that shows HOW it happened, and the
one-line answer to "what happened overnight".

Evidence timeline (closes the P1-B "前中後 스냅샷 배선" follow-up):
- The ring buffer now stores per-frame head boxes, and an event captures the
  frames nearest T-2.5s / T-1.0s / T-0 (server cap: 3 snapshots). A fall's
  evidentiary value is in the seconds BEFORE the person hit the floor - one
  end-state frame never showed how it happened.
- Each past frame is blurred with the head positions of ITS OWN moment -
  blurring history with the current tick's boxes would leave a face exactly
  where it actually was (PIPA).
- Right after startup the ring is short: fewer frames go out, never zero and
  never duplicated. Detail view captions the sequence 이전/직전/발생 시점 by
  position (offsets are edge config the server doesn't know).

24h catch-up brief:
- The events console renders one line above the stream on load - "지난 24시간
  - 감지 N건 (중대 n) · 니어미스 n · 평균 대응 t · 미확인 n건 남음" - reusing
  GET /api/report?days=1, with a link into /report. High-tinted when the
  window held critical/high events; hidden entirely until data arrives.

Verified: pytest 235 pass (3 new: timeline selection, short-buffer dedup,
3-frame capture through run_once) / PHPUnit 138 pass + Chrome real-render
(brief line with severe tint on the console; detail page showing the
3-frame timeline with captions; no console errors).
2026-08-07 07:25:25 +09:00

248 lines
11 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 _engine(**fall_kw):
return EventEngine(
FallDetector(FallConfig(**fall_kw)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
class _ScriptedPose:
def __init__(self, frames): self.frames = list(frames)
def infer(self, frame): return self.frames.pop(0)
def test_dropout_tick_does_not_feed_detectors_stale_frame():
# A track not re-detected this tick still sits in the tracker (< max_age) with a frozen
# latest frame. Passing it to the engine lets prone_confirm accrue on stale data: here the
# dropout tick alone would confirm the fall. A stale track must be treated as no-data.
src = _Source()
pose = _ScriptedPose([[_lying_person()], []])
tracker = IouTracker() # max_age=1.0 > dropout gap, so the track stays active
pool = TrackSmootherPool()
engine = _engine(prone_confirm_s=0.5, falling_confirm_s=0.1)
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) # dropout tick
assert not any(e.event_type == EventType.FALL for e in sink.events)
def test_dropout_then_redetection_still_confirms_fall():
# ...but the person re-detected still prone MUST confirm — the dropout pauses evidence,
# it does not erase the incident.
src = _Source()
pose = _ScriptedPose([[_lying_person()], [], [_lying_person()]])
tracker = IouTracker()
pool = TrackSmootherPool()
engine = _engine(prone_confirm_s=0.5, falling_confirm_s=0.1)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.3, 0.7])
def now_fn(): return next(times)
for _ in range(3):
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_event_snapshots_are_a_timeline_not_one_frame():
# 낙상의 증거 가치는 바닥에 닿기 전 몇 초에 있다: 이벤트에는 현재 1장이 아니라
# 링버퍼에서 고른 과거→현재 타임라인(최대 3장)이 붙어야 한다.
class _SnapSink:
def __init__(self): self.sent = []
def try_send(self, event, snapshots):
self.sent.append((event, snapshots)); return True
src, pose = _Source(), _Pose()
tracker = IouTracker()
pool = TrackSmootherPool()
engine = _engine(prone_confirm_s=3.0, falling_confirm_s=0.1)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
times = iter([0.0, 1.0, 2.0, 3.5])
def now_fn(): return next(times)
for _ in range(4): # 3.5초에 prone_confirm(3.0) 충족 → fall
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
falls = [(e, s) for e, s in sink.sent if e.event_type == EventType.FALL]
assert len(falls) == 1
assert len(falls[0][1]) == 3 # 과거 2장 + 발생 시점 1장
for jpg in falls[0][1]:
assert jpg[:2] == b"\xff\xd8"
def test_ergo_report_carries_no_snapshots():
# ERGO_REPORT는 익명 통계다 — 프레임이 붙는 순간 통계가 감시 이미지가 된다.
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
from edge.detectors.fall import FallDetector, FallConfig
class _SnapSink:
def __init__(self): self.sent = []
def try_send(self, event, snapshots):
self.sent.append((event, snapshots)); return True
src = _Source()
pose = _ScriptedPose([[_person_at(500)], [_person_at(500)]])
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"),
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.5)))
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
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)
ergo = [(e, s) for e, s in sink.sent if e.event_type == EventType.ERGO_REPORT]
assert len(ergo) == 1
assert ergo[0][1] == [] # 스냅샷 미부착
def _lying_person_at(x1, x2):
kps = [Keypoint((x1 + x2) / 2, 15, 0.9) for _ in range(17)]
return Person(BBox(x1, 0, x2, 20), kps)
def test_contended_crossing_resets_fall_state():
# Two prone tracks; then one box overlapping BOTH (a crossing). The surviving match may
# be a swap, so the contended track's fall state must reset — no FALL may fire off the
# pre-crossing prone timer.
src = _Source()
pose = _ScriptedPose([
[_lying_person_at(0, 40), _lying_person_at(30, 70)],
[_lying_person_at(15, 55)],
])
tracker = IouTracker(iou_thr=0.3)
pool = TrackSmootherPool()
engine = _engine(prone_confirm_s=0.05, falling_confirm_s=0.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)
assert not any(e.event_type == EventType.FALL for e in sink.events)
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