Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **`CMC.apply_batch` homogeneity guard** — now raises `TypeError` immediately when the tracklet list contains mixed state-estimator types, preventing silent state corruption ([#414](https://github.com/roboflow/trackers/pull/414)).
- **`BoTSORTTracklet.apply_cmc` delegates to `CMC.apply_batch`** — per-track and batch paths now share identical code; behaviour is unchanged ([#414](https://github.com/roboflow/trackers/pull/414)).

### ⚠️ Breaking Changes

- **Invalid lost-track buffer settings now raise `ValueError`** — `lost_track_buffer` must be non-negative and `frame_rate` must be finite and positive for `SORTTracker`, `ByteTrackTracker`, `OCSORTTracker`, and `BoTSORTTracker`. Explicit `lost_track_buffer=0` remains valid and means no missed-frame grace period; negative buffers and invalid frame rates previously initialized but produced nonsensical lifecycle behavior.

### 🔧 Fixed

- **BoT-SORT score fusion with signed IoU** — `_fuse_score` multiplied raw negative IoU values by confidence, inverting track ranking for GIoU/DIoU/CIoU; `normalize_for_fusion` now normalises similarity before fusion ([#403](https://github.com/roboflow/trackers/pull/403)).
Expand All @@ -34,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Eager division warnings on zero-area boxes** — IoU helper switched from `np.where` (eager) to `np.divide(..., where=...)` (lazy), suppressing `RuntimeWarning` under strict NumPy error settings ([#403](https://github.com/roboflow/trackers/pull/403)).
- **CLI argparse crash on `BaseIoU` parameter** — `iou=` is now excluded from argparse auto-discovery; the variant must be set programmatically ([#403](https://github.com/roboflow/trackers/pull/403)).
- **ByteTrack tracked nothing when detections lacked confidence scores** — the default-fill changed from `np.zeros` to `np.ones`, matching SORT / OC-SORT / BoT-SORT behaviour, so detectors that emit `sv.Detections` without `confidence` now produce tracks instead of empty results ([#415](https://github.com/roboflow/trackers/pull/415)).
- **Positive low-FPS lost-track buffers no longer collapse to zero frames** — all trackers now scale positive `lost_track_buffer` values with `ceil(...)` and keep confirmed tracks alive through exactly the scaled number of missed frames, matching OC-SORT's previous inclusive boundary semantics.

---

Expand Down
15 changes: 15 additions & 0 deletions src/trackers/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import inspect
import math
import re
import types
import warnings
Expand Down Expand Up @@ -319,6 +320,20 @@ class BaseTracker(ABC):
tracks: list[Any]
maximum_frames_without_update: int

@staticmethod
def _compute_maximum_frames_without_update(
lost_track_buffer: int,
frame_rate: float,
) -> int:
"""Scale positive lost-track buffers without changing explicit zero-buffer configs."""
if lost_track_buffer < 0:
raise ValueError("lost_track_buffer must be greater than or equal to 0")
if not math.isfinite(frame_rate) or frame_rate <= 0:
raise ValueError("frame_rate must be a finite positive value")
if lost_track_buffer == 0:
return 0
return max(1, math.ceil(frame_rate / 30.0 * lost_track_buffer))

def __init_subclass__(cls, **kwargs: Any) -> None:
"""Register subclass in the tracker registry if it defines tracker_id.

Expand Down
21 changes: 11 additions & 10 deletions src/trackers/core/botsort/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ class BoTSORTTracker(BaseTracker):
9) Remove tracks that have been lost for too long

Args:
lost_track_buffer: Time buffer (in frames at 30 FPS) for keeping lost tracks
alive before deletion. This is scaled by `frame_rate`.
lost_track_buffer: Non-negative time buffer (in frames at 30 FPS) for
keeping lost tracks alive before deletion. `0` deletes a confirmed
track on the first missed frame. This is scaled by `frame_rate`.
frame_rate: Video frame rate used to scale the lost track buffer to
time-like behavior.
time-like behavior. Must be positive.
track_activation_threshold: Minimum detection confidence to spawn a new
track.
minimum_consecutive_frames: Number of successful updates required before
Expand Down Expand Up @@ -85,9 +86,9 @@ class BoTSORTTracker(BaseTracker):
supply an ``iou`` argument.

Notes:
- `maximum_frames_without_update` is computed as:
int(frame_rate / 30.0 * lost_track_buffer)
to maintain consistent “seconds” worth of buffer across different FPS.
- Positive `maximum_frames_without_update` values are scaled by
``frame_rate`` and rounded up to at least one missed frame. Explicit
zero-buffer configurations remain zero.
- When CMC is enabled, pass the current video frame via the ``frame``
argument of :meth:`update`.
"""
Expand Down Expand Up @@ -128,10 +129,10 @@ def __init__(
state_estimator_class: type[BaseStateEstimator] = XCYCWHStateEstimator,
iou: BaseIoU | None = None,
) -> None:
# Calculate maximum frames without update based on lost_track_buffer and
# frame_rate. This scales the buffer based on the frame rate to ensure
# consistent time-based tracking across different frame rates.
self.maximum_frames_without_update = int(frame_rate / 30.0 * lost_track_buffer)
self.maximum_frames_without_update = self._compute_maximum_frames_without_update(
lost_track_buffer=lost_track_buffer,
frame_rate=frame_rate,
)
self.minimum_consecutive_frames = minimum_consecutive_frames
self.minimum_iou_threshold_first_assoc = minimum_iou_threshold_first_assoc
self.minimum_iou_threshold_second_assoc = minimum_iou_threshold_second_assoc
Expand Down
8 changes: 4 additions & 4 deletions src/trackers/core/botsort/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ def get_alive_tracklets(
"""
Remove dead or immature lost tracklets and return alive ones.

A tracklet is kept if it is within ``maximum_frames_without_update`` **and**
it is either mature (enough successful updates) or was just updated this
frame.
A tracklet is kept if it has been missed for at most
``maximum_frames_without_update`` frames **and** it is either mature
(enough successful updates) or was just updated this frame.

Args:
tracklets: List of BoTSORTTracklet objects.
Expand All @@ -37,7 +37,7 @@ def get_alive_tracklets(
for tracker in tracklets:
is_mature = tracker.number_of_successful_updates >= minimum_consecutive_frames
is_active = tracker.time_since_update == 0
if tracker.time_since_update < maximum_frames_without_update and (is_mature or is_active):
if tracker.time_since_update <= maximum_frames_without_update and (is_mature or is_active):
alive_tracklets.append(tracker)
return alive_tracklets

Expand Down
19 changes: 10 additions & 9 deletions src/trackers/core/bytetrack/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ class ByteTrackTracker(BaseTracker):
provides.

Args:
lost_track_buffer: `int` specifying number of frames to buffer when a
track is lost. Increasing this value enhances occlusion handling but
may increase ID switching for disappearing objects.
lost_track_buffer: Non-negative `int` specifying number of 30 FPS frames
to buffer when a track is lost. `0` deletes a confirmed track on the
first missed frame. Increasing this value enhances occlusion
handling but may increase ID switching for disappearing objects.
frame_rate: `float` specifying video frame rate in frames per second.
Used to scale the lost track buffer for consistent tracking across
different frame rates.
Must be positive. Used to scale the lost track buffer for consistent
tracking across different frame rates.
track_activation_threshold: `float` specifying minimum detection
confidence to create new tracks. Higher values reduce false
positives but may miss low-confidence objects.
Expand Down Expand Up @@ -99,10 +100,10 @@ def __init__(
state_estimator_class: type[BaseStateEstimator] = XYXYStateEstimator,
iou: BaseIoU | None = None,
) -> None:
# Calculate maximum frames without update based on lost_track_buffer and
# frame_rate. This scales the buffer based on the frame rate to ensure
# consistent time-based tracking across different frame rates.
self.maximum_frames_without_update = int(frame_rate / 30.0 * lost_track_buffer)
self.maximum_frames_without_update = self._compute_maximum_frames_without_update(
lost_track_buffer=lost_track_buffer,
frame_rate=frame_rate,
)
self.minimum_consecutive_frames = minimum_consecutive_frames
self.minimum_iou_threshold = minimum_iou_threshold
self.track_activation_threshold = track_activation_threshold
Expand Down
4 changes: 2 additions & 2 deletions src/trackers/core/bytetrack/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def _get_alive_tracklets(
) -> list[T_ByteTrackTracklet]:
"""
Remove dead or immature lost tracklets and get alive trackers
that are within `maximum_frames_without_update` AND (it's mature OR
that are within `maximum_frames_without_update` missed frames AND (it's mature OR
it was just updated).

Note:
Expand Down Expand Up @@ -57,6 +57,6 @@ def _get_alive_tracklets(
tracklet.number_of_successful_consecutive_updates >= minimum_consecutive_frames
)
is_active = tracklet.time_since_update == 0
if tracklet.time_since_update < maximum_frames_without_update and (is_mature or is_active):
if tracklet.time_since_update <= maximum_frames_without_update and (is_mature or is_active):
alive_tracklets.append(tracklet)
return alive_tracklets
19 changes: 10 additions & 9 deletions src/trackers/core/ocsort/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ class OCSORTTracker(BaseTracker):
selection.

Args:
lost_track_buffer: `int` specifying number of frames to buffer when a
track is lost. Increasing this value enhances occlusion handling but
may increase ID switching for similar objects.
lost_track_buffer: Non-negative `int` specifying number of 30 FPS frames
to buffer when a track is lost. `0` deletes a confirmed track on the
first missed frame. Increasing this value enhances occlusion
handling but may increase ID switching for similar objects.
frame_rate: `float` specifying video frame rate in frames per second.
Used to scale the lost track buffer for consistent tracking across
different frame rates.
Must be positive. Used to scale the lost track buffer for consistent
tracking across different frame rates.
minimum_consecutive_frames: `int` specifying number of consecutive
frames before a track is considered valid. Before reaching this
threshold, tracks are assigned `tracker_id` of `-1`.
Expand Down Expand Up @@ -96,10 +97,10 @@ def __init__(
state_estimator_class: type[BaseStateEstimator] = XCYCSRStateEstimator,
iou: BaseIoU | None = None,
) -> None:
# Calculate maximum frames without update based on lost_track_buffer and
# frame_rate. This scales the buffer based on the frame rate to ensure
# consistent time-based tracking across different frame rates.
self.maximum_frames_without_update = int(frame_rate / 30.0 * lost_track_buffer)
self.maximum_frames_without_update = self._compute_maximum_frames_without_update(
lost_track_buffer=lost_track_buffer,
frame_rate=frame_rate,
)
self.minimum_consecutive_frames = minimum_consecutive_frames
self.minimum_iou_threshold = minimum_iou_threshold
self.direction_consistency_weight = direction_consistency_weight
Expand Down
19 changes: 10 additions & 9 deletions src/trackers/core/sort/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ class SORTTracker(BaseTracker):
applicability.

Args:
lost_track_buffer: `int` specifying number of frames to buffer when a
track is lost. Increasing this value enhances occlusion handling but
may increase ID switching for similar objects.
lost_track_buffer: Non-negative `int` specifying number of 30 FPS frames
to buffer when a track is lost. `0` deletes a confirmed track on the
first missed frame. Increasing this value enhances occlusion
handling but may increase ID switching for similar objects.
frame_rate: `float` specifying video frame rate in frames per second.
Used to scale the lost track buffer for consistent tracking across
different frame rates.
Must be positive. Used to scale the lost track buffer for consistent
tracking across different frame rates.
track_activation_threshold: `float` specifying minimum detection
confidence to create new tracks. Higher values reduce false
positives but may miss low-confidence objects.
Expand Down Expand Up @@ -87,10 +88,10 @@ def __init__(
state_estimator_class: type[BaseStateEstimator] = XYXYStateEstimator,
iou: BaseIoU | None = None,
) -> None:
# Calculate maximum frames without update based on lost_track_buffer and
# frame_rate. This scales the buffer based on the frame rate to ensure
# consistent time-based tracking across different frame rates.
self.maximum_frames_without_update = int(frame_rate / 30.0 * lost_track_buffer)
self.maximum_frames_without_update = self._compute_maximum_frames_without_update(
lost_track_buffer=lost_track_buffer,
frame_rate=frame_rate,
)
self.minimum_consecutive_frames = minimum_consecutive_frames
self.minimum_iou_threshold = minimum_iou_threshold
self.track_activation_threshold = track_activation_threshold
Expand Down
4 changes: 2 additions & 2 deletions src/trackers/core/sort/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def _get_alive_tracklets(
) -> list[T_SORTTracklet]:
"""
Remove dead or immature lost tracklets and get alive trackers
that are within `maximum_frames_without_update` AND (it's mature OR
that are within `maximum_frames_without_update` missed frames AND (it's mature OR
it was just updated).

Note:
Expand All @@ -42,6 +42,6 @@ def _get_alive_tracklets(
for tracklet in tracklets:
is_mature = tracklet.number_of_successful_updates >= minimum_consecutive_frames
is_active = tracklet.time_since_update == 0
if tracklet.time_since_update < maximum_frames_without_update and (is_mature or is_active):
if tracklet.time_since_update <= maximum_frames_without_update and (is_mature or is_active):
alive_tracklets.append(tracklet)
return alive_tracklets
4 changes: 2 additions & 2 deletions tests/core/test_botsort_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ def test_mature_track_dies_past_buffer(self) -> None:
assert alive == []

def test_buffer_boundary_exactly_at_limit(self) -> None:
"""time_since_update == maximum_frames_without_update is past the limit."""
"""time_since_update == maximum_frames_without_update is still within the buffer."""
track = _make_tracklet(success_updates=3, time_since=30)
alive = get_alive_tracklets([track], minimum_consecutive_frames=3, maximum_frames_without_update=30)
assert alive == []
assert alive == [track]

def test_buffer_boundary_one_under_limit(self) -> None:
"""time_since_update == maximum_frames_without_update - 1 survives."""
Expand Down
89 changes: 88 additions & 1 deletion tests/core/test_trackers.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ def test_reset_clears_tracks_and_restarts_ids(tracker_id: str) -> None:
# automatically without any explicit call from the tracker.
#
# These tests pin the contract for every concrete tracker:
# 1. A confirmed track is pruned after ``lost_track_buffer + N`` empty frames.
# 1. A confirmed track is pruned once the scaled ``lost_track_buffer`` is exceeded.
# 2. ``time_since_update`` actually advances when frames are missed.
# 3. A confirmed track survives a short occlusion.
# 4. Tracks spawned after frame 1 start unconfirmed.
Expand Down Expand Up @@ -454,6 +454,93 @@ def test_track_survives_short_occlusion(tracker_id: str) -> None:
assert tracker.tracks[0].tracker_id == confirmed_id, "confirmed track must survive a short gap"


@pytest.mark.parametrize("tracker_id", ALL_TRACKER_IDS)
def test_track_survives_exact_lost_buffer_boundary(tracker_id: str) -> None:
"""lost_track_buffer=N keeps a confirmed track alive for exactly N missed frames."""
tracker = _instantiate(
tracker_id,
lost_track_buffer=3,
frame_rate=30,
minimum_consecutive_frames=1,
)
bbox = (100.0, 100.0, 200.0, 200.0)

_run_until_confirmed(tracker, _detection(bbox))

for _ in range(tracker.maximum_frames_without_update):
tracker.update(sv.Detections.empty())

assert len(tracker.tracks) == 1, "track must survive through the full lost buffer"
assert tracker.tracks[0].time_since_update == tracker.maximum_frames_without_update

tracker.update(sv.Detections.empty())

assert len(tracker.tracks) == 0, "track must expire after the lost buffer is exceeded"


@pytest.mark.parametrize("tracker_id", ALL_TRACKER_IDS)
def test_low_frame_rate_lost_buffer_rounds_up_to_one_frame(tracker_id: str) -> None:
"""Low-FPS scaling must not floor a positive lost_track_buffer to zero."""
tracker = _instantiate(
tracker_id,
lost_track_buffer=1,
frame_rate=10,
minimum_consecutive_frames=1,
)
bbox = (100.0, 100.0, 200.0, 200.0)

assert tracker.maximum_frames_without_update == 1

_run_until_confirmed(tracker, _detection(bbox))
tracker.update(sv.Detections.empty())

assert len(tracker.tracks) == 1, "one requested missed frame must be preserved"
assert tracker.tracks[0].time_since_update == 1

tracker.update(sv.Detections.empty())

assert len(tracker.tracks) == 0, "track expires once the one-frame buffer is exceeded"


@pytest.mark.parametrize("tracker_id", ALL_TRACKER_IDS)
def test_zero_lost_buffer_expires_on_first_missed_frame(tracker_id: str) -> None:
"""lost_track_buffer=0 is an explicit no-grace-period configuration."""
tracker = _instantiate(
tracker_id,
lost_track_buffer=0,
frame_rate=30,
minimum_consecutive_frames=1,
)
bbox = (100.0, 100.0, 200.0, 200.0)

assert tracker.maximum_frames_without_update == 0

_run_until_confirmed(tracker, _detection(bbox))
assert len(tracker.tracks) == 1

tracker.update(sv.Detections.empty())

assert len(tracker.tracks) == 0, "zero buffer must prune on the first missed frame"


@pytest.mark.parametrize("tracker_id", ALL_TRACKER_IDS)
@pytest.mark.parametrize(
"kwargs",
[
{"lost_track_buffer": -1},
{"frame_rate": 0},
{"frame_rate": -30},
],
)
def test_lost_buffer_configuration_rejects_invalid_values(
tracker_id: str,
kwargs: dict[str, int],
) -> None:
"""Non-negative lost_track_buffer and positive frame_rate are required."""
with pytest.raises(ValueError):
_instantiate(tracker_id, **kwargs)


# ==========================================================================
# 4. tracked_objects property
# ==========================================================================
Expand Down
14 changes: 7 additions & 7 deletions tests/data/tracker_expected_dancetrack.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
"IDSW": 674
},
"bytetrack": {
"HOTA": 80.236,
"MOTA": 99.52,
"IDF1": 76.648,
"IDSW": 582
"HOTA": 80.29,
"MOTA": 99.521,
"IDF1": 76.789,
"IDSW": 581
},
"ocsort": {
"HOTA": 78.004,
Expand All @@ -18,9 +18,9 @@
"IDSW": 631
},
"botsort": {
"HOTA": 80.322,
"MOTA": 99.605,
"IDF1": 76.841,
"HOTA": 80.374,
"MOTA": 99.606,
"IDF1": 76.956,
"IDSW": 608
}
}
Loading
Loading