feat(edge): EventEngine orchestration with dedup + cooldown

This commit is contained in:
유창욱 2026-07-11 21:12:43 +09:00
parent 406c0f5b6c
commit e8e24c266f
2 changed files with 122 additions and 0 deletions

73
edge/engine.py Normal file
View file

@ -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

49
tests/test_engine.py Normal file
View file

@ -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)