From 752aabaff11eec43b40a4d23466dc824dc88e1da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sat, 11 Jul 2026 22:19:39 +0900 Subject: [PATCH] fix(edge): forget per-track state on lost tracks (FIX 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fall._states, immobility._still_since/_fired, zone._inside_since/_fired, and engine._last_emit were never pruned when a track was lost (ghost already pruned via on_lost). On a 24/7 edge device this is unbounded growth as track IDs keep incrementing. Add forget(track_id) to FallDetector, ImmobilityWatchdog, and ZoneIntrusionDetector (drops that track's per-track keys/sets). EventEngine.forget(track_id) calls each sub-detector's forget and removes _last_emit cooldown keys whose tuple's second element is that track_id (covers FALL/NO_MOTION/IMMOBILITY/zone/GHOST — GHOST keys are (GHOST, track_id) after FIX 4). EventEngine.process now calls self.forget(tr.track_id) for every track in lost_tracks after emitting, since ghost.on_lost already consumed that track's ghost-relevant state at the top of process(). app.py run_once already calls pool.forget(tr.track_id) for lost tracks as part of FIX 1, completing the cleanup chain (smoother pool + detector state + dedup cache). Tests: tests/test_engine.py adds test_forget_prunes_state_on_lost — fires a FALL event, then passes the track as lost, and asserts engine._last_emit no longer has that track's keys and fall._states no longer has the track id. --- edge/detectors/fall.py | 3 +++ edge/detectors/immobility.py | 4 ++++ edge/detectors/zone.py | 4 ++++ edge/engine.py | 12 ++++++++++++ tests/test_engine.py | 16 ++++++++++++++++ 5 files changed, 39 insertions(+) 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)