move_detect/tests/test_snapshot.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

47 lines
1.8 KiB
Python

import numpy as np
from edge.models import BBox
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
def _frame(val=128):
return np.full((64, 64, 3), val, dtype=np.uint8)
def test_ringbuffer_keeps_recent():
rb = RingBuffer(maxlen=3)
for i in range(5):
rb.push(float(i), _frame(i))
assert len(rb.frames) == 3
assert rb.frames[-1][0] == 4.0
def test_timeline_picks_frames_nearest_to_offsets():
rb = RingBuffer(maxlen=90)
for i in range(6): # t = 0..5, 1초 간격
rb.push(float(i), _frame(i * 10), [BBox(i, 0, i + 1, 1)])
frames, boxes = rb.timeline(now=5.0, offsets_s=(2.5, 1.0, 0.0))
assert len(frames) == 3
# 과거→현재 순: 목표 2.5/4.0/5.0초에 가장 가까운 t=2(또는 3), 4, 5
assert frames[-1][0, 0, 0] == 50 # 마지막은 현재 프레임
assert boxes[-1][0].x1 == 5 # 각 프레임의 '자기 시점' 블러 박스
assert frames[0][0, 0, 0] < frames[1][0, 0, 0] < frames[2][0, 0, 0]
def test_timeline_short_buffer_returns_fewer_without_duplicates():
rb = RingBuffer(maxlen=90)
rb.push(0.0, _frame(10), [])
frames, boxes = rb.timeline(now=0.0, offsets_s=(2.5, 1.0, 0.0))
assert len(frames) == 1 # 기동 직후: 있는 프레임 하나만, 중복 없음
assert len(boxes) == 1
def test_face_blur_changes_region():
fb = FaceBlurrer()
f = _frame(0)
f[0:10, 0:10] = 255
out = fb.blur(f.copy(), [BBox(0, 0, 10, 10)])
assert out is not None
assert out.shape == f.shape
def test_snapshotter_returns_jpeg_bytes():
snap = Snapshotter(FaceBlurrer())
imgs = snap.capture([_frame(100), _frame(120)], [[], []])
assert 1 <= len(imgs) <= 3
assert imgs[0][:2] == b"\xff\xd8" # JPEG magic