TobiasLogic's picture
Upload ObjectModel-v1: code, README, assets (v1 checkpoint)
9b92c75 verified
Raw
History Blame Contribute Delete
7.02 kB
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