diff --git a/edge/engine.py b/edge/engine.py new file mode 100644 index 0000000..1b567e2 --- /dev/null +++ b/edge/engine.py @@ -0,0 +1,73 @@ +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 + + +@dataclass +class EngineConfig: + camera_id: str = "cam" + cooldown_s: float = 30.0 + + +class EventEngine: + def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, + ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, + cfg: EngineConfig): + self.fall = fall + self.immobility = immobility + self.ghost = ghost + self.zone = zone + 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 _mk(self, etype: EventType, now: float, track_id=None, zone_id=None, + metrics=None, spatial=None) -> Event: + return Event(etype, severity_for(etype), self.cfg.camera_id, track_id, + zone_id, now, 0.0, metrics or {}, spatial) + + def process(self, active_tracks: list[Track], lost_tracks: list[Track], + now: float) -> list[Event]: + events: list[Event] = [] + self.ghost.on_lost(lost_tracks, now) + + for tr in active_tracks: + zone_hits = self.zone.update(tr, now) + in_zone = bool(self.zone.active_zone_ids(tr.track_id)) + + 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, now, tr.track_id, metrics=meta)) + elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now): + events.append(self._mk(EventType.NO_MOTION, now, tr.track_id, metrics=meta)) + + 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, now, tr.track_id, metrics=imeta)) + + for hit in zone_hits: + etype = (EventType.ZONE_RESTRICTED if hit["level"] == "restricted" + else EventType.ZONE_WARNING) + if self._allow((etype, tr.track_id, hit["zone_id"]), now): + events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"])) + + for g in self.ghost.update(active_tracks, now): + key = (EventType.GHOST, round(g["spatial"][0]), round(g["spatial"][1])) + if self._allow(key, now): + events.append(self._mk(EventType.GHOST, now, spatial=g["spatial"], + metrics={"last_state": g["last_state"], + "waited_s": g["waited_s"]})) + return events diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..816502f --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,49 @@ +from collections import deque +from edge.models import Keypoint, BBox +from edge.tracking import Track, TrackFrame +from edge.events import EventType +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 + +def _lying_kps(): + 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 kps + +def _lying_track(track, t): + track.frames.append(TrackFrame(t, BBox(90, 290, 210, 320), _lying_kps())) + return track + +def _engine(): + return EventEngine( + FallDetector(FallConfig(prone_confirm_s=0.5)), + ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), + GhostIncidentDetector(GhostConfig()), + ZoneIntrusionDetector([], ZoneConfig()), + EngineConfig(camera_id="cam-01", cooldown_s=30.0), + ) + +def test_fall_produces_fall_event(): + eng = _engine() + tr = Track(1, deque(maxlen=90)) + _lying_track(tr, 0.0) + eng.process([tr], [], now=0.0) + _lying_track(tr, 0.6) + events = eng.process([tr], [], now=0.6) + types = [e.event_type for e in events] + assert EventType.FALL in types + assert events[0].camera_id == "cam-01" + +def test_cooldown_suppresses_duplicate(): + eng = _engine() + tr = Track(1, deque(maxlen=90)) + _lying_track(tr, 0.0); eng.process([tr], [], now=0.0) + _lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # FALL 1회 + _lying_track(tr, 0.7) + again = eng.process([tr], [], now=0.7) + assert all(e.event_type != EventType.FALL for e in again)