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
212 lines
13 KiB
Python
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
|