feat(v1): add pose quality evidence
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user