From 51eed139cfbdfa32f0d55e199a34609d10d462c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 7 Aug 2026 07:25:25 +0900 Subject: [PATCH] feat(evidence): pre-event snapshot timeline + 24h catch-up brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/INDEX.md | 5 ++++ edge/app.py | 19 ++++++++++++-- edge/snapshot.py | 27 ++++++++++++++++--- server/public/assets/app.css | 15 +++++++++++ server/public/assets/app.js | 48 ++++++++++++++++++++++++++++++++++ server/public/assets/detail.js | 19 +++++++++++--- server/src/View/events.php | 3 +++ tests/test_app.py | 24 +++++++++++++++++ tests/test_snapshot.py | 20 ++++++++++++++ 9 files changed, 170 insertions(+), 10 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 03d8e7c..3f75601 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -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/바리스트/타일/필터). diff --git a/edge/app.py b/edge/app.py index 623957e..66bb3b9 100644 --- a/edge/app.py +++ b/edge/app.py @@ -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 diff --git a/edge/snapshot.py b/edge/snapshot.py index a374f89..140db1f 100644 --- a/edge/snapshot.py +++ b/edge/snapshot.py @@ -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: diff --git a/server/public/assets/app.css b/server/public/assets/app.css index a5832e0..25344ce 100644 --- a/server/public/assets/app.css +++ b/server/public/assets/app.css @@ -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 ----------------- */ diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 3e68909..f07612d 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -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); })(); diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js index 2cd9d12..ab0e6b1 100644 --- a/server/public/assets/detail.js +++ b/server/public/assets/detail.js @@ -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); }); } diff --git a/server/src/View/events.php b/server/src/View/events.php index 658f698..136e31d 100644 --- a/server/src/View/events.php +++ b/server/src/View/events.php @@ -66,6 +66,9 @@ render_header('이벤트');
+ +
감지 이벤트를 불러오는 중…
diff --git a/tests/test_app.py b/tests/test_app.py index da718a8..6e255be 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index e69cb47..7bbed90 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -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)