From 5bfd7560bbadc83144ebb0a1dde3f1835923ee58 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Fri, 15 May 2026 17:09:10 +0200 Subject: [PATCH] Unify lost track buffer semantics --- CHANGELOG.md | 5 ++ src/trackers/core/base.py | 15 ++++ src/trackers/core/botsort/tracker.py | 21 ++--- src/trackers/core/botsort/utils.py | 8 +- src/trackers/core/bytetrack/tracker.py | 19 ++--- src/trackers/core/bytetrack/utils.py | 4 +- src/trackers/core/ocsort/tracker.py | 19 ++--- src/trackers/core/sort/tracker.py | 19 ++--- src/trackers/core/sort/utils.py | 4 +- tests/core/test_botsort_utils.py | 4 +- tests/core/test_trackers.py | 89 ++++++++++++++++++++- tests/data/tracker_expected_dancetrack.json | 14 ++-- tests/data/tracker_expected_sportsmot.json | 4 +- 13 files changed, 168 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8662b35..630a5765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). @@ -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. --- diff --git a/src/trackers/core/base.py b/src/trackers/core/base.py index e27dce66..e99d2b52 100644 --- a/src/trackers/core/base.py +++ b/src/trackers/core/base.py @@ -7,6 +7,7 @@ from __future__ import annotations import inspect +import math import re import types import warnings @@ -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. diff --git a/src/trackers/core/botsort/tracker.py b/src/trackers/core/botsort/tracker.py index af2830af..804965d4 100644 --- a/src/trackers/core/botsort/tracker.py +++ b/src/trackers/core/botsort/tracker.py @@ -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 @@ -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`. """ @@ -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 diff --git a/src/trackers/core/botsort/utils.py b/src/trackers/core/botsort/utils.py index e240f55d..37825f7e 100644 --- a/src/trackers/core/botsort/utils.py +++ b/src/trackers/core/botsort/utils.py @@ -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. @@ -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 diff --git a/src/trackers/core/bytetrack/tracker.py b/src/trackers/core/bytetrack/tracker.py index 9abefaf6..dd36a3fc 100644 --- a/src/trackers/core/bytetrack/tracker.py +++ b/src/trackers/core/bytetrack/tracker.py @@ -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. @@ -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 diff --git a/src/trackers/core/bytetrack/utils.py b/src/trackers/core/bytetrack/utils.py index 6014999a..380b3a96 100644 --- a/src/trackers/core/bytetrack/utils.py +++ b/src/trackers/core/bytetrack/utils.py @@ -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: @@ -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 diff --git a/src/trackers/core/ocsort/tracker.py b/src/trackers/core/ocsort/tracker.py index 220f63c6..ca718176 100644 --- a/src/trackers/core/ocsort/tracker.py +++ b/src/trackers/core/ocsort/tracker.py @@ -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`. @@ -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 diff --git a/src/trackers/core/sort/tracker.py b/src/trackers/core/sort/tracker.py index 4f845c30..0755fcbc 100644 --- a/src/trackers/core/sort/tracker.py +++ b/src/trackers/core/sort/tracker.py @@ -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. @@ -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 diff --git a/src/trackers/core/sort/utils.py b/src/trackers/core/sort/utils.py index 6d7840c1..547809df 100644 --- a/src/trackers/core/sort/utils.py +++ b/src/trackers/core/sort/utils.py @@ -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: @@ -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 diff --git a/tests/core/test_botsort_utils.py b/tests/core/test_botsort_utils.py index 528eca6c..39824bf6 100644 --- a/tests/core/test_botsort_utils.py +++ b/tests/core/test_botsort_utils.py @@ -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.""" diff --git a/tests/core/test_trackers.py b/tests/core/test_trackers.py index 68964395..a2344cd8 100644 --- a/tests/core/test_trackers.py +++ b/tests/core/test_trackers.py @@ -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. @@ -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 # ========================================================================== diff --git a/tests/data/tracker_expected_dancetrack.json b/tests/data/tracker_expected_dancetrack.json index 6b9f8cdf..28e3380a 100644 --- a/tests/data/tracker_expected_dancetrack.json +++ b/tests/data/tracker_expected_dancetrack.json @@ -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, @@ -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 } } diff --git a/tests/data/tracker_expected_sportsmot.json b/tests/data/tracker_expected_sportsmot.json index f2cbda21..ad12fb95 100644 --- a/tests/data/tracker_expected_sportsmot.json +++ b/tests/data/tracker_expected_sportsmot.json @@ -19,8 +19,8 @@ }, "botsort": { "HOTA": 85.559, - "MOTA": 98.884, + "MOTA": 98.883, "IDF1": 80.626, - "IDSW": 983 + "IDSW": 985 } }