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