fix(edge): forget per-track state on lost tracks (FIX 5)

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.
This commit is contained in:
유창욱 2026-07-11 22:19:39 +09:00
parent f7266f5980
commit 752aabaff1
5 changed files with 39 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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