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.
Files changed (74) hide show
  1. trackers/__init__.py +58 -0
  2. trackers/annotators/__init__.py +5 -0
  3. trackers/annotators/trace.py +219 -0
  4. trackers/core/__init__.py +5 -0
  5. trackers/core/base.py +683 -0
  6. trackers/core/botsort/__init__.py +8 -0
  7. trackers/core/botsort/cmc.py +47 -0
  8. trackers/core/botsort/tracker.py +503 -0
  9. trackers/core/botsort/tracklet.py +252 -0
  10. trackers/core/botsort/utils.py +92 -0
  11. trackers/core/bytetrack/__init__.py +5 -0
  12. trackers/core/bytetrack/tracker.py +427 -0
  13. trackers/core/bytetrack/tracklet.py +71 -0
  14. trackers/core/bytetrack/utils.py +65 -0
  15. trackers/core/cbiou/__init__.py +5 -0
  16. trackers/core/cbiou/tracker.py +335 -0
  17. trackers/core/ocsort/__init__.py +5 -0
  18. trackers/core/ocsort/tracker.py +416 -0
  19. trackers/core/ocsort/tracklet.py +371 -0
  20. trackers/core/ocsort/utils.py +113 -0
  21. trackers/core/sort/__init__.py +5 -0
  22. trackers/core/sort/tracker.py +294 -0
  23. trackers/core/sort/tracklet.py +84 -0
  24. trackers/core/sort/utils.py +153 -0
  25. trackers/datasets/__init__.py +7 -0
  26. trackers/datasets/download.py +167 -0
  27. trackers/datasets/manifest.py +144 -0
  28. trackers/eval/__init__.py +54 -0
  29. trackers/eval/box.py +194 -0
  30. trackers/eval/clear.py +365 -0
  31. trackers/eval/constants.py +17 -0
  32. trackers/eval/evaluate.py +421 -0
  33. trackers/eval/hota.py +350 -0
  34. trackers/eval/identity.py +235 -0
  35. trackers/eval/results.py +704 -0
  36. trackers/io/__init__.py +5 -0
  37. trackers/io/frames.py +67 -0
  38. trackers/io/mot.py +469 -0
  39. trackers/io/paths.py +40 -0
  40. trackers/io/video.py +164 -0
  41. trackers/motion/__init__.py +5 -0
  42. trackers/motion/estimator.py +225 -0
  43. trackers/motion/transformation.py +144 -0
  44. trackers/py.typed +0 -0
  45. trackers/scripts/__init__.py +5 -0
  46. trackers/scripts/__main__.py +70 -0
  47. trackers/scripts/download.py +109 -0
  48. trackers/scripts/eval.py +169 -0
  49. trackers/scripts/progress.py +232 -0
  50. trackers/scripts/track.py +738 -0
  51. trackers/scripts/tune.py +230 -0
  52. trackers/tune/__init__.py +11 -0
  53. trackers/tune/tuner.py +452 -0
  54. trackers/utils/__init__.py +5 -0
  55. trackers/utils/base_tracklet.py +113 -0
  56. trackers/utils/cmc.py +935 -0
  57. trackers/utils/converters.py +115 -0
  58. trackers/utils/detections.py +49 -0
  59. trackers/utils/device.py +30 -0
  60. trackers/utils/downloader.py +230 -0
  61. trackers/utils/general.py +73 -0
  62. trackers/utils/iou.py +202 -0
  63. trackers/utils/kalman_filter.py +155 -0
  64. trackers/utils/motion_models.py +204 -0
  65. trackers/utils/predict_timing.py +60 -0
  66. trackers/utils/state_representations.py +341 -0
  67. tracktors/__init__.py +20 -0
  68. tracktors/py.typed +1 -0
  69. tracktors-2.6.0.dev0.dist-info/METADATA +218 -0
  70. tracktors-2.6.0.dev0.dist-info/RECORD +74 -0
  71. tracktors-2.6.0.dev0.dist-info/WHEEL +5 -0
  72. tracktors-2.6.0.dev0.dist-info/entry_points.txt +3 -0
  73. tracktors-2.6.0.dev0.dist-info/licenses/LICENSE +201 -0
  74. 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"]