Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions retina_tracker/tracker.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Core Tracker class and GNN data association logic."""

import math
import numbers
from collections import deque

import numpy as np
Expand All @@ -19,6 +21,8 @@

MERGE_WINDOW_MS = 5000
MAX_COMPLETED_TRACKS = 5000
MAX_FRAME_DT_S = 60.0
BACKWARDS_RUN_BEFORE_RESYNC = 3


class Tracker:
Expand All @@ -31,7 +35,7 @@ def __init__(self, event_writer=None, detection_window=20, config=None):
self.completed_tracks = deque(maxlen=MAX_COMPLETED_TRACKS)
self.last_timestamp = None
self.detection_window = detection_window
self.frame_count = 0
self._reset_counters()
self.event_writer = event_writer
self.config = config if config else get_config()

Expand All @@ -50,13 +54,32 @@ def reset(self):
self.all_tracks = []
self.completed_tracks.clear()
self.last_timestamp = None
self._reset_counters()

def _reset_counters(self):
self.frame_count = 0
self.n_dt_clamped = 0
self.n_frames_rejected = 0
self.n_clock_resyncs = 0
self.n_backwards = 0

def process_frame(self, detections, timestamp):
"""Advance every track by one frame, `timestamp` in milliseconds.

A timestamp that is not a finite number drops the frame.
"""
self.frame_count += 1

if not isinstance(timestamp, numbers.Real) or not math.isfinite(timestamp):
self.n_frames_rejected += 1
return

if self.last_timestamp is not None:
dt = (timestamp - self.last_timestamp) / 1000.0
raw_dt = (timestamp - self.last_timestamp) / 1000.0
dt = min(max(raw_dt, 0.0), MAX_FRAME_DT_S)
if dt != raw_dt:
self.n_dt_clamped += 1
self.n_backwards = self.n_backwards + 1 if raw_dt <= 0 else 0
else:
dt = 0.5

Expand Down Expand Up @@ -176,7 +199,12 @@ def process_frame(self, detections, timestamp):
if len(self.all_tracks) > 1:
self._merge_tracks()

self.last_timestamp = timestamp
resync = self.n_backwards >= BACKWARDS_RUN_BEFORE_RESYNC
if self.last_timestamp is None or timestamp > self.last_timestamp or resync:
if resync:
self.n_clock_resyncs += 1
self.n_backwards = 0
self.last_timestamp = timestamp

def _associate(self, detections):
if not self.tracks or not detections:
Expand Down Expand Up @@ -212,8 +240,8 @@ def _associate(self, detections):
b = B[0, 1]
c = B[1, 0]
det_S = a * d - b * c
valid = np.abs(det_S) > 1e-15
if not np.any(valid):
pos_def = (a > 0) & (det_S > 1e-15)
if not np.any(pos_def):
continue

gate = base_gate
Expand All @@ -226,9 +254,9 @@ def _associate(self, detections):
nu0 = innovations[:, 0]
nu1 = innovations[:, 1]
mahal = np.full(len(detections), np.inf)
mahal[valid] = (
d[valid] * nu0[valid] ** 2 - (b + c) * nu0[valid] * nu1[valid] + a[valid] * nu1[valid] ** 2
) / det_S[valid]
mahal[pos_def] = (
d[pos_def] * nu0[pos_def] ** 2 - (b + c) * nu0[pos_def] * nu1[pos_def] + a[pos_def] * nu1[pos_def] ** 2
) / det_S[pos_def]

within_gate = mahal < gate
if not np.any(within_gate):
Expand All @@ -243,7 +271,7 @@ def _associate(self, detections):

row_ind, col_ind = linear_sum_assignment(cost_matrix)

associations = [(r, c) for r, c in zip(row_ind, col_ind) if cost_matrix[r, c] < 1e6]
associations = [(r, c) for r, c in zip(row_ind, col_ind) if 0 <= cost_matrix[r, c] < 1e6]

return associations

Expand Down
22 changes: 22 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Restore the global config between tests.

`retina_tracker.config` keeps the config in a module global that `set_config`
replaces and every accessor (`MIN_SNR()`, `GATE_THRESHOLD()`, ...) reads at call
time, so without this a file that sets it supplies the config for every later
file in collection order. The snapshot is deep, because the config is nested and
a test that mutates a subsection in place would otherwise write through a
shallow copy into the next test.
"""

import copy

import pytest

from retina_tracker import config as config_module


@pytest.fixture(autouse=True)
def _isolate_global_config():
saved = copy.deepcopy(config_module._config)
yield
config_module.set_config(saved)
Loading
Loading