- 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.
100 lines
3.8 KiB
Python
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)
|