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:
parent
d8cef54fe4
commit
51eed139cf
9 changed files with 170 additions and 10 deletions
|
|
@ -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/바리스트/타일/필터).
|
||||
|
|
|
|||
19
edge/app.py
19
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ----------------- */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue