Files
silver_pose/v1/tracking.py
T

103 lines
3.4 KiB
Python

"""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)