move_detect/tests/test_app.py
유창욱 3aca674a78
Some checks failed
verify / edge (push) Has been cancelled
verify / server (push) Has been cancelled
feat: 이해관계자 페르소나 검증 4건 — 열람 감사 · 증거 보존 · 크론 생존 · 증거 정책 (1.3.0)
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 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
2026-09-25 18:34:26 +09:00

299 lines
14 KiB
Python

import numpy as np
from edge.models import Person, BBox, Keypoint
from edge.tracking import IouTracker
from edge.smoothing import TrackSmootherPool
from edge.detectors.fall import FallDetector, FallConfig
from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig
from edge.detectors.ghost import GhostIncidentDetector, GhostConfig
from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig
from edge.engine import EventEngine, EngineConfig
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.events import EventType
from edge.app import run_once
def _lying_person():
kps = [Keypoint(150, 300, 0.9) for _ in range(17)]
kps[5] = Keypoint(100, 300, 0.9); kps[6] = Keypoint(100, 310, 0.9)
kps[11] = Keypoint(200, 300, 0.9); kps[12] = Keypoint(200, 310, 0.9)
kps[0] = Keypoint(90, 320, 0.9)
return Person(BBox(90, 290, 210, 320), kps)
class _Source:
def read(self): return True, np.full((360, 640, 3), 128, np.uint8)
class _Pose:
def infer(self, frame): return [_lying_person()]
class _Sink:
def __init__(self): self.events = []
def try_send(self, event, snapshots):
self.events.append(event); return True
def test_run_once_pipeline_emits_fall_after_prone():
src, pose = _Source(), _Pose()
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=0.5)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.6])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
assert any(e.event_type == EventType.FALL for e in sink.events)
def test_run_once_stamps_events_with_wall_clock_not_monotonic():
# run_once must thread a separate wall clock through to occurred_at: the daemon runs the loop on
# a monotonic clock (NTP-step-immune timers) but events have to carry real wall time.
src, pose = _Source(), _Pose()
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=0.5)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
mono = iter([0.0, 0.6]) # monotonic loop clock (drives prone_confirm_s)
wallt = iter([1_700_000_000.0, 1_700_000_000.6]) # wall clock (stamps occurred_at)
run_once(src, pose, tracker, pool, engine, ring, snap, sink,
now_fn=lambda: next(mono), wall_fn=lambda: next(wallt))
run_once(src, pose, tracker, pool, engine, ring, snap, sink,
now_fn=lambda: next(mono), wall_fn=lambda: next(wallt))
falls = [e for e in sink.events if e.event_type == EventType.FALL]
assert falls and falls[0].occurred_at == 1_700_000_000.6
def _engine(**fall_kw):
return EventEngine(
FallDetector(FallConfig(**fall_kw)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
class _ScriptedPose:
def __init__(self, frames): self.frames = list(frames)
def infer(self, frame): return self.frames.pop(0)
def test_dropout_tick_does_not_feed_detectors_stale_frame():
# A track not re-detected this tick still sits in the tracker (< max_age) with a frozen
# latest frame. Passing it to the engine lets prone_confirm accrue on stale data: here the
# dropout tick alone would confirm the fall. A stale track must be treated as no-data.
src = _Source()
pose = _ScriptedPose([[_lying_person()], []])
tracker = IouTracker() # max_age=1.0 > dropout gap, so the track stays active
pool = TrackSmootherPool()
engine = _engine(prone_confirm_s=0.5, falling_confirm_s=0.1)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.6])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn) # dropout tick
assert not any(e.event_type == EventType.FALL for e in sink.events)
def test_dropout_then_redetection_still_confirms_fall():
# ...but the person re-detected still prone MUST confirm — the dropout pauses evidence,
# it does not erase the incident.
src = _Source()
pose = _ScriptedPose([[_lying_person()], [], [_lying_person()]])
tracker = IouTracker()
pool = TrackSmootherPool()
engine = _engine(prone_confirm_s=0.5, falling_confirm_s=0.1)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.3, 0.7])
def now_fn(): return next(times)
for _ in range(3):
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
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
from edge.detectors.fall import FallDetector, FallConfig
class _SnapSink:
def __init__(self): self.sent = []
def try_send(self, event, snapshots):
self.sent.append((event, snapshots)); return True
src = _Source()
pose = _ScriptedPose([[_person_at(500)], [_person_at(500)]])
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.5)))
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
times = iter([0.0, 0.6])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
ergo = [(e, s) for e, s in sink.sent if e.event_type == EventType.ERGO_REPORT]
assert len(ergo) == 1
assert ergo[0][1] == [] # 스냅샷 미부착
def _lying_person_at(x1, x2):
kps = [Keypoint((x1 + x2) / 2, 15, 0.9) for _ in range(17)]
return Person(BBox(x1, 0, x2, 20), kps)
def test_contended_crossing_resets_fall_state():
# Two prone tracks; then one box overlapping BOTH (a crossing). The surviving match may
# be a swap, so the contended track's fall state must reset — no FALL may fire off the
# pre-crossing prone timer.
src = _Source()
pose = _ScriptedPose([
[_lying_person_at(0, 40), _lying_person_at(30, 70)],
[_lying_person_at(15, 55)],
])
tracker = IouTracker(iou_thr=0.3)
pool = TrackSmootherPool()
engine = _engine(prone_confirm_s=0.05, falling_confirm_s=0.01)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.1])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
assert not any(e.event_type == EventType.FALL for e in sink.events)
def _person_at(cx):
# standing person centered at cx; nose at (cx, 60)
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
kps[5] = Keypoint(cx - 10, 100, 0.9); kps[6] = Keypoint(cx + 10, 100, 0.9)
kps[11] = Keypoint(cx - 10, 200, 0.9); kps[12] = Keypoint(cx + 10, 200, 0.9)
kps[0] = Keypoint(cx, 60, 0.9)
return Person(BBox(cx - 20, 60, cx + 20, 210), kps)
class _TwoPersonPose:
def __init__(self, cx_a, cx_b):
self.cx_a, self.cx_b = cx_a, cx_b
def infer(self, frame):
return [_person_at(self.cx_a), _person_at(self.cx_b)]
def test_two_persons_get_independent_smoothing():
# person A far on the left, person B far on the right; small per-frame
# jitter so IoU tracking stays continuous across frames.
src = _Source()
pose = _TwoPersonPose(cx_a=100, cx_b=900)
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
)
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
times = iter([0.0, 0.1])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
tracks = {tr.track_id: tr for tr in tracker._tracks.values()}
assert len(tracks) == 2
xs = sorted(tr.latest.keypoints[0].x for tr in tracks.values())
left_nose_x, right_nose_x = xs
# each track's smoothed nose must stay on its own side, nowhere near
# the midpoint between the two persons (500) — proves no shared filter
# state leaked between tracks.
assert left_nose_x < 300
assert right_nose_x > 700
def test_overstay_goes_out_without_evidence_while_the_same_batch_keeps_it():
"""체류 상한 초과에는 사진이 붙지 않는다 — 같은 배치의 구역 침입에는 붙는 채로.
상수만 검사하면 배선이 끊겨도 초록이 뜬다(이 프로젝트가 감사마다 잡아온 모양이다).
그래서 실제 run_once 를 태워, 한 번의 전송에서 한쪽은 증거를 들고 다른 쪽은 안 드는지를
본다. 체류 초과의 발화 조건은 시간 경과 하나뿐이라, 여기 사진이 붙으면 정상 작업자가
규정대로 일하는 동안 주기적으로 본인 사진이 서버로 가는 시스템이 된다.
"""
from edge.detectors.zone import Zone
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
class _SnapSink:
def __init__(self): self.sent = []
def try_send(self, event, snapshots):
self.sent.append((event, snapshots)); return True
poly = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] # 프레임 전체
zone = Zone("z1", "restricted", poly, {"max_dwell_s": 1.0})
src = _Source()
pose = _ScriptedPose([[_person_at(320)], [_person_at(320)]])
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([zone], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
# 기본 설정 그대로. 바꾸는 것은 존 규칙(현장 설정)인 max_dwell_s 뿐이다.
occupancy=OccupancyWatchdog(OccupancyConfig()))
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
# 트래커·스무더는 프레임 간에 공유한다 — 체류는 "같은 트랙이 계속 있었는가"라서,
# 매 호출 새 트래커를 주면 실제와 다른 조건에서 통과할 수 있다.
tracker, pool = IouTracker(), TrackSmootherPool()
times = iter([0.0, 1.5])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
by_type = {e.event_type: s for e, s in sink.sent}
assert EventType.OVERSTAY in by_type, "체류 상한 초과가 발화하지 않았습니다 — 테스트가 무의미합니다"
assert by_type[EventType.OVERSTAY] == [], \
"체류 상한 초과에 사진이 붙었습니다. 오래 있었다는 이유로 작업자 사진이 남습니다."
# 대조군: 같은 파이프라인에서 진짜 사건에는 증거가 붙어 있어야 한다. 이게 없으면
# 위 단언은 "증거 기능이 통째로 죽었다"로도 통과한다.
assert EventType.ZONE_RESTRICTED in by_type, "구역 침입이 발화하지 않았습니다"
assert by_type[EventType.ZONE_RESTRICTED], \
"구역 침입에서 증거가 사라졌습니다 — 사고 조사에서 볼 것이 없어집니다."