앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.
읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.
크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.
증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.
증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.
마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).
검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
139 lines
7.2 KiB
Python
139 lines
7.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 Event, EventType, severity_for
|
|
|
|
# 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)
|
|
|
|
# 사진을 붙이지 않는 유형.
|
|
#
|
|
# 기준은 "이 사진이 사건의 증거인가, 그 사람이 거기 있었다는 기록인가"이다. 넘어짐·추락·협착은
|
|
# 무슨 일이 일어났는지가 프레임 안에 있고, 단독작업·정원 초과는 규칙 위반 자체가 인원 수라서
|
|
# 사진이 그 수를 증명한다(관리자가 오탐인지 확인하는 수단이기도 하다). 아래 넷은 다르다:
|
|
#
|
|
# ERGO_REPORT — 카메라 단위 익명 통계. 프레임을 붙이면 통계가 감시 영상이 된다.
|
|
# OVERSTAY — 발화 조건이 시간 경과 하나뿐이다. "정상 작업을 오래 했다"는 사건이 아니고,
|
|
# 사진이 말해 주는 것은 그게 누구였고 무엇을 입었는지뿐이다. 그 사람이 실제로
|
|
# 쓰러지면 fall/no_motion 이 따로 발화하고 그쪽에는 증거가 붙는다.
|
|
# DEVICE_FAULT / MONITORING_DEGRADED — 장치 상태. 사람이 찍힐 이유가 없다.
|
|
#
|
|
# 이 목록이 짧게 유지되는 것이 중요하다. "정상 근무 중에는 아무것도 남지 않는다"는 우리가
|
|
# 작업자에게 한 약속인데, 시간이 조건인 유형에 사진을 붙이는 순간 그 약속이 거짓이 된다.
|
|
NO_EVIDENCE_TYPES = frozenset({
|
|
EventType.ERGO_REPORT,
|
|
EventType.OVERSTAY,
|
|
EventType.DEVICE_FAULT,
|
|
EventType.MONITORING_DEGRADED,
|
|
})
|
|
|
|
|
|
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,
|
|
integrity=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)
|
|
# Camera-integrity findings are about the FRAME, not about any track, so they are produced
|
|
# here (where the frame is) rather than inside the engine (which only sees tracks). They
|
|
# matter because a camera that has been knocked askew keeps producing perfectly healthy
|
|
# frames while every zone drawn on it now points somewhere else — the failure that used to
|
|
# be invisible until after an incident.
|
|
if integrity is not None:
|
|
stamp = wall_now if wall_now is not None else now
|
|
for etype, metrics in integrity.observe(frame, now):
|
|
et = EventType(etype)
|
|
events.append(Event(et, severity_for(et), engine.cfg.camera_id, None, None,
|
|
stamp, 0.0, metrics, None))
|
|
for tr in lost:
|
|
pool.forget(tr.track_id)
|
|
if events:
|
|
# 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다.
|
|
evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES]
|
|
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 in NO_EVIDENCE_TYPES 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()),
|
|
}
|