move_detect/edge/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

108 lines
5.2 KiB
Python

from edge.config import Config
from edge.tracking import IouTracker
from edge.smoothing import TrackSmootherPool
from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector
from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.models import BBox
from edge.events import EventType
# Evidence timeline offsets (seconds before the event) — 3 frames tell how it developed.
# The server accepts at most 3 snapshots per event (EventValidator), so this is the cap.
SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)
def _head_boxes(persons: list) -> list:
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
# ~40x40 patch — a close worker fills far more than 40px, so a constant box left the face
# fully identifiable. Centered on the nose when it is confident, else on the bbox's top
# (the head is the upper part of the person) so a bad keypoint can't move the blur off it.
boxes = []
for p in persons:
bb = p.bbox
nose = p.keypoints[0]
half_w = max(8.0, 0.25 * bb.width)
if nose.conf >= 0.3:
cx, cy = nose.x, nose.y
else:
cx, cy = bb.cx, bb.y1 + 0.12 * bb.height
boxes.append(BBox(cx - half_w, cy - 0.18 * bb.height,
cx + half_w, cy + 0.08 * bb.height))
return boxes
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
ok, frame = source.read()
if not ok:
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
# frame" apart from "frame processed, no events" — a dead/unplugged USB camera returns
# (False, None) forever, and counting it as a healthy frame made a monitoring outage
# look perfectly green (fps~=target, cameras_erroring=0).
return None
now = now_fn()
persons = pose.infer(frame)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
lost = tracker.lost()
# Head boxes are computed EVERY tick and stored with the frame (not only when an event
# fires): a past frame pulled from the ring later must be blurred with the head positions
# of its own moment, not the current tick's.
head_boxes = _head_boxes(persons)
# A contended (crossing) match may be an id swap — FALLEN state, frozen baseline, or an
# immobility timer would ride along to the wrong person. Reset that track's safety state;
# a genuinely fallen worker still prone re-confirms within prone_confirm_s.
for tid in tracker.ambiguous():
engine.forget(tid)
pool.forget(tid)
# A track not re-detected this tick still sits in the tracker (< max_age) with a frozen
# latest frame. Feeding it downstream would re-smooth the same frame and let time-based
# detector timers (prone confirm / no-motion / immobility) accrue on stale data — treat
# it as no-data until it is re-detected or ages into lost().
fresh = [tr for tr in tracks if tr.latest.t == now]
for tr in fresh:
f = tr.latest
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame, head_boxes)
h, w = frame.shape[:2]
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
wall_now = wall_fn() if wall_fn is not None else None
events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now)
for tr in lost:
pool.forget(tr.track_id)
if events:
# ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame
# would turn a statistic into surveillance imagery. Capture only when a real incident
# needs evidence, and never attach snapshots to the ergo report itself.
evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT]
snaps = []
if evidential:
# Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end
# state (a fall's value is in the seconds before the person hit the floor).
# Right after startup the ring is short and fewer frames go out — never zero,
# since the current frame was pushed above. Each frame blurs with its own boxes.
frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S)
snaps = snapshotter.capture(frames, boxes)
for e in events:
sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps)
return events
def build_pipeline(config: Config, source, pose) -> dict:
return {
"source": source,
"pose": pose,
"tracker": IouTracker(),
"pool": TrackSmootherPool(),
"engine": EventEngine(
FallDetector(config.fall),
ImmobilityWatchdog(config.immobility),
GhostIncidentDetector(config.ghost),
ZoneIntrusionDetector([], config.zone),
config.engine,
),
"ring": RingBuffer(),
"snapshotter": Snapshotter(FaceBlurrer()),
}