diff --git a/edge/detectors/fall.py b/edge/detectors/fall.py index 90202fc..8256a52 100644 --- a/edge/detectors/fall.py +++ b/edge/detectors/fall.py @@ -36,6 +36,9 @@ class FallDetector: self.cfg = cfg self._states: dict[int, _State] = {} + def forget(self, track_id: int) -> None: + self._states.pop(track_id, None) + def _prone(self, kps, bbox, st: _State) -> tuple[bool, dict]: angle = torso_angle_deg(kps) ar = bbox.aspect diff --git a/edge/detectors/immobility.py b/edge/detectors/immobility.py index e3a35bc..3a0dd6d 100644 --- a/edge/detectors/immobility.py +++ b/edge/detectors/immobility.py @@ -17,6 +17,10 @@ class ImmobilityWatchdog: self._still_since: dict[int, float] = {} self._fired: set[int] = set() + def forget(self, track_id: int) -> None: + self._still_since.pop(track_id, None) + self._fired.discard(track_id) + def _displacement(self, track: Track) -> float: if len(track.frames) < 2: return 1.0 diff --git a/edge/detectors/zone.py b/edge/detectors/zone.py index e15370b..80cbdd4 100644 --- a/edge/detectors/zone.py +++ b/edge/detectors/zone.py @@ -36,6 +36,10 @@ class ZoneIntrusionDetector: self._inside_since: dict[tuple[int, str], float] = {} self._fired: set[tuple[int, str]] = set() + def forget(self, track_id: int) -> None: + self._inside_since = {k: v for k, v in self._inside_since.items() if k[0] != track_id} + self._fired = {k for k in self._fired if k[0] != track_id} + def _foot(self, track: Track) -> tuple[float, float]: kps = track.latest.keypoints a, b = kps[L_ANKLE], kps[R_ANKLE] diff --git a/edge/engine.py b/edge/engine.py index b469a70..5074a3c 100644 --- a/edge/engine.py +++ b/edge/engine.py @@ -32,6 +32,14 @@ class EventEngine: 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._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, now: float, track_id=None, zone_id=None, metrics=None, spatial=None) -> Event: m = metrics or {} @@ -73,4 +81,8 @@ class EventEngine: spatial=g["spatial"], 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 diff --git a/tests/test_engine.py b/tests/test_engine.py index 014ced7..a969631 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -69,6 +69,22 @@ def test_no_motion_event_carries_duration(): assert e.duration_s == e.metrics["immobility_s"] +def test_forget_prunes_state_on_lost(): + 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) # FALL fires + assert any(e.event_type == EventType.FALL for e in events) + assert (EventType.FALL, 1) in eng._last_emit + assert 1 in eng.fall._states + + # track is now lost -> engine must prune its per-track state + eng.process([], [tr], now=1.0) + assert not any(len(k) >= 2 and k[1] == 1 for k in eng._last_emit) + assert 1 not in eng.fall._states + + def test_allow_cooldown_primitive(): eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0) key = ("FALL", 1)