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)