tracktors 2.6.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trackers/__init__.py +58 -0
- trackers/annotators/__init__.py +5 -0
- trackers/annotators/trace.py +219 -0
- trackers/core/__init__.py +5 -0
- trackers/core/base.py +683 -0
- trackers/core/botsort/__init__.py +8 -0
- trackers/core/botsort/cmc.py +47 -0
- trackers/core/botsort/tracker.py +503 -0
- trackers/core/botsort/tracklet.py +252 -0
- trackers/core/botsort/utils.py +92 -0
- trackers/core/bytetrack/__init__.py +5 -0
- trackers/core/bytetrack/tracker.py +427 -0
- trackers/core/bytetrack/tracklet.py +71 -0
- trackers/core/bytetrack/utils.py +65 -0
- trackers/core/cbiou/__init__.py +5 -0
- trackers/core/cbiou/tracker.py +335 -0
- trackers/core/ocsort/__init__.py +5 -0
- trackers/core/ocsort/tracker.py +416 -0
- trackers/core/ocsort/tracklet.py +371 -0
- trackers/core/ocsort/utils.py +113 -0
- trackers/core/sort/__init__.py +5 -0
- trackers/core/sort/tracker.py +294 -0
- trackers/core/sort/tracklet.py +84 -0
- trackers/core/sort/utils.py +153 -0
- trackers/datasets/__init__.py +7 -0
- trackers/datasets/download.py +167 -0
- trackers/datasets/manifest.py +144 -0
- trackers/eval/__init__.py +54 -0
- trackers/eval/box.py +194 -0
- trackers/eval/clear.py +365 -0
- trackers/eval/constants.py +17 -0
- trackers/eval/evaluate.py +421 -0
- trackers/eval/hota.py +350 -0
- trackers/eval/identity.py +235 -0
- trackers/eval/results.py +704 -0
- trackers/io/__init__.py +5 -0
- trackers/io/frames.py +67 -0
- trackers/io/mot.py +469 -0
- trackers/io/paths.py +40 -0
- trackers/io/video.py +164 -0
- trackers/motion/__init__.py +5 -0
- trackers/motion/estimator.py +225 -0
- trackers/motion/transformation.py +144 -0
- trackers/py.typed +0 -0
- trackers/scripts/__init__.py +5 -0
- trackers/scripts/__main__.py +70 -0
- trackers/scripts/download.py +109 -0
- trackers/scripts/eval.py +169 -0
- trackers/scripts/progress.py +232 -0
- trackers/scripts/track.py +738 -0
- trackers/scripts/tune.py +230 -0
- trackers/tune/__init__.py +11 -0
- trackers/tune/tuner.py +452 -0
- trackers/utils/__init__.py +5 -0
- trackers/utils/base_tracklet.py +113 -0
- trackers/utils/cmc.py +935 -0
- trackers/utils/converters.py +115 -0
- trackers/utils/detections.py +49 -0
- trackers/utils/device.py +30 -0
- trackers/utils/downloader.py +230 -0
- trackers/utils/general.py +73 -0
- trackers/utils/iou.py +202 -0
- trackers/utils/kalman_filter.py +155 -0
- trackers/utils/motion_models.py +204 -0
- trackers/utils/predict_timing.py +60 -0
- trackers/utils/state_representations.py +341 -0
- tracktors/__init__.py +20 -0
- tracktors/py.typed +1 -0
- tracktors-2.6.0.dev0.dist-info/METADATA +218 -0
- tracktors-2.6.0.dev0.dist-info/RECORD +74 -0
- tracktors-2.6.0.dev0.dist-info/WHEEL +5 -0
- tracktors-2.6.0.dev0.dist-info/entry_points.txt +3 -0
- tracktors-2.6.0.dev0.dist-info/licenses/LICENSE +201 -0
- tracktors-2.6.0.dev0.dist-info/top_level.txt +2 -0
trackers/core/base.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------
|
|
2
|
+
# Trackers
|
|
3
|
+
# Copyright (c) 2026 Roboflow. All Rights Reserved.
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
|
5
|
+
# ------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import types
|
|
13
|
+
import warnings
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, ClassVar, Protocol, Union, cast, get_args, get_origin
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import supervision as sv
|
|
21
|
+
import torch
|
|
22
|
+
|
|
23
|
+
from trackers.utils.base_tracklet import BaseTracklet
|
|
24
|
+
from trackers.utils.predict_timing import PredictTiming
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ParameterInfo:
|
|
29
|
+
"""Holds metadata for a single tracker parameter.
|
|
30
|
+
|
|
31
|
+
Stores the type, default value, and description extracted from the
|
|
32
|
+
tracker's __init__ signature and docstring.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
param_type: type
|
|
36
|
+
default_value: Any
|
|
37
|
+
description: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TrackerParameters(dict[str, ParameterInfo]):
|
|
41
|
+
"""Tracker parameter mapping with CLI-only filtering for IoU metrics."""
|
|
42
|
+
|
|
43
|
+
def items(self) -> Iterator[tuple[str, ParameterInfo]]: # type: ignore[override]
|
|
44
|
+
try:
|
|
45
|
+
from trackers.utils.iou import BaseIoU
|
|
46
|
+
except ImportError:
|
|
47
|
+
yield from super().items()
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
for name, param_info in super().items():
|
|
51
|
+
param_type = param_info.param_type
|
|
52
|
+
if isinstance(param_type, type) and issubclass(param_type, BaseIoU):
|
|
53
|
+
continue
|
|
54
|
+
yield name, param_info
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class TrackerInfo:
|
|
59
|
+
"""Holds a tracker class and its extracted parameter metadata.
|
|
60
|
+
|
|
61
|
+
Used by the CLI to discover available trackers and their configurable
|
|
62
|
+
options without instantiating them.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
tracker_class: type[BaseTracker]
|
|
66
|
+
parameters: dict[str, ParameterInfo]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Pattern: leading whitespace, optional backticks, param name (supports dotted),
|
|
70
|
+
# optional (type info), colon, and captures description
|
|
71
|
+
_PARAM_START_PATTERN = re.compile(r"^\s*`?(\w+(?:\.\w+)*)`?\s*(?:\([^)]*\))?\s*:\s*(.*)$")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_docstring_arguments(docstring: str) -> dict[str, str]:
|
|
75
|
+
"""Extract parameter-to-description mapping from Google-style Args section.
|
|
76
|
+
|
|
77
|
+
Supports multiple formats including `param: desc`, `param (type): desc`,
|
|
78
|
+
and multi-line descriptions with proper continuation handling.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
docstring: Raw docstring text to parse.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Mapping of parameter names to their description strings.
|
|
85
|
+
Empty dict if no Args section is found in the docstring.
|
|
86
|
+
"""
|
|
87
|
+
if not docstring:
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
result: dict[str, str] = {}
|
|
91
|
+
lines = docstring.splitlines()
|
|
92
|
+
i = 0
|
|
93
|
+
n = len(lines)
|
|
94
|
+
|
|
95
|
+
# Find Args: section
|
|
96
|
+
while i < n:
|
|
97
|
+
if lines[i].strip() == "Args:":
|
|
98
|
+
i += 1
|
|
99
|
+
break
|
|
100
|
+
i += 1
|
|
101
|
+
|
|
102
|
+
if i == n:
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
current_param: str | None = None
|
|
106
|
+
current_desc_parts: list[str] = []
|
|
107
|
+
|
|
108
|
+
while i < n:
|
|
109
|
+
line = lines[i].rstrip()
|
|
110
|
+
stripped = line.strip()
|
|
111
|
+
|
|
112
|
+
if not stripped:
|
|
113
|
+
i += 1
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if stripped in (
|
|
117
|
+
"Returns:",
|
|
118
|
+
"Yields:",
|
|
119
|
+
"Raises:",
|
|
120
|
+
"Attributes:",
|
|
121
|
+
"Note:",
|
|
122
|
+
"Notes:",
|
|
123
|
+
"Example:",
|
|
124
|
+
"Examples:",
|
|
125
|
+
"See Also:",
|
|
126
|
+
):
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
match = _PARAM_START_PATTERN.match(line)
|
|
130
|
+
if match:
|
|
131
|
+
if current_param:
|
|
132
|
+
result[current_param] = " ".join(current_desc_parts).strip()
|
|
133
|
+
current_param = match.group(1)
|
|
134
|
+
desc_first = match.group(2).strip()
|
|
135
|
+
current_desc_parts = [desc_first] if desc_first else []
|
|
136
|
+
elif current_param:
|
|
137
|
+
current_desc_parts.append(stripped)
|
|
138
|
+
|
|
139
|
+
i += 1
|
|
140
|
+
|
|
141
|
+
if current_param:
|
|
142
|
+
result[current_param] = " ".join(current_desc_parts).strip()
|
|
143
|
+
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _normalize_type(annotation: Any, default: Any) -> Any:
|
|
148
|
+
"""Unwrap Optional/Union/generics to base type for CLI argument parsing.
|
|
149
|
+
|
|
150
|
+
Converts complex annotations like Optional[int], list[str], or int | None
|
|
151
|
+
to their base types (int, list, int) suitable for argparse type conversion.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
annotation: Type annotation to simplify.
|
|
155
|
+
default: Default value used for fallback type inference when
|
|
156
|
+
annotation is Any or cannot be resolved.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Simplified type (e.g., int, str, list) suitable for argparse type
|
|
160
|
+
conversion, or Any if the annotation cannot be resolved to a concrete
|
|
161
|
+
type.
|
|
162
|
+
"""
|
|
163
|
+
origin = get_origin(annotation)
|
|
164
|
+
args = get_args(annotation)
|
|
165
|
+
|
|
166
|
+
if origin is None:
|
|
167
|
+
if annotation is Any and default is not None:
|
|
168
|
+
return type(default)
|
|
169
|
+
return annotation if isinstance(annotation, type) else Any
|
|
170
|
+
|
|
171
|
+
# Handle Union types (typing.Union and Python 3.10+ int | None syntax)
|
|
172
|
+
union_type = getattr(types, "UnionType", None)
|
|
173
|
+
if origin is Union or (union_type is not None and origin is union_type):
|
|
174
|
+
non_none = [a for a in args if a is not type(None)]
|
|
175
|
+
if non_none:
|
|
176
|
+
return _normalize_type(non_none[0], default)
|
|
177
|
+
return Any
|
|
178
|
+
|
|
179
|
+
if origin in (list, tuple, set, frozenset):
|
|
180
|
+
return origin
|
|
181
|
+
|
|
182
|
+
if origin is dict:
|
|
183
|
+
return dict
|
|
184
|
+
|
|
185
|
+
if default is not None:
|
|
186
|
+
return type(default)
|
|
187
|
+
return Any
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _extract_params_from_init(cls: type) -> dict[str, ParameterInfo]:
|
|
191
|
+
"""Introspect __init__ signature and docstring to build parameter metadata.
|
|
192
|
+
|
|
193
|
+
Combines type hints, default values, and docstring descriptions into a
|
|
194
|
+
structured format. Falls back to class docstring if __init__ has none.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
cls: Class whose __init__ to analyze.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Mapping of parameter names to ParameterInfo objects, excluding
|
|
201
|
+
the ``self`` parameter.
|
|
202
|
+
"""
|
|
203
|
+
sig = inspect.signature(cls.__init__) # type: ignore[misc]
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
from typing import get_type_hints
|
|
207
|
+
|
|
208
|
+
type_hints = get_type_hints(cls.__init__) # type: ignore[misc]
|
|
209
|
+
except Exception:
|
|
210
|
+
type_hints = {}
|
|
211
|
+
|
|
212
|
+
# Check __init__ docstring first, then fall back to class docstring
|
|
213
|
+
init_doc = cls.__init__.__doc__ or "" # type: ignore[misc]
|
|
214
|
+
class_doc = cls.__doc__ or ""
|
|
215
|
+
param_docs = _parse_docstring_arguments(init_doc) or _parse_docstring_arguments(class_doc)
|
|
216
|
+
|
|
217
|
+
params: dict[str, ParameterInfo] = {}
|
|
218
|
+
for name, param in sig.parameters.items():
|
|
219
|
+
if name == "self":
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
default = param.default if param.default is not inspect.Parameter.empty else None
|
|
223
|
+
|
|
224
|
+
annotation = type_hints.get(name, Any)
|
|
225
|
+
param_type = _normalize_type(annotation, default)
|
|
226
|
+
|
|
227
|
+
# Fallback: infer from default if annotation is Any
|
|
228
|
+
if param_type is Any and default is not None:
|
|
229
|
+
param_type = type(default)
|
|
230
|
+
|
|
231
|
+
description = param_docs.get(name, "")
|
|
232
|
+
|
|
233
|
+
params[name] = ParameterInfo(param_type=param_type, default_value=default, description=description)
|
|
234
|
+
|
|
235
|
+
return params
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_VALID_SPACE_TYPES: frozenset[str] = frozenset({"randint", "uniform", "choice"})
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _validate_search_space_entry(cls_name: str, key: str, spec: Any, init_params: set[str]) -> None:
|
|
242
|
+
if key not in init_params:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"{cls_name}: search_space key {key!r} is not a "
|
|
245
|
+
f"parameter of __init__. "
|
|
246
|
+
f"Valid parameters: {sorted(init_params)}"
|
|
247
|
+
)
|
|
248
|
+
if not isinstance(spec, dict):
|
|
249
|
+
raise ValueError(f"{cls_name}: search_space[{key!r}] must be a dict, got {type(spec).__name__!r}")
|
|
250
|
+
if "type" not in spec:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"{cls_name}: search_space[{key!r}] missing required key 'type'. Valid types: {sorted(_VALID_SPACE_TYPES)}"
|
|
253
|
+
)
|
|
254
|
+
if spec["type"] not in _VALID_SPACE_TYPES:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"{cls_name}: search_space[{key!r}]['type'] = "
|
|
257
|
+
f"{spec['type']!r} is not valid. "
|
|
258
|
+
f"Valid types: {sorted(_VALID_SPACE_TYPES)}"
|
|
259
|
+
)
|
|
260
|
+
space_type = spec["type"]
|
|
261
|
+
if space_type == "choice":
|
|
262
|
+
if "options" not in spec:
|
|
263
|
+
raise ValueError(f"{cls_name}: search_space[{key!r}] with type 'choice' missing required key 'options'")
|
|
264
|
+
opts = spec["options"]
|
|
265
|
+
if isinstance(opts, (str, bytes)):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"{cls_name}: search_space[{key!r}]['options'] must be "
|
|
268
|
+
f"a sequence of choices, not {type(opts).__name__!r}"
|
|
269
|
+
)
|
|
270
|
+
try:
|
|
271
|
+
n_opts = len(opts)
|
|
272
|
+
except TypeError as exc:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"{cls_name}: search_space[{key!r}]['options'] must be a sized sequence, got {type(opts).__name__!r}"
|
|
275
|
+
) from exc
|
|
276
|
+
if n_opts < 1:
|
|
277
|
+
raise ValueError(f"{cls_name}: search_space[{key!r}]['options'] must be non-empty, got {opts!r}")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
if "range" not in spec:
|
|
281
|
+
raise ValueError(f"{cls_name}: search_space[{key!r}] missing required key 'range'")
|
|
282
|
+
rng = spec["range"]
|
|
283
|
+
if not (hasattr(rng, "__len__") and len(rng) == 2):
|
|
284
|
+
raise ValueError(f"{cls_name}: search_space[{key!r}]['range'] must be a 2-element sequence, got {rng!r}")
|
|
285
|
+
if rng[0] >= rng[1]:
|
|
286
|
+
raise ValueError(f"{cls_name}: search_space[{key!r}]['range'] must have low < high, got {rng!r}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TrackletProtocol(Protocol):
|
|
290
|
+
"""Contract every tracklet in ``BaseTracker.tracks`` must satisfy."""
|
|
291
|
+
|
|
292
|
+
tracker_id: int
|
|
293
|
+
|
|
294
|
+
def get_state_bbox(self) -> torch.Tensor: ...
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class BaseTracker(ABC):
|
|
298
|
+
"""Abstract tracker with auto-registration via tracker_id class variable.
|
|
299
|
+
|
|
300
|
+
Subclasses that define `tracker_id` are automatically registered and
|
|
301
|
+
become discoverable. Parameter metadata is extracted from __init__ for
|
|
302
|
+
CLI integration.
|
|
303
|
+
|
|
304
|
+
Attributes:
|
|
305
|
+
tracker_id: Unique identifier for the tracker. Subclasses must define
|
|
306
|
+
this to be registered.
|
|
307
|
+
search_space: Hyperparameter search space for tuning. Each key must
|
|
308
|
+
match an `__init__` parameter. Values are dicts with `type`
|
|
309
|
+
``"randint"`` or ``"uniform"`` and ``range`` ``[low, high]``, or
|
|
310
|
+
`type` ``"choice"`` and ``options`` (non-empty sequence of
|
|
311
|
+
categorical values for Optuna).
|
|
312
|
+
tracks: List of alive tracklets after each `update()`. Each element
|
|
313
|
+
must satisfy `TrackletProtocol` (exposes `.tracker_id: int` and
|
|
314
|
+
`.get_state_bbox() -> np.ndarray`). Subclasses must initialise
|
|
315
|
+
this as an empty list in `__init__`. Override `tracked_objects`
|
|
316
|
+
if using a different internal container.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
_registry: ClassVar[dict[str, TrackerInfo]] = {}
|
|
320
|
+
tracker_id: ClassVar[str | None] = None
|
|
321
|
+
search_space: ClassVar[dict[str, dict] | None] = None
|
|
322
|
+
# list[Any]: elements satisfy TrackletProtocol; list is invariant so
|
|
323
|
+
# list[ConcreteTracklet] in subclasses rejects list[TrackletProtocol] base.
|
|
324
|
+
tracks: list[Any]
|
|
325
|
+
maximum_frames_without_update: int
|
|
326
|
+
maximum_time_without_update: float | None
|
|
327
|
+
_next_track_id: int
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def _compute_maximum_frames_without_update(
|
|
331
|
+
lost_track_buffer: int,
|
|
332
|
+
frame_rate: float,
|
|
333
|
+
) -> int:
|
|
334
|
+
"""Scale positive lost-track buffers without changing explicit zero-buffer configs.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
lost_track_buffer: Non-negative buffer length expressed in 30 FPS frames.
|
|
338
|
+
Zero means no missed-frame grace period.
|
|
339
|
+
frame_rate: Actual video frame rate in frames per second. Must be
|
|
340
|
+
finite and strictly positive.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Scaled maximum number of missed frames before a confirmed track expires.
|
|
344
|
+
Returns zero when ``lost_track_buffer`` is zero; otherwise returns
|
|
345
|
+
``max(1, ceil(frame_rate / 30.0 * lost_track_buffer))`` to ensure at
|
|
346
|
+
least one frame of grace for any positive buffer at any frame rate.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
ValueError: If ``lost_track_buffer`` is negative.
|
|
350
|
+
ValueError: If ``frame_rate`` is not finite or is not strictly positive.
|
|
351
|
+
ValueError: If the scaled product overflows to infinity for extreme inputs.
|
|
352
|
+
|
|
353
|
+
Examples:
|
|
354
|
+
>>> BaseTracker._compute_maximum_frames_without_update(30, 30.0)
|
|
355
|
+
30
|
|
356
|
+
>>> BaseTracker._compute_maximum_frames_without_update(30, 60.0)
|
|
357
|
+
60
|
|
358
|
+
>>> BaseTracker._compute_maximum_frames_without_update(3, 15.0)
|
|
359
|
+
2
|
|
360
|
+
>>> BaseTracker._compute_maximum_frames_without_update(0, 30.0)
|
|
361
|
+
0
|
|
362
|
+
"""
|
|
363
|
+
if lost_track_buffer < 0:
|
|
364
|
+
raise ValueError("lost_track_buffer must be greater than or equal to 0")
|
|
365
|
+
if not math.isfinite(frame_rate) or frame_rate <= 0:
|
|
366
|
+
raise ValueError("frame_rate must be a finite positive value")
|
|
367
|
+
if lost_track_buffer == 0:
|
|
368
|
+
return 0
|
|
369
|
+
scaled = frame_rate / 30.0 * lost_track_buffer
|
|
370
|
+
if not math.isfinite(scaled):
|
|
371
|
+
raise ValueError("Scaled lost_track_buffer overflows: frame_rate / 30.0 * lost_track_buffer must be finite")
|
|
372
|
+
return max(1, math.ceil(scaled))
|
|
373
|
+
|
|
374
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
375
|
+
"""Register subclass in the tracker registry if it defines tracker_id.
|
|
376
|
+
|
|
377
|
+
Extracts parameter metadata from __init__ at class definition time.
|
|
378
|
+
Validates search_space (if present) against __init__ parameters.
|
|
379
|
+
"""
|
|
380
|
+
super().__init_subclass__(**kwargs)
|
|
381
|
+
|
|
382
|
+
# Validate search_space keys match __init__ parameters (search_space optional)
|
|
383
|
+
search_space = getattr(cls, "search_space", None)
|
|
384
|
+
if search_space is not None and len(search_space) > 0:
|
|
385
|
+
init_params = {n for n in inspect.signature(cls.__init__).parameters if n != "self"}
|
|
386
|
+
for key, spec in search_space.items():
|
|
387
|
+
_validate_search_space_entry(cls.__name__, key, spec, init_params)
|
|
388
|
+
|
|
389
|
+
tracker_id = getattr(cls, "tracker_id", None)
|
|
390
|
+
if tracker_id is not None:
|
|
391
|
+
BaseTracker._registry[tracker_id] = TrackerInfo(
|
|
392
|
+
tracker_class=cls,
|
|
393
|
+
parameters=TrackerParameters(_extract_params_from_init(cls)),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def _lookup_tracker(cls, name: str) -> TrackerInfo | None:
|
|
398
|
+
"""Look up registered tracker by name.
|
|
399
|
+
|
|
400
|
+
Internal method used by CLI for tracker discovery and instantiation.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
name: Tracker identifier (e.g., "bytetrack", "sort").
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
TrackerInfo containing the tracker class and its parameter
|
|
407
|
+
metadata, or None if no tracker is registered under that name.
|
|
408
|
+
"""
|
|
409
|
+
return cls._registry.get(name)
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def _registered_trackers(cls) -> list[str]:
|
|
413
|
+
"""List all registered tracker names.
|
|
414
|
+
|
|
415
|
+
Internal method used by CLI for help text and argument validation.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Alphabetically sorted list of registered tracker identifiers
|
|
419
|
+
(e.g., ``["bytetrack", "ocsort", "sort"]``).
|
|
420
|
+
"""
|
|
421
|
+
return sorted(cls._registry.keys())
|
|
422
|
+
|
|
423
|
+
_frame_rate: float = 1.0
|
|
424
|
+
_last_timestamp: float | None = None
|
|
425
|
+
|
|
426
|
+
def _init_timestamp_state(self, frame_rate: float) -> None:
|
|
427
|
+
"""Register reference FPS and reset timestamp bookkeeping.
|
|
428
|
+
|
|
429
|
+
Call from ``__init__`` on all concrete trackers.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
frame_rate: Reference frames per second for bootstrap elapsed time.
|
|
433
|
+
"""
|
|
434
|
+
self._frame_rate = frame_rate
|
|
435
|
+
self._last_timestamp = None
|
|
436
|
+
|
|
437
|
+
def _warn_if_frame_unused(self, frame: np.ndarray | None) -> None:
|
|
438
|
+
"""Emit a UserWarning when a frame is passed to a tracker that ignores it.
|
|
439
|
+
|
|
440
|
+
Subclasses that do not perform camera motion compensation should call this
|
|
441
|
+
at the top of their ``update()`` implementation.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
frame: Value passed to ``update(frame=...)``.
|
|
445
|
+
"""
|
|
446
|
+
if frame is not None:
|
|
447
|
+
warnings.warn(
|
|
448
|
+
f"{type(self).__name__}.update() received a frame argument but does not use it.",
|
|
449
|
+
UserWarning,
|
|
450
|
+
stacklevel=3,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def _predict_timing(self, timestamp: float | None) -> PredictTiming:
|
|
454
|
+
"""Build predict timing from an optional timestamp.
|
|
455
|
+
|
|
456
|
+
All timestamp ordering checks live here: fixed-rate mode, bootstrap,
|
|
457
|
+
backwards (skip whole update), duplicate (skip predict only), normal gap.
|
|
458
|
+
``_last_timestamp`` advances only on bootstrap and strictly increasing times.
|
|
459
|
+
"""
|
|
460
|
+
if timestamp is None:
|
|
461
|
+
self._last_timestamp = None
|
|
462
|
+
return PredictTiming(frame_step=1.0, elapsed_seconds=None)
|
|
463
|
+
|
|
464
|
+
if not np.isfinite(timestamp):
|
|
465
|
+
warnings.warn(
|
|
466
|
+
f"{type(self).__name__}: timestamp {timestamp!r} is not finite; skipping update.",
|
|
467
|
+
UserWarning,
|
|
468
|
+
stacklevel=3,
|
|
469
|
+
)
|
|
470
|
+
return PredictTiming(frame_step=0.0, elapsed_seconds=None, skip_update=True)
|
|
471
|
+
|
|
472
|
+
last = self._last_timestamp
|
|
473
|
+
|
|
474
|
+
if last is None:
|
|
475
|
+
# Bootstrap: no prior timestamp, so we cannot compute t - t_prev.
|
|
476
|
+
# Use one nominal frame period (1 / frame_rate) so the first Kalman
|
|
477
|
+
# step is frame_step=1.0 — matching fixed-rate behaviour rather than
|
|
478
|
+
# using the absolute timestamp value (e.g. 37.2 s would break tuning).
|
|
479
|
+
self._last_timestamp = timestamp
|
|
480
|
+
elapsed = 1.0 / self._frame_rate
|
|
481
|
+
return PredictTiming(
|
|
482
|
+
frame_step=elapsed * self._frame_rate,
|
|
483
|
+
elapsed_seconds=elapsed,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if timestamp < last:
|
|
487
|
+
warnings.warn(
|
|
488
|
+
f"{type(self).__name__}: timestamp {timestamp} is earlier than the "
|
|
489
|
+
f"previous timestamp {last}. Skipping update; pass capture times in "
|
|
490
|
+
"non-decreasing order.",
|
|
491
|
+
UserWarning,
|
|
492
|
+
stacklevel=3,
|
|
493
|
+
)
|
|
494
|
+
return PredictTiming(frame_step=0.0, elapsed_seconds=None, skip_update=True)
|
|
495
|
+
|
|
496
|
+
if timestamp == last:
|
|
497
|
+
warnings.warn(
|
|
498
|
+
f"{type(self).__name__}: duplicate timestamp {timestamp}; skipping predict for this step.",
|
|
499
|
+
UserWarning,
|
|
500
|
+
stacklevel=3,
|
|
501
|
+
)
|
|
502
|
+
return PredictTiming(frame_step=0.0, elapsed_seconds=0.0)
|
|
503
|
+
|
|
504
|
+
elapsed = timestamp - last
|
|
505
|
+
self._last_timestamp = timestamp
|
|
506
|
+
return PredictTiming(
|
|
507
|
+
frame_step=elapsed * self._frame_rate,
|
|
508
|
+
elapsed_seconds=elapsed,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _detections_for_skipped_update(self, detections: sv.Detections) -> sv.Detections:
|
|
512
|
+
"""Return detections unchanged except tracker_id=-1; do not mutate tracks."""
|
|
513
|
+
if len(detections) == 0:
|
|
514
|
+
result = detections.select(slice(None))
|
|
515
|
+
result.tracker_id = torch.empty(
|
|
516
|
+
0,
|
|
517
|
+
dtype=torch.long,
|
|
518
|
+
device=detections.xyxy.device,
|
|
519
|
+
)
|
|
520
|
+
return result
|
|
521
|
+
result = cast(sv.Detections, detections[:])
|
|
522
|
+
result.tracker_id = torch.full(
|
|
523
|
+
(len(result),),
|
|
524
|
+
-1,
|
|
525
|
+
dtype=torch.long,
|
|
526
|
+
device=detections.xyxy.device,
|
|
527
|
+
)
|
|
528
|
+
return result
|
|
529
|
+
|
|
530
|
+
def _predict_tracklets(self, tracklets: list[Any], timing: PredictTiming) -> None:
|
|
531
|
+
"""Predict all tracklets unless the timestamp did not advance."""
|
|
532
|
+
if timing.skip_predict:
|
|
533
|
+
return
|
|
534
|
+
for tracklet in tracklets:
|
|
535
|
+
tracklet.predict(timing)
|
|
536
|
+
|
|
537
|
+
def _lost_track_time_budget(
|
|
538
|
+
self,
|
|
539
|
+
timing: PredictTiming,
|
|
540
|
+
seconds_budget: float | None,
|
|
541
|
+
) -> float | None:
|
|
542
|
+
"""Return the seconds lost-track budget when timestamps are in use."""
|
|
543
|
+
return seconds_budget if timing.uses_elapsed_time else None
|
|
544
|
+
|
|
545
|
+
def _prune_lost_tracks(self, timing: PredictTiming) -> None:
|
|
546
|
+
"""Remove tracks that exceed their lost-track budget (ghost-ID prevention).
|
|
547
|
+
|
|
548
|
+
Applies a budget-only filter so immature tracks stay alive for matching.
|
|
549
|
+
Call after ``_predict_tracklets`` and before association.
|
|
550
|
+
|
|
551
|
+
At fixed frame rate (no timestamps) this is a no-op — the frame-count
|
|
552
|
+
budget is enforced post-association, preserving the last-frame re-association
|
|
553
|
+
opportunity that the original trackers relied on. In variable-FPS mode the
|
|
554
|
+
time budget can differ from the frame budget, so expired-by-time tracks are
|
|
555
|
+
removed here before they can be matched and revived with a stale ID.
|
|
556
|
+
"""
|
|
557
|
+
budget = self._lost_track_time_budget(timing, self.maximum_time_without_update)
|
|
558
|
+
if budget is None:
|
|
559
|
+
return
|
|
560
|
+
self.tracks = [
|
|
561
|
+
t
|
|
562
|
+
for t in self.tracks
|
|
563
|
+
if BaseTracklet.within_lost_track_budget(
|
|
564
|
+
t,
|
|
565
|
+
maximum_frames_without_update=self.maximum_frames_without_update,
|
|
566
|
+
maximum_time_without_update=budget,
|
|
567
|
+
)
|
|
568
|
+
]
|
|
569
|
+
|
|
570
|
+
@abstractmethod
|
|
571
|
+
def update(
|
|
572
|
+
self,
|
|
573
|
+
detections: sv.Detections,
|
|
574
|
+
frame: np.ndarray | None = None,
|
|
575
|
+
timestamp: float | None = None,
|
|
576
|
+
) -> sv.Detections:
|
|
577
|
+
"""Process new detections and assign track IDs.
|
|
578
|
+
|
|
579
|
+
Matches incoming detections to existing tracks, creates new tracks
|
|
580
|
+
for unmatched detections, and handles track lifecycle management.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
detections: Current frame detections with xyxy, confidence, class_id.
|
|
584
|
+
frame: Current video frame in BGR format (H, W, 3), or ``None``.
|
|
585
|
+
Used by trackers with camera motion compensation (e.g. BoTSORT).
|
|
586
|
+
timestamp: Absolute time of the current frame in seconds, or
|
|
587
|
+
``None`` for fixed-rate mode (``frame_step = 1.0`` per call).
|
|
588
|
+
Must be non-negative. When provided, elapsed seconds are
|
|
589
|
+
converted to Kalman frame units via ``* frame_rate``; pruning
|
|
590
|
+
uses seconds directly. Must be non-decreasing in capture time.
|
|
591
|
+
Passing ``None`` resets the internal timestamp anchor so the
|
|
592
|
+
next timestamped call is treated as a fresh bootstrap.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
sv.Detections enriched with tracker_id assigned for each
|
|
596
|
+
detection box. When the update is skipped (backwards or
|
|
597
|
+
non-finite timestamp), all ``tracker_id`` values are ``-1``.
|
|
598
|
+
|
|
599
|
+
Warns:
|
|
600
|
+
UserWarning: If ``timestamp`` is earlier than the previous call
|
|
601
|
+
(backwards order); the whole update is skipped and all output
|
|
602
|
+
IDs are ``-1``. If ``timestamp`` equals the previous call
|
|
603
|
+
(duplicate); predict is skipped but association still runs on
|
|
604
|
+
the last state (``elapsed_seconds = 0.0``).
|
|
605
|
+
|
|
606
|
+
Note:
|
|
607
|
+
Mixing timestamped and non-timestamped calls in the same session is
|
|
608
|
+
unsupported. Calling ``update(detections)`` (no timestamp) resets
|
|
609
|
+
``_last_timestamp`` to ``None``; the next timestamped call is then
|
|
610
|
+
treated as a fresh bootstrap (``frame_step = 1 / frame_rate``) rather
|
|
611
|
+
than measuring the real gap from the previous call. If you switch from
|
|
612
|
+
``update(d, timestamp=t)`` to ``update(d)`` and then back to
|
|
613
|
+
``update(d, timestamp=t2)``, the elapsed gap ``t2 - t`` is silently
|
|
614
|
+
discarded and the Kalman step is reset to one nominal frame.
|
|
615
|
+
"""
|
|
616
|
+
pass
|
|
617
|
+
|
|
618
|
+
@abstractmethod
|
|
619
|
+
def reset(self) -> None:
|
|
620
|
+
"""Clear all internal tracking state.
|
|
621
|
+
|
|
622
|
+
Call between videos or when tracking should restart from scratch.
|
|
623
|
+
"""
|
|
624
|
+
pass
|
|
625
|
+
|
|
626
|
+
def _reset_id_allocator(self) -> None:
|
|
627
|
+
"""Restart this tracker instance's ID allocation from zero."""
|
|
628
|
+
self._next_track_id = 0
|
|
629
|
+
|
|
630
|
+
def _allocate_tracker_id(self) -> int:
|
|
631
|
+
"""Return the next tracker ID (zero-indexed) and advance the internal counter."""
|
|
632
|
+
next_track_id = self._next_track_id
|
|
633
|
+
self._next_track_id = next_track_id + 1
|
|
634
|
+
return next_track_id
|
|
635
|
+
|
|
636
|
+
@property
|
|
637
|
+
def tracked_objects(self) -> sv.Detections:
|
|
638
|
+
"""All confirmed alive tracks with Kalman-predicted bounding boxes.
|
|
639
|
+
|
|
640
|
+
Exposes every confirmed track (tracker_id != -1) that the tracker
|
|
641
|
+
still considers alive after the most recent `update()` call, including
|
|
642
|
+
tracks not matched to a detection on the current frame (e.g.
|
|
643
|
+
temporarily occluded or missed by the detector). Tracks are dropped
|
|
644
|
+
once the time since the last matching detection exceeds
|
|
645
|
+
`lost_track_buffer` (scaled by `frame_rate`).
|
|
646
|
+
|
|
647
|
+
Unlike the `update()` return value, the result omits `confidence` and
|
|
648
|
+
`class_id` (both remain `None`). Kalman-predicted boxes have no
|
|
649
|
+
associated detection score or class label.
|
|
650
|
+
|
|
651
|
+
Note:
|
|
652
|
+
`sv.LabelAnnotator` and other supervision annotators that read
|
|
653
|
+
`class_id` or `confidence` cannot be used directly on this result
|
|
654
|
+
and will raise `TypeError`. Guard with
|
|
655
|
+
``if detections.class_id is not None`` before annotating.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
sv.Detections with Kalman-predicted xyxy and tracker_id for each
|
|
659
|
+
confirmed alive track. Returns an empty sv.Detections (with an
|
|
660
|
+
empty int tracker_id array) when no confirmed tracks are alive.
|
|
661
|
+
The exact set depends on each tracker's pruning logic.
|
|
662
|
+
|
|
663
|
+
Raises:
|
|
664
|
+
AttributeError: If a `BaseTracker` subclass does not initialise
|
|
665
|
+
`self.tracks` as a list of objects satisfying
|
|
666
|
+
`TrackletProtocol` in `__init__`.
|
|
667
|
+
"""
|
|
668
|
+
tracklets = [t for t in self.tracks if t.tracker_id != -1]
|
|
669
|
+
if tracklets:
|
|
670
|
+
xyxy = torch.stack([t.get_state_bbox() for t in tracklets]).to(dtype=torch.float32)
|
|
671
|
+
device = xyxy.device
|
|
672
|
+
else:
|
|
673
|
+
all_tracklets = getattr(self, "tracks", [])
|
|
674
|
+
device = (
|
|
675
|
+
all_tracklets[0].get_state_bbox().device
|
|
676
|
+
if all_tracklets
|
|
677
|
+
else getattr(self, "_device", torch.device("cpu"))
|
|
678
|
+
)
|
|
679
|
+
xyxy = torch.empty((0, 4), dtype=torch.float32, device=device)
|
|
680
|
+
tracker_ids = torch.tensor([t.tracker_id for t in tracklets], dtype=torch.long, device=device)
|
|
681
|
+
result = sv.Detections(xyxy=xyxy)
|
|
682
|
+
result.tracker_id = tracker_ids
|
|
683
|
+
return result
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------
|
|
2
|
+
# Trackers
|
|
3
|
+
# Copyright (c) 2026 Roboflow. All Rights Reserved.
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
|
5
|
+
# ------------------------------------------------------------------------
|
|
6
|
+
from .tracker import BoTSORTTracker
|
|
7
|
+
|
|
8
|
+
__all__ = ["BoTSORTTracker"]
|