fix(edge): identity-aware GHOST dedup keyed on track_id (FIX 4)

edge/engine.py keyed the ghost cooldown as
(GHOST, round(x), round(y)) — purely spatial, ignoring track identity.
Two distinct workers collapsing at ~the same pixel within the cooldown
window would suppress each other's ghost event, potentially masking a
second CRITICAL incident.

edge/detectors/ghost.py GhostIncidentDetector.update now includes the
candidate's track_id in each emitted dict. edge/engine.py keys the
cooldown as (EventType.GHOST, track_id) and sets the emitted Event's
track_id accordingly, matching how FALL/NO_MOTION/IMMOBILITY/zone events
are already keyed per-track.

Tests: tests/test_ghost.py asserts ghosts[0]["track_id"] == 1 on the
existing reappear-timeout test.
This commit is contained in:
유창욱 2026-07-11 22:17:48 +09:00
parent f46ed40088
commit f7266f5980
3 changed files with 5 additions and 3 deletions

View file

@ -42,7 +42,7 @@ class GhostIncidentDetector:
out = [] out = []
for tid, (spatial, state, _height, lost_at) in list(self._candidates.items()): for tid, (spatial, state, _height, lost_at) in list(self._candidates.items()):
if now - lost_at >= self.cfg.reappear_s: if now - lost_at >= self.cfg.reappear_s:
out.append({"spatial": spatial, "last_state": state, out.append({"track_id": tid, "spatial": spatial, "last_state": state,
"waited_s": round(now - lost_at, 1)}) "waited_s": round(now - lost_at, 1)})
self._candidates.pop(tid, None) self._candidates.pop(tid, None)
return out return out

View file

@ -67,9 +67,10 @@ class EventEngine:
events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"])) events.append(self._mk(etype, now, tr.track_id, zone_id=hit["zone_id"]))
for g in self.ghost.update(active_tracks, now): for g in self.ghost.update(active_tracks, now):
key = (EventType.GHOST, round(g["spatial"][0]), round(g["spatial"][1])) key = (EventType.GHOST, g["track_id"])
if self._allow(key, now): if self._allow(key, now):
events.append(self._mk(EventType.GHOST, now, spatial=g["spatial"], events.append(self._mk(EventType.GHOST, now, track_id=g["track_id"],
spatial=g["spatial"],
metrics={"last_state": g["last_state"], metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]})) "waited_s": g["waited_s"]}))
return events return events

View file

@ -16,6 +16,7 @@ def test_lost_dangerous_track_becomes_ghost_after_wait():
assert len(ghosts) == 1 assert len(ghosts) == 1
assert ghosts[0]["last_state"] == "FALLEN" assert ghosts[0]["last_state"] == "FALLEN"
assert ghosts[0]["spatial"] == (110.0, 130.0) # bbox 중심 assert ghosts[0]["spatial"] == (110.0, 130.0) # bbox 중심
assert ghosts[0]["track_id"] == 1
def test_non_dangerous_lost_track_is_ignored(): def test_non_dangerous_lost_track_is_ignored():
d = GhostIncidentDetector(GhostConfig(reappear_s=1.0)) d = GhostIncidentDetector(GhostConfig(reappear_s=1.0))