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).
This commit is contained in:
유창욱 2026-08-07 07:25:25 +09:00
parent d8cef54fe4
commit 51eed139cf
9 changed files with 170 additions and 10 deletions

View file

@ -1,5 +1,10 @@
# INDEX — 작업 기록 (완료 결과만)
- **2026-08-07** 증거 타임라인 + 24시간 캐치업: 이벤트 스냅샷이 링버퍼 기반 과거→발생
3장(프레임별 자기 시점 얼굴 블러, P1-B 잔여 종결), 상세뷰 이전/직전/발생 캡션. 콘솔
첫 진입 '지난 24시간' 요약 배너(/api/report?days=1 재사용, 리포트 링크). pytest 235/
PHPUnit 138 + Chrome 실렌더.
- **2026-08-07** 안전 운영 리포트 `/report` (주간 안전회의용 인쇄 가능 한 장: 중대 이벤트·
TTA(FP 제외)·니어미스 피라미드·오탐율·유형/카메라 순위·인체공학 노출·존 규칙 위반) +
이벤트 스트림 유형 필터. PHPUnit 138 + Chrome 실렌더(KPI/바리스트/타일/필터).

View file

@ -10,6 +10,10 @@ 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
@ -42,6 +46,10 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
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.
@ -56,7 +64,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
for tr in fresh:
f = tr.latest
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame)
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.
@ -69,7 +77,14 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
# 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 = snapshotter.capture([frame], [_head_boxes(persons)]) if evidential else []
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

View file

@ -7,15 +7,34 @@ from edge.models import BBox
class RingBuffer:
def __init__(self, maxlen: int = 90):
self.frames: deque = deque(maxlen=maxlen)
self.frames: deque = deque(maxlen=maxlen) # (t, frame, head_boxes)
def push(self, t: float, frame) -> None:
self.frames.append((t, frame))
def push(self, t: float, frame, head_boxes: list | None = None) -> None:
# head_boxes ride along per frame: a PAST frame must be blurred with the head
# positions of ITS OWN moment — blurring history with the current tick's boxes
# would leave yesterday's face exactly where it actually was (PIPA).
self.frames.append((t, frame, head_boxes or []))
def window(self, pre: int, post: int, index: int) -> list:
lo = max(0, index - pre)
hi = min(len(self.frames), index + post + 1)
return [f for (_t, f) in list(self.frames)[lo:hi]]
return [f for (_t, f, _b) in list(self.frames)[lo:hi]]
def timeline(self, now: float, offsets_s: tuple) -> tuple[list, list]:
"""각 목표 시각(now - offset)에 가장 가까운 프레임을 골라 (frames, head_boxes)를
과거→현재 순으로 반환. 버퍼가 짧으면(기동 직후) 있는 만큼만 — 같은 프레임이 여러
목표에 걸리면 한 번만 들어간다."""
items = list(self.frames)
if not items:
return [], []
idxs: list[int] = []
for off in sorted(offsets_s, reverse=True):
target = now - off
i = min(range(len(items)), key=lambda k: abs(items[k][0] - target))
if i not in idxs:
idxs.append(i)
idxs.sort()
return [items[i][1] for i in idxs], [items[i][2] for i in idxs]
class FaceBlurrer:

View file

@ -345,6 +345,16 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.ack-done, .fp-done { color:var(--ink-3); font-family:var(--mono); font-size:.75rem; white-space:nowrap; }
.fp-done { text-decoration:line-through; text-decoration-color:#c9d1da; }
/* 24h catch-up brief above the stream — one line of "what happened overnight". */
.day-brief {
margin:0 0 .65rem; padding:.5rem .75rem; font-size:.79rem; color:var(--ink-2);
background:var(--panel-2); border:1px solid var(--line); border-radius:8px;
}
.day-brief strong { color:var(--ink); margin-right:.1rem; }
.day-brief.has-severe { background:var(--high-bg); border-color:var(--high-line); }
.day-brief a { color:var(--accent); text-decoration:none; font-weight:600; white-space:nowrap; }
.day-brief a:hover { text-decoration:underline; }
/* Rail: FP-rate report rows. */
.fp-stats { display:flex; flex-direction:column; gap:.34rem; font-size:.74rem; }
.fp-stats.is-empty { color:var(--ink-3); }
@ -400,6 +410,11 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.detail-row { margin:.35rem 0; }
.detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; }
#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; }
/* Evidence timeline: each frame carries its moment label — the sequence reads left→right
into the event ("이전 → 직전 → 발생 시점"). */
.detail-snap { margin:0; display:flex; flex-direction:column; gap:.3rem; flex:1 1 240px; max-width:360px; }
.detail-snap figcaption { font-size:.72rem; font-weight:700; color:var(--ink-3); font-family:var(--mono); }
.detail-snap:last-child figcaption { color:var(--ink); }
.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:10px; background:#fff; padding:4px; box-shadow:var(--shadow); }
/* ---- Responsive: rail collapses above the stream ----------------- */

View file

@ -691,6 +691,53 @@
.catch(function () { /* 패널은 보조 정보 — 실패 시 마지막 상태 유지 */ });
}
// --- 24h catch-up brief ------------------------------------------------------------
// The operator sitting down at 08:00 needs "what happened overnight" in one line before
// the live stream takes over. Reuses GET /api/report?days=1; loaded once per page load
// (the live KPIs and list carry the present — this line carries the past).
var dayBrief = document.getElementById('day-brief');
function briefDuration(s) {
if (s === null || s === undefined) return null;
if (s < 60) return Math.round(s) + '초';
return Math.floor(s / 60) + '분 ' + Math.round(s % 60) + '초';
}
function renderDayBrief(r) {
if (!dayBrief) return;
var t = r.totals;
var severe = t.critical + t.high;
dayBrief.textContent = '';
dayBrief.hidden = false;
dayBrief.classList.toggle('has-severe', severe > 0);
var lead = document.createElement('strong');
lead.textContent = '지난 24시간';
dayBrief.appendChild(lead);
var parts = [];
if (t.events === 0) {
parts.push('감지 이벤트 없음');
} else {
parts.push('감지 ' + t.events + '건' + (severe > 0 ? ' (중대 ' + severe + ')' : ''));
if (r.near_miss.near_fall > 0) parts.push('니어미스 ' + r.near_miss.near_fall);
var avg = briefDuration(r.tta.avg_s);
if (avg) parts.push('평균 대응 ' + avg);
parts.push(t.unacked > 0 ? '미확인 ' + t.unacked + '건 남음' : '전부 확인됨');
}
dayBrief.appendChild(document.createTextNode(' — ' + parts.join(' · ') + ' '));
var link = document.createElement('a');
link.href = B + '/report';
link.textContent = '리포트 보기 →';
dayBrief.appendChild(link);
}
function loadDayBrief() {
if (!dayBrief) return;
fetch(B + '/api/report?days=1', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data) renderDayBrief(data); })
.catch(function () { /* 배너는 캐치업 보조 — 실패 시 그냥 숨김 유지 */ });
}
document.addEventListener('click', unlockAudio);
// Filter bar (client-side) + "더 보기" (server-side cursor).
@ -707,6 +754,7 @@
loadEvents();
loadDevices();
loadFpStats();
loadDayBrief();
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
setInterval(loadFpStats, FP_STATS_INTERVAL_MS);
})();

View file

@ -135,14 +135,25 @@
snapBox.appendChild(none);
return;
}
snaps.forEach(function (s) {
if (!s || !s.file_uuid) return;
// The edge sends an evidence TIMELINE (oldest → the event moment), so caption by
// position from the end — exact offsets are edge config the server doesn't know.
var CAPTIONS = ['이전', '직전', '발생 시점'];
var kept = snaps.filter(function (s) { return s && s.file_uuid; });
kept.forEach(function (s, i) {
var fig = document.createElement('figure');
fig.className = 'detail-snap';
var img = document.createElement('img');
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
img.alt = '사고 스냅샷';
var cap = kept.length > 1
? CAPTIONS[Math.max(0, CAPTIONS.length - kept.length + i)] : '발생 시점';
img.alt = '사고 스냅샷 — ' + cap;
img.className = 'detail-snapshot';
img.loading = 'lazy';
snapBox.appendChild(img);
fig.appendChild(img);
var fc = document.createElement('figcaption');
fc.textContent = cap;
fig.appendChild(fc);
snapBox.appendChild(fig);
});
}

View file

@ -66,6 +66,9 @@ render_header('이벤트');
<div id="events-status" class="kpi-strip" aria-label="미확인 이벤트 현황"></div>
</div>
</header>
<!-- Catch-up line for the operator who just sat down: what the last 24h held while
nobody was watching. Hidden until the numbers arrive (an empty banner is noise). -->
<div id="day-brief" class="day-brief" hidden></div>
<div id="events-root" class="event-list" aria-live="polite" aria-busy="true">
<div class="stream-empty">감지 이벤트를 불러오는 중…</div>
</div>

View file

@ -120,6 +120,30 @@ def test_dropout_then_redetection_still_confirms_fall():
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

View file

@ -12,6 +12,26 @@ def test_ringbuffer_keeps_recent():
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)