move_detect/edge/tracking.py
유창욱 32f2237db7 fix(edge): close audit P2-6/7/8 - id swaps, one-frame FALLING, stale-track timers
- P2-6 tracking: global greedy IoU matching (all pairs, best overlap first)
  replaces track-iteration-order greedy, so a crossing worker's box can no
  longer be stolen by whichever track came first. Any track in a contended
  match (person overlapping 2 tracks, or track overlapping 2 persons) is
  reported via tracker.ambiguous(); run_once resets its detector + smoother
  state so FALLEN/baseline/immobility never ride an id swap onto the wrong
  person. A genuinely fallen worker re-confirms within prone_confirm_s.
- P2-7 fall: STANDING -> FALLING now requires sustained prone
  (falling_confirm_s, 1.0s). One prone-looking frame (a bend, a bbox glitch)
  no longer arms the ghost detector's CRITICAL if the person then leaves.
  FALLEN / no_motion timing is unchanged (keyed off prone_since).
- P2-8 app: run_once feeds the engine and smoothers only tracks re-detected
  this tick; a track coasting inside max_age with a frozen frame is no-data,
  so prone-confirm / no-motion / immobility timers cannot accrue on stale
  data. Re-detection still confirms the fall (dropout pauses evidence).

TDD: 7 new tests verified failing on the pre-fix code (git stash), then
203 passed / 2 skipped (hardware) with the fixes.
2026-08-06 17:01:24 +09:00

100 lines
3.8 KiB
Python

from collections import deque
from dataclasses import dataclass
from edge.models import Person, BBox, Keypoint
@dataclass
class TrackFrame:
t: float
bbox: BBox
keypoints: list # list[Keypoint]
@dataclass
class Track:
track_id: int
frames: deque
@property
def latest(self) -> TrackFrame:
return self.frames[-1]
def _iou(a: BBox, b: BBox) -> float:
ix1, iy1 = max(a.x1, b.x1), max(a.y1, b.y1)
ix2, iy2 = min(a.x2, b.x2), min(a.y2, b.y2)
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
union = a.width * a.height + b.width * b.height - inter
return inter / union if union else 0.0
class IouTracker:
"""경량 IoU 그리디 매칭 추적기. 실장비는 ByteTrack 어댑터로 교체 가능(동일 TrackerPort)."""
def __init__(self, iou_thr: float = 0.3, max_age: float = 1.0, history: int = 90):
self.iou_thr = iou_thr
self.max_age = max_age
self.history = history
self._tracks: dict[int, Track] = {}
self._last_seen: dict[int, float] = {}
self._lost: list[Track] = []
self._ambiguous: set[int] = set()
self._next_id = 1
def update(self, persons: list[Person], t: float) -> list[Track]:
self._lost = []
self._ambiguous = set()
# 전역 그리디: 모든 (트랙, 사람) 쌍을 IoU 내림차순으로 배정. 트랙 순회 순서 우선의
# 로컬 그리디는 교차 시 먼저 온 트랙이 남의 박스를 가로채(0.48이 0.74를 이김) FALLEN
# 상태·frozen baseline이 다른 사람에게 넘어갔다.
pairs = []
track_cands: dict[int, int] = {}
person_cands: dict[int, int] = {}
for tid, track in self._tracks.items():
for i, p in enumerate(persons):
iou = _iou(track.latest.bbox, p.bbox)
if iou >= self.iou_thr:
pairs.append((iou, tid, i))
track_cands[tid] = track_cands.get(tid, 0) + 1
person_cands[i] = person_cands.get(i, 0) + 1
pairs.sort(key=lambda pr: pr[0], reverse=True)
used_tracks: set[int] = set()
used_persons: set[int] = set()
for _iou_v, tid, i in pairs:
if tid in used_tracks or i in used_persons:
continue
used_tracks.add(tid)
used_persons.add(i)
p = persons[i]
self._tracks[tid].frames.append(TrackFrame(t, p.bbox, p.keypoints))
self._last_seen[tid] = t
# 경합 = 저신뢰 매칭. 한 사람이 두 트랙과, 또는 한 트랙이 두 사람과 임계 이상 겹치면
# (교차) 어느 배정이든 스왑일 수 있다 — 연루된 모든 트랙을 표시해 호출자가 트랙별
# 안전 상태(FALLEN·baseline·부동 타이머)를 리셋하게 한다.
for _iou_v, tid, i in pairs:
if track_cands[tid] > 1 or person_cands[i] > 1:
self._ambiguous.add(tid)
# 신규 트랙
for i, p in enumerate(persons):
if i in used_persons:
continue
tid = self._next_id
self._next_id += 1
self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)],
maxlen=self.history))
self._last_seen[tid] = t
# 소실 판정
for tid in list(self._tracks.keys()):
if t - self._last_seen[tid] > self.max_age:
self._lost.append(self._tracks.pop(tid))
self._last_seen.pop(tid, None)
return list(self._tracks.values())
def lost(self) -> list[Track]:
return list(self._lost)
def ambiguous(self) -> set[int]:
"""직전 update에서 겹침 경합에 연루된(매칭이 스왑일 수 있는) 트랙 id."""
return set(self._ambiguous)