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 @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): 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.cfg = cfg self._last_emit: dict[tuple, float] = {} 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.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) in_zone = bool(self.zone.active_zone_ids(tr.track_id)) # 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미 # 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다. foot = self.zone._foot(tr, frame_wh) 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") if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now): events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, metrics=meta, spatial=foot)) 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)) 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"])) 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) events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"], spatial=gs, metrics={"last_state": g["last_state"], "waited_s": g["waited_s"]})) # 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