Skip to main content

warden_vision/tracker/
byte_track.rs

1//! ByteTrack: a Kalman-filter multi-object tracker that, unlike
2//! [`SortTracker`](crate::tracker::SortTracker), associates in two IoU
3//! passes per frame — high-confidence detections first, then low-confidence
4//! ones against whatever tracks are still unmatched. This lets it recover
5//! tracks through brief occlusion or detector noise using boxes that a
6//! confidence-thresholding preprocessing step would otherwise discard
7//! entirely, which is ByteTrack's core idea (Zhang et al., 2022).
8//!
9//! Not a byte-for-byte port of the reference implementation: it skips the
10//! separate "unconfirmed" track category for brand-new single-frame
11//! tracks, and its default thresholds are tuned for this crate rather
12//! than copied from the paper.
13
14use crate::core::{Detection, Detections};
15use crate::tracker::assignment::assign_by_iou;
16use crate::tracker::kalman::KalmanBoxFilter;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum TrackState {
20    /// Matched within the last frame (either continuously, or just
21    /// re-matched after being [`TrackState::Lost`]).
22    Tracked,
23    /// Unmatched for at least one frame; still retained (and still
24    /// eligible for stage-1 re-matching) until `lost_track_buffer` frames
25    /// have elapsed.
26    Lost,
27}
28
29struct Track {
30    id: usize,
31    filter: KalmanBoxFilter,
32    state: TrackState,
33    time_since_update: usize,
34}
35
36/// A ByteTrack multi-object tracker.
37///
38/// Unlike [`SortTracker`](crate::tracker::SortTracker)'s
39/// [`update`](crate::tracker::SortTracker::update), which mutates every
40/// input detection in place, [`ByteTracker::update`] returns a new
41/// [`Detections`] containing only the detections that ended up associated
42/// with a track. This is a deliberate consequence of the algorithm: a
43/// low-confidence detection that fails to match any existing track is, by
44/// design, never allowed to spawn a new one (that's what keeps ByteTrack
45/// from turning background noise into new identities), so it has no track
46/// id to report and is dropped from the output rather than passed through
47/// unmodified.
48pub struct ByteTracker {
49    tracks: Vec<Track>,
50    next_id: usize,
51    /// Detections at or above this confidence are "high-confidence": they
52    /// are matched first, and any left over spawn new tracks.
53    track_activation_threshold: f32,
54    /// IoU threshold for stage-1 (high-confidence) matching.
55    high_confidence_matching_threshold: f32,
56    /// IoU threshold for stage-2 (low-confidence) matching. Kept separate
57    /// from, and typically lower than,
58    /// `high_confidence_matching_threshold` since low-confidence detections
59    /// tend to have noisier box localization.
60    low_confidence_matching_threshold: f32,
61    /// Number of consecutive missed frames a [`TrackState::Lost`] track
62    /// tolerates before being dropped.
63    lost_track_buffer: usize,
64}
65
66impl ByteTracker {
67    /// Creates a tracker.
68    ///
69    /// - `track_activation_threshold`: confidence cutoff between the
70    ///   high-confidence and low-confidence detection pools.
71    /// - `lost_track_buffer`: frames a track survives, unmatched, before
72    ///   being dropped.
73    /// - `high_confidence_matching_threshold` / `low_confidence_matching_threshold`:
74    ///   minimum IoU for a match in each of the two association passes.
75    pub fn new(
76        track_activation_threshold: f32,
77        lost_track_buffer: usize,
78        high_confidence_matching_threshold: f32,
79        low_confidence_matching_threshold: f32,
80    ) -> Self {
81        Self {
82            tracks: Vec::new(),
83            next_id: 1,
84            track_activation_threshold,
85            high_confidence_matching_threshold,
86            low_confidence_matching_threshold,
87            lost_track_buffer,
88        }
89    }
90
91    /// Number of tracks currently being maintained, including lost-but-not-
92    /// yet-aged-out ones.
93    pub fn active_tracks(&self) -> usize {
94        self.tracks.len()
95    }
96
97    /// Advances the tracker by one frame.
98    ///
99    /// Returns a new [`Detections`] holding the subset of `detections` that
100    /// matched or spawned a track, each with [`Detection::tracker_id`] set;
101    /// see the type-level docs for why unmatched low-confidence detections
102    /// are dropped rather than passed through.
103    pub fn update(&mut self, detections: &Detections) -> Detections {
104        let predicted_boxes: Vec<[f32; 4]> =
105            self.tracks.iter_mut().map(|t| t.filter.predict()).collect();
106
107        let (high_idx, low_idx): (Vec<usize>, Vec<usize>) = (0..detections.len())
108            .partition(|&i| detections.detections[i].confidence >= self.track_activation_threshold);
109        let high_boxes: Vec<[f32; 4]> = high_idx
110            .iter()
111            .map(|&i| detections.detections[i].bbox)
112            .collect();
113        let low_boxes: Vec<[f32; 4]> = low_idx
114            .iter()
115            .map(|&i| detections.detections[i].bbox)
116            .collect();
117
118        let mut track_matched = vec![false; self.tracks.len()];
119        let mut assigned_track_id: Vec<Option<usize>> = vec![None; detections.len()];
120
121        // Stage 1: every current track (tracked or lost) against high-confidence detections.
122        let stage1 = assign_by_iou(
123            &predicted_boxes,
124            &high_boxes,
125            self.high_confidence_matching_threshold,
126        );
127        for (k, matched_track) in stage1.iter().enumerate() {
128            if let Some(track_index) = matched_track {
129                let detection_index = high_idx[k];
130                let track = &mut self.tracks[*track_index];
131                track
132                    .filter
133                    .update(detections.detections[detection_index].bbox);
134                track.time_since_update = 0;
135                track.state = TrackState::Tracked;
136                assigned_track_id[detection_index] = Some(track.id);
137                track_matched[*track_index] = true;
138            }
139        }
140
141        // Stage 2: tracks still Tracked (not already Lost) but unmatched in
142        // stage 1, against low-confidence detections.
143        let stage2_track_indices: Vec<usize> = (0..self.tracks.len())
144            .filter(|&i| !track_matched[i] && self.tracks[i].state == TrackState::Tracked)
145            .collect();
146        let stage2_track_boxes: Vec<[f32; 4]> = stage2_track_indices
147            .iter()
148            .map(|&i| predicted_boxes[i])
149            .collect();
150        let stage2 = assign_by_iou(
151            &stage2_track_boxes,
152            &low_boxes,
153            self.low_confidence_matching_threshold,
154        );
155        for (k, matched_track) in stage2.iter().enumerate() {
156            if let Some(local_index) = matched_track {
157                let track_index = stage2_track_indices[*local_index];
158                let detection_index = low_idx[k];
159                let track = &mut self.tracks[track_index];
160                track
161                    .filter
162                    .update(detections.detections[detection_index].bbox);
163                track.time_since_update = 0;
164                track.state = TrackState::Tracked;
165                assigned_track_id[detection_index] = Some(track.id);
166                track_matched[track_index] = true;
167            }
168        }
169
170        // Anything still unmatched after both stages is (or remains) lost.
171        for (i, track) in self.tracks.iter_mut().enumerate() {
172            if !track_matched[i] {
173                track.time_since_update += 1;
174                track.state = TrackState::Lost;
175            }
176        }
177        self.tracks
178            .retain(|t| t.time_since_update <= self.lost_track_buffer);
179
180        // Unmatched high-confidence detections spawn new tracks. Unmatched
181        // low-confidence detections do not (see type-level docs).
182        for (k, matched_track) in stage1.iter().enumerate() {
183            if matched_track.is_none() {
184                let detection_index = high_idx[k];
185                let id = self.next_id;
186                self.next_id += 1;
187                let filter = KalmanBoxFilter::new(detections.detections[detection_index].bbox);
188                self.tracks.push(Track {
189                    id,
190                    filter,
191                    state: TrackState::Tracked,
192                    time_since_update: 0,
193                });
194                assigned_track_id[detection_index] = Some(id);
195            }
196        }
197
198        let output: Vec<Detection> = detections
199            .iter()
200            .enumerate()
201            .filter_map(|(i, d)| {
202                assigned_track_id[i].map(|id| {
203                    let mut d = d.clone();
204                    d.tracker_id = Some(id);
205                    d
206                })
207            })
208            .collect();
209        Detections::new(output)
210    }
211}
212
213impl Default for ByteTracker {
214    /// Commonly used ByteTrack-style defaults: 0.25 confidence split, a
215    /// 30-frame lost-track buffer, 0.3 IoU for high-confidence matching,
216    /// 0.2 IoU (more permissive, since low-confidence boxes are noisier)
217    /// for low-confidence matching.
218    fn default() -> Self {
219        Self::new(0.25, 30, 0.3, 0.2)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::core::Detection;
227
228    fn det(bbox: [f32; 4], confidence: f32) -> Detection {
229        Detection::new(bbox, confidence, 0)
230    }
231
232    #[test]
233    fn high_confidence_detection_spawns_a_track() {
234        let mut tracker = ByteTracker::default();
235        let detections = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.9)]);
236        let out = tracker.update(&detections);
237        assert_eq!(out.len(), 1);
238        assert!(out.detections[0].tracker_id.is_some());
239        assert_eq!(tracker.active_tracks(), 1);
240    }
241
242    #[test]
243    fn low_confidence_unmatched_detection_is_dropped_and_spawns_nothing() {
244        let mut tracker = ByteTracker::default();
245        let detections = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.1)]);
246        let out = tracker.update(&detections);
247        assert_eq!(out.len(), 0);
248        assert_eq!(tracker.active_tracks(), 0);
249    }
250
251    #[test]
252    fn matching_high_confidence_detection_keeps_same_id() {
253        let mut tracker = ByteTracker::default();
254        let first = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.9)]);
255        let out1 = tracker.update(&first);
256        let id = out1.detections[0].tracker_id.unwrap();
257
258        let second = Detections::new(vec![det([1.0, 1.0, 11.0, 11.0], 0.9)]);
259        let out2 = tracker.update(&second);
260        assert_eq!(out2.detections[0].tracker_id, Some(id));
261    }
262
263    #[test]
264    fn low_confidence_detection_recovers_an_existing_track() {
265        let mut tracker = ByteTracker::default();
266        let first = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.9)]);
267        let out1 = tracker.update(&first);
268        let id = out1.detections[0].tracker_id.unwrap();
269
270        // Same object, but now only seen at low confidence (e.g. partial
271        // occlusion): stage 2 should still recover it under the same id
272        // rather than dropping it.
273        let second = Detections::new(vec![det([1.0, 1.0, 11.0, 11.0], 0.15)]);
274        let out2 = tracker.update(&second);
275        assert_eq!(out2.len(), 1);
276        assert_eq!(out2.detections[0].tracker_id, Some(id));
277    }
278
279    #[test]
280    fn track_survives_briefly_missing_and_is_dropped_after_lost_track_buffer() {
281        let mut tracker = ByteTracker::new(0.25, 2, 0.3, 0.2);
282        let first = Detections::new(vec![det([0.0, 0.0, 10.0, 10.0], 0.9)]);
283        tracker.update(&first);
284        assert_eq!(tracker.active_tracks(), 1);
285
286        for _ in 0..2 {
287            tracker.update(&Detections::empty());
288        }
289        assert_eq!(
290            tracker.active_tracks(),
291            1,
292            "should survive up to lost_track_buffer misses"
293        );
294
295        tracker.update(&Detections::empty());
296        assert_eq!(
297            tracker.active_tracks(),
298            0,
299            "should be dropped once lost_track_buffer is exceeded"
300        );
301    }
302
303    #[test]
304    fn two_independent_objects_maintain_separate_identities() {
305        let mut tracker = ByteTracker::default();
306        let first = Detections::new(vec![
307            det([0.0, 0.0, 10.0, 10.0], 0.9),
308            det([100.0, 0.0, 110.0, 10.0], 0.9),
309        ]);
310        let out1 = tracker.update(&first);
311        let id_left = out1.detections[0].tracker_id.unwrap();
312        let id_right = out1.detections[1].tracker_id.unwrap();
313        assert_ne!(id_left, id_right);
314
315        let second = Detections::new(vec![
316            det([2.0, 0.0, 12.0, 10.0], 0.9),
317            det([102.0, 0.0, 112.0, 10.0], 0.9),
318        ]);
319        let out2 = tracker.update(&second);
320        assert_eq!(out2.detections[0].tracker_id, Some(id_left));
321        assert_eq!(out2.detections[1].tracker_id, Some(id_right));
322    }
323}