"""Lightweight deterministic person tracking for the single-camera V1 flow.""" from dataclasses import dataclass from math import hypot from typing import Dict, Sequence, Tuple from v1.pose import PersonPose @dataclass(frozen=True) class TrackedPersonPose: track_id: str detected_at_monotonic: float pose: PersonPose @dataclass class _Track: center: Tuple[float, float] last_seen_at: float class PersonTracker: """Assign stable IDs by nearest normalized box center across adjacent frames.""" def __init__( self, max_match_distance_ratio: float = 0.2, max_age_seconds: float = 2.0 ) -> None: if not 0.0 < max_match_distance_ratio <= 1.0: raise ValueError("max_match_distance_ratio must be in (0, 1]") if max_age_seconds <= 0: raise ValueError("max_age_seconds must be positive") self._max_match_distance_ratio = max_match_distance_ratio self._max_age_seconds = max_age_seconds self._tracks: Dict[str, _Track] = {} self._next_track_number = 1 def update( self, poses: Sequence[PersonPose], detected_at_monotonic: float, frame_size: Tuple[int, int], ) -> Sequence[TrackedPersonPose]: width, height = frame_size if width <= 0 or height <= 0: raise ValueError("frame_size must contain positive width and height") self._expire_tracks(detected_at_monotonic) available_ids = set(self._tracks) tracked = [] for pose in poses: center = self._box_center(pose) track_id = self._nearest_available_track(center, available_ids, width, height) if track_id is None: track_id = "P-{0:04d}".format(self._next_track_number) self._next_track_number += 1 else: available_ids.remove(track_id) self._tracks[track_id] = _Track(center=center, last_seen_at=detected_at_monotonic) tracked.append( TrackedPersonPose( track_id=track_id, detected_at_monotonic=float(detected_at_monotonic), pose=pose, ) ) return tuple(tracked) def _nearest_available_track( self, center: Tuple[float, float], available_ids: set, width: int, height: int, ): closest_id = None closest_distance = None for track_id in available_ids: previous = self._tracks[track_id].center distance = hypot( (center[0] - previous[0]) / float(width), (center[1] - previous[1]) / float(height), ) if distance <= self._max_match_distance_ratio and ( closest_distance is None or distance < closest_distance ): closest_id = track_id closest_distance = distance return closest_id def _expire_tracks(self, now: float) -> None: expired_ids = [ track_id for track_id, track in self._tracks.items() if now - track.last_seen_at > self._max_age_seconds ] for track_id in expired_ids: del self._tracks[track_id] @staticmethod def _box_center(pose: PersonPose) -> Tuple[float, float]: left, top, right, bottom = pose.box_xyxy return ((left + right) / 2.0, (top + bottom) / 2.0)