move_detect/edge/engine.py
유창욱 93f896af87 fix(audit): 1.2.0 4차 재감사 — 확정 23건 전량 해소
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다.

이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는**
결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나,
값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다.

CRITICAL 4건:

R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now
기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는
항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서
초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는
회귀 테스트를 넣었다.

R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50]
이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은
2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달
불가였다.

R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어
400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이
하지 않는 일을 약속하고 있었다.

R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에
트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시
prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이
진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다.
ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다.

HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두
희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한
사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던
문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개
누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프.

BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번
실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션
목록도 같은 이유로 테스트를 붙였다.

MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된
그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다.
데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인
넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져
눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다.
문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을
소개서 감지 표에 넣었다.

검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건.
dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 23:11:57 +09:00

212 lines
13 KiB
Python

from dataclasses import dataclass
from edge.tracking import Track
from edge.events import Event, EventType, severity_for
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.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
from edge.detectors.height import DescentWatch, DescentConfig
@dataclass
class EngineConfig:
camera_id: str = "cam"
cooldown_s: float = 30.0
_OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
"overcapacity": EventType.OVERCAPACITY,
"overstay": EventType.OVERSTAY}
class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None,
ergonomics: ErgoMonitor | None = None,
descent: DescentWatch | None = None):
self.fall = fall
self.immobility = immobility
self.ghost = ghost
self.zone = zone
# Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working.
self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig())
self.ergonomics = ergonomics if ergonomics is not None else ErgoMonitor(ErgoConfig())
self.descent = descent if descent is not None else DescentWatch(DescentConfig())
self.cfg = cfg
self._last_emit: dict[tuple, float] = {}
# 트랙별 마지막 구역. 소실 이벤트(ghost/추락)는 소실 후에 발보되므로
# 그때는 트랙이 없다 — 마지막으로 있던 구역을 들고 있어야 실을 수 있다.
self._last_zone: dict[int, str | None] = {}
def _allow(self, key: tuple, now: float) -> bool:
last = self._last_emit.get(key)
if last is not None and now - last < self.cfg.cooldown_s:
return False
self._last_emit[key] = now
return True
def forget(self, track_id: int) -> None:
"""트랙 소실 시 하위 감지기 + dedup 캐시의 트랙별 상태를 정리 (24/7 장치 메모리 누수 방지)."""
self.fall.forget(track_id)
self.immobility.forget(track_id)
self.zone.forget(track_id)
self.descent.forget(track_id)
self.ghost.forget(track_id)
self._last_zone.pop(track_id, None)
self.occupancy.forget(track_id)
self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)}
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
metrics=None, spatial=None) -> Event:
m = metrics or {}
duration_s = m.get("immobility_s") or m.get("waited_s") or m.get("dwell_s") \
or m.get("alone_s") or 0.0
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
zone_id, occurred_at, duration_s, m, spatial)
def process(self, active_tracks: list[Track], lost_tracks: list[Track],
now: float, frame_wh=None, wall_now: float | None = None) -> list[Event]:
# `now` is a MONOTONIC clock (never jumps): it drives every detector timer, hysteresis,
# and cooldown, so an NTP step cannot instantly satisfy immobile_s / expire all tracks /
# reset a genuine immobility countdown. `wall_now` is real wall-clock time, used ONLY to
# stamp Event.occurred_at (the server correlates and retains by wall time). Defaults to
# `now` so single-clock callers (unit tests) keep their existing occurred_at behavior.
occurred_at = wall_now if wall_now is not None else now
events: list[Event] = []
self.ghost.on_lost(lost_tracks, now)
for tr in active_tracks:
# frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh)
active_zones = self.zone.active_zone_ids(tr.track_id)
in_zone = bool(active_zones)
# 사람 상태 이벤트(넘어짐·미동·소실)에도 구역을 싣는다. 서버의 동시 다발 판정이
# "같은 구역"으로 묶으려면 이 값이 있어야 하고, 없으면 카메라가 다른 두 희생자가
# 절대 묶이지 않는다 — 밀폐공간이 정확히 그 상황이다.
# 여러 구역이 겹치면 정렬해 첫 번째를 쓴다(같은 입력에 같은 답이 나오도록).
here = sorted(active_zones)[0] if active_zones else None
self._last_zone[tr.track_id] = here
# 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미
# 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다.
foot = self.zone._foot(tr, frame_wh)
# 하강 감시는 낙상 판정보다 먼저 — 같은 틱에 확정된 넘어짐을 승격할 수 있어야 한다.
descent = self.descent.update(tr, now)
elevated = self.zone.in_elevated_zone(tr.track_id)
if descent is not None or elevated:
# 위험 자세가 아니어도 ghost 를 무장시킨다. 발판에 서 있다가 화각 밖으로
# 떨어지는 사람은 소실 순간 STANDING 이라, 기존 무장 조건으로는 안 걸린다.
self.ghost.arm_height(tr.track_id, descent, elevated, now)
sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
# 자세 부담 노출은 사고 상태가 아닌 트랙만 — 쓰러진 사람의 90° 몸통은
# 인체공학 노출이 아니라 이미 사고로 처리 중인 상태다.
if state is None or state.name == "STANDING":
self.ergonomics.update(tr, now)
if sig == "fall":
# 방금 떨어진 사람이 바닥에서 확정되면 그것은 넘어짐이 아니라 추락이다.
# 두 건으로 올리지 않고 유형만 올린다 — 한 번의 사고는 한 건이다.
# 착지(prone 시작) 시각과 하강을 비교한다. now 로 비교하면 확정이
# prone_confirm_s 만큼 늦게 오므로 기본값에서 영원히 거짓이 된다.
prone_since = state.prone_since if state is not None else None
falling = self.descent.landed_from_drop(tr.track_id, prone_since)
etype = EventType.FALL_FROM_HEIGHT if (falling or elevated) else EventType.FALL
if self._allow((etype, tr.track_id), now):
m = dict(meta)
d = self.descent.detail(tr.track_id)
if d:
m.update(d)
if elevated:
m["elevated_zone"] = True
events.append(self._mk(etype, occurred_at, tr.track_id,
metrics=m, spatial=foot, zone_id=here))
if etype is EventType.FALL_FROM_HEIGHT:
# 프레임 안에서 이미 냈으므로 소실 경로가 같은 사고를 또 내지 않게 한다.
self.ghost.disarm_height(tr.track_id)
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id,
metrics=meta, spatial=foot, zone_id=here))
elif sig == "near_fall" and self._allow((EventType.NEAR_FALL, tr.track_id), now):
# 니어미스(FALLING까지 갔다 확정 전 회복): 알람용이 아니라 히트맵/통계 재료.
events.append(self._mk(EventType.NEAR_FALL, occurred_at, tr.track_id,
metrics=meta, spatial=foot))
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id,
metrics=imeta, spatial=foot))
for hit in zone_hits:
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
# PINCH/HIGH — the old `restricted else WARNING` silently downgraded it. The
# dashboard offers exactly these three levels (see zones.php).
etype = {"pinch": EventType.PINCH,
"restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
events.append(self._mk(etype, occurred_at, tr.track_id,
zone_id=hit["zone_id"], spatial=foot))
# 존 규칙(2인 1조 · 정원 · 체류): 위 per-track 루프에서 zone.update가 갱신한 멤버십을
# 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로
# 여기 인원수는 이번 틱에 실제로 검출된 사람만 센다.
zone_tracks: dict[str, set[int]] = {}
for tr in active_tracks:
for zid in self.zone.active_zone_ids(tr.track_id):
zone_tracks.setdefault(zid, set()).add(tr.track_id)
for o in self.occupancy.update(self.zone.zones, zone_tracks, now):
etype = _OCCUPANCY_TYPES[o["kind"]]
# 쿨다운 키에 zone_id 포함: 존 A의 위반이 존 B의 발보를 막지 않는다. track_id가
# None인 존 단위 이벤트의 키는 forget()이 지우지 못하지만, 존 수에 비례하는
# 소량이라 누수로 보지 않는다 (ghost와 달리 키 공간이 유한).
if self._allow((etype, o["track_id"], o["zone_id"]), now):
events.append(self._mk(etype, occurred_at, track_id=o["track_id"],
zone_id=o["zone_id"], metrics=o["metrics"]))
# 자세 부담 주기 리포트 — 간격이 자체 게이트라 쿨다운(_allow)을 타지 않는다.
# 카메라 단위 익명 합계: track_id·spatial 없음 (개인 좌표를 남기지 않는다).
ergo = self.ergonomics.report(now)
if ergo is not None:
events.append(self._mk(EventType.ERGO_REPORT, occurred_at, metrics=ergo))
for g in self.ghost.update(active_tracks, now):
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it
# emits, and track ids are never reused, so a given track fires GHOST at most once ever.
# Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for
# a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that
# forget() can never reclaim (the track never appears in lost_tracks again).
# Ghost's loss point is camera-native pixels (bbox centre at loss time). Normalize to
# the same 0..1 space every other event's spatial uses so the heatmap can overlay them;
# frame_wh=None (unit tests) keeps raw coords, matching the zone detector's convention.
gs = g["spatial"]
if frame_wh and gs is not None:
w, h = frame_wh
if w > 0 and h > 0:
gs = (gs[0] / w, gs[1] / h)
gmeta = {"last_state": g["last_state"], "waited_s": g["waited_s"]}
# 추락 후보로 무장돼 있었으면 '위험 상태 소실'이 아니라 '추락'이다. 화각 밖으로
# 떨어진 사람에게는 이쪽이 유일한 발보 경로이므로 유형이 정확해야 한다.
if g.get("height_fall"):
for k in ("drop_ratio", "drop_s", "from_y2", "to_y2", "armed_by", "elevated_zone"):
if k in g:
gmeta[k] = g[k]
gmeta["exited_frame"] = True
etype = EventType.FALL_FROM_HEIGHT
else:
etype = EventType.GHOST
events.append(self._mk(etype, occurred_at, track_id=g["track_id"],
zone_id=self._last_zone.get(g["track_id"]),
spatial=gs, metrics=gmeta))
# ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe.
for tr in lost_tracks:
self.forget(tr.track_id)
return events