from __future__ import annotations from dataclasses import dataclass import numpy as np from scipy.optimize import linear_sum_assignment def _box_to_state(box: np.ndarray) -> np.ndarray: x0, y0, x1, y1 = box width, height = x1 - x0, y1 - y0 cx, cy = x0 + width / 2.0, y0 + height / 2.0 scale = width * height aspect = width / max(height, 1e-6) return np.array([cx, cy, scale, aspect], dtype=np.float64) def _state_to_box(state: np.ndarray) -> np.ndarray: cx, cy, scale, aspect = state[:4] scale = max(scale, 1e-6) width = np.sqrt(scale * aspect) height = scale / max(width, 1e-6) return np.array( [cx - width / 2.0, cy - height / 2.0, cx + width / 2.0, cy + height / 2.0], dtype=np.float64, ) def iou_matrix(boxes_a: np.ndarray, boxes_b: np.ndarray) -> np.ndarray: if boxes_a.shape[0] == 0 or boxes_b.shape[0] == 0: return np.zeros((boxes_a.shape[0], boxes_b.shape[0]), dtype=np.float64) area_a = (boxes_a[:, 2] - boxes_a[:, 0]) * (boxes_a[:, 3] - boxes_a[:, 1]) area_b = (boxes_b[:, 2] - boxes_b[:, 0]) * (boxes_b[:, 3] - boxes_b[:, 1]) top_left = np.maximum(boxes_a[:, None, :2], boxes_b[None, :, :2]) bottom_right = np.minimum(boxes_a[:, None, 2:], boxes_b[None, :, 2:]) width_height = np.clip(bottom_right - top_left, 0, None) intersection = width_height[..., 0] * width_height[..., 1] union = area_a[:, None] + area_b[None, :] - intersection return intersection / np.clip(union, 1e-9, None) class _KalmanBoxTracker: """Constant-velocity Kalman filter over (cx, cy, scale, aspect) — the classic SORT state. Aspect ratio is treated as constant (no velocity term for it), matching Bewley et al. 2016. """ _next_id = 1 def __init__(self, box: np.ndarray, label: int, score: float) -> None: self.state = np.zeros(7, dtype=np.float64) self.state[:4] = _box_to_state(box) self.covariance = np.eye(7) * 10.0 self.covariance[4:, 4:] *= 1000.0 self._transition = np.eye(7) for i in range(3): self._transition[i, i + 4] = 1.0 self._observation = np.zeros((4, 7)) self._observation[:4, :4] = np.eye(4) self._process_noise = np.eye(7) * 1.0 self._process_noise[4:, 4:] *= 0.01 self._measurement_noise = np.eye(4) * 1.0 self.id = _KalmanBoxTracker._next_id _KalmanBoxTracker._next_id += 1 self.label = label self.score = score self.hits = 1 self.age = 0 self.time_since_update = 0 def predict(self) -> np.ndarray: self.state = self._transition @ self.state self.covariance = self._transition @ self.covariance @ self._transition.T + self._process_noise self.age += 1 self.time_since_update += 1 state = self.state.copy() state[2] = max(state[2], 1e-6) return _state_to_box(state) def update(self, box: np.ndarray, label: int, score: float) -> None: measurement = _box_to_state(box) innovation = measurement - self._observation @ self.state innovation_cov = self._observation @ self.covariance @ self._observation.T + self._measurement_noise kalman_gain = self.covariance @ self._observation.T @ np.linalg.inv(innovation_cov) self.state = self.state + kalman_gain @ innovation self.covariance = (np.eye(7) - kalman_gain @ self._observation) @ self.covariance self.label = label self.score = score self.hits += 1 self.time_since_update = 0 def current_box(self) -> np.ndarray: return _state_to_box(self.state) @dataclass class Track: id: int box: tuple[float, float, float, float] label: int score: float hits: int age: int class SortTracker: """Minimal SORT-style tracker: Kalman motion prediction + IoU/Hungarian association. This sits *outside* the model as a post-processing layer over independent per-frame detections — ObjectModel-v1 itself has no temporal component. Gives detections a persistent id and lets a track survive a few frames of missed detection (occlusion, a confidence dip) via `max_age`. """ def __init__(self, iou_threshold: float = 0.3, max_age: int = 5, min_hits: int = 3) -> None: self.iou_threshold = iou_threshold self.max_age = max_age self.min_hits = min_hits self._trackers: list[_KalmanBoxTracker] = [] def update(self, boxes: np.ndarray, labels: np.ndarray, scores: np.ndarray) -> list[Track]: """Advance one frame. `boxes` is [N, 4] xyxy, `labels`/`scores` are [N].""" predicted = ( np.array([tracker.predict() for tracker in self._trackers]) if self._trackers else np.zeros((0, 4)) ) matches, unmatched_detections, _ = self._associate(predicted, boxes) for det_idx, trk_idx in matches: self._trackers[trk_idx].update(boxes[det_idx], int(labels[det_idx]), float(scores[det_idx])) for det_idx in unmatched_detections: self._trackers.append( _KalmanBoxTracker(boxes[det_idx], int(labels[det_idx]), float(scores[det_idx])) ) self._trackers = [t for t in self._trackers if t.time_since_update <= self.max_age] results = [] for tracker in self._trackers: confirmed = tracker.hits >= self.min_hits or tracker.age <= self.min_hits if tracker.time_since_update == 0 and confirmed: x0, y0, x1, y1 = tracker.current_box() results.append( Track( id=tracker.id, box=(x0, y0, x1, y1), label=tracker.label, score=tracker.score, hits=tracker.hits, age=tracker.age, ) ) return results def _associate( self, predicted: np.ndarray, detections: np.ndarray ) -> tuple[list[tuple[int, int]], list[int], list[int]]: if predicted.shape[0] == 0 or detections.shape[0] == 0: return [], list(range(detections.shape[0])), list(range(predicted.shape[0])) iou = iou_matrix(detections, predicted) row_idx, col_idx = linear_sum_assignment(1.0 - iou) matches: list[tuple[int, int]] = [] matched_detections: set[int] = set() matched_trackers: set[int] = set() for row, col in zip(row_idx, col_idx, strict=True): if iou[row, col] >= self.iou_threshold: matches.append((row, col)) matched_detections.add(row) matched_trackers.add(col) unmatched_detections = [i for i in range(detections.shape[0]) if i not in matched_detections] unmatched_trackers = [i for i in range(predicted.shape[0]) if i not in matched_trackers] return matches, unmatched_detections, unmatched_trackers