Skip to main content

warden_vision/tracker/
sort.rs

1//! A SORT-style multi-object tracker: Kalman-filter motion prediction plus
2//! IoU-based data association solved as a linear assignment problem.
3
4use crate::core::Detections;
5use crate::tracker::assignment::assign_by_iou;
6use crate::tracker::kalman::KalmanBoxFilter;
7
8struct Track {
9    id: usize,
10    filter: KalmanBoxFilter,
11    time_since_update: usize,
12}
13
14/// A SORT (Simple Online and Realtime Tracking) multi-object tracker.
15///
16/// Each call to [`SortTracker::update`] advances every active track's
17/// Kalman filter by one frame, solves the assignment between predicted
18/// track boxes and the frame's detections by intersection-over-union, and
19/// writes the resulting identity into each detection's
20/// [`tracker_id`](crate::core::Detection::tracker_id):
21/// - Matched detections update their track's filter and keep its id.
22/// - Unmatched detections spawn a new track with a freshly allocated id.
23/// - Tracks left unmatched for more than `max_age` consecutive frames are
24///   dropped.
25pub struct SortTracker {
26    tracks: Vec<Track>,
27    next_id: usize,
28    max_age: usize,
29    iou_threshold: f32,
30}
31
32impl SortTracker {
33    /// Creates a tracker.
34    ///
35    /// - `max_age`: number of consecutive missed frames a track tolerates
36    ///   before being dropped.
37    /// - `iou_threshold`: minimum IoU between a predicted track box and a
38    ///   detection for them to be considered a match.
39    pub fn new(max_age: usize, iou_threshold: f32) -> Self {
40        Self {
41            tracks: Vec::new(),
42            next_id: 1,
43            max_age,
44            iou_threshold,
45        }
46    }
47
48    /// Number of tracks currently being maintained (including ones missed
49    /// this frame but not yet aged out).
50    pub fn active_tracks(&self) -> usize {
51        self.tracks.len()
52    }
53
54    /// Advances the tracker by one frame, assigning `tracker_id` to every
55    /// detection in place.
56    pub fn update(&mut self, detections: &mut Detections) {
57        let predicted_boxes: Vec<[f32; 4]> =
58            self.tracks.iter_mut().map(|t| t.filter.predict()).collect();
59
60        let num_tracks = self.tracks.len();
61        let detection_boxes: Vec<[f32; 4]> = detections.detections.iter().map(|d| d.bbox).collect();
62        let matches = assign_by_iou(&predicted_boxes, &detection_boxes, self.iou_threshold);
63
64        let mut track_matched = vec![false; num_tracks];
65        for (detection_index, track_index) in matches.iter().enumerate() {
66            if let Some(track_index) = track_index {
67                let track = &mut self.tracks[*track_index];
68                track
69                    .filter
70                    .update(detections.detections[detection_index].bbox);
71                track.time_since_update = 0;
72                detections.detections[detection_index].tracker_id = Some(track.id);
73                track_matched[*track_index] = true;
74            }
75        }
76
77        for (track, matched) in self.tracks.iter_mut().zip(track_matched.iter()) {
78            if !matched {
79                track.time_since_update += 1;
80            }
81        }
82
83        for (detection_index, track_index) in matches.iter().enumerate() {
84            if track_index.is_none() {
85                let id = self.next_id;
86                self.next_id += 1;
87                let filter = KalmanBoxFilter::new(detections.detections[detection_index].bbox);
88                self.tracks.push(Track {
89                    id,
90                    filter,
91                    time_since_update: 0,
92                });
93                detections.detections[detection_index].tracker_id = Some(id);
94            }
95        }
96
97        self.tracks.retain(|t| t.time_since_update <= self.max_age);
98    }
99}
100
101impl Default for SortTracker {
102    /// A tracker with commonly used SORT defaults: 30-frame track lifetime,
103    /// 0.3 IoU match threshold.
104    fn default() -> Self {
105        Self::new(30, 0.3)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::core::Detection;
113
114    #[test]
115    fn new_detection_gets_a_fresh_tracker_id() {
116        let mut tracker = SortTracker::default();
117        let mut detections = Detections::new(vec![Detection::new([0.0, 0.0, 10.0, 10.0], 0.9, 0)]);
118        tracker.update(&mut detections);
119        assert!(detections.detections[0].tracker_id.is_some());
120        assert_eq!(tracker.active_tracks(), 1);
121    }
122
123    #[test]
124    fn matching_detection_keeps_same_tracker_id() {
125        let mut tracker = SortTracker::new(5, 0.3);
126
127        let mut first = Detections::new(vec![Detection::new([0.0, 0.0, 10.0, 10.0], 0.9, 0)]);
128        tracker.update(&mut first);
129        let id = first.detections[0].tracker_id.unwrap();
130
131        let mut second = Detections::new(vec![Detection::new([1.0, 1.0, 11.0, 11.0], 0.9, 0)]);
132        tracker.update(&mut second);
133
134        assert_eq!(second.detections[0].tracker_id, Some(id));
135        assert_eq!(tracker.active_tracks(), 1);
136    }
137
138    #[test]
139    fn two_independent_objects_maintain_separate_identities() {
140        let mut tracker = SortTracker::new(5, 0.3);
141
142        let mut first = Detections::new(vec![
143            Detection::new([0.0, 0.0, 10.0, 10.0], 0.9, 0),
144            Detection::new([100.0, 0.0, 110.0, 10.0], 0.9, 0),
145        ]);
146        tracker.update(&mut first);
147        let id_left = first.detections[0].tracker_id.unwrap();
148        let id_right = first.detections[1].tracker_id.unwrap();
149        assert_ne!(id_left, id_right);
150
151        let mut second = Detections::new(vec![
152            Detection::new([2.0, 0.0, 12.0, 10.0], 0.9, 0),
153            Detection::new([102.0, 0.0, 112.0, 10.0], 0.9, 0),
154        ]);
155        tracker.update(&mut second);
156
157        assert_eq!(second.detections[0].tracker_id, Some(id_left));
158        assert_eq!(second.detections[1].tracker_id, Some(id_right));
159    }
160
161    #[test]
162    fn far_apart_detection_does_not_match_existing_track() {
163        let mut tracker = SortTracker::new(5, 0.3);
164
165        let mut first = Detections::new(vec![Detection::new([0.0, 0.0, 10.0, 10.0], 0.9, 0)]);
166        tracker.update(&mut first);
167        let id1 = first.detections[0].tracker_id.unwrap();
168
169        let mut second =
170            Detections::new(vec![Detection::new([200.0, 200.0, 210.0, 210.0], 0.9, 0)]);
171        tracker.update(&mut second);
172        let id2 = second.detections[0].tracker_id.unwrap();
173
174        assert_ne!(id1, id2);
175        // The original track is still alive (missed one frame, within
176        // max_age), plus the new one: two active tracks.
177        assert_eq!(tracker.active_tracks(), 2);
178    }
179
180    #[test]
181    fn track_is_removed_after_max_age_missed_frames() {
182        let mut tracker = SortTracker::new(2, 0.3);
183
184        let mut first = Detections::new(vec![Detection::new([0.0, 0.0, 10.0, 10.0], 0.9, 0)]);
185        tracker.update(&mut first);
186        assert_eq!(tracker.active_tracks(), 1);
187
188        for _ in 0..2 {
189            let mut empty = Detections::empty();
190            tracker.update(&mut empty);
191        }
192        assert_eq!(
193            tracker.active_tracks(),
194            1,
195            "should survive up to max_age misses"
196        );
197
198        let mut empty = Detections::empty();
199        tracker.update(&mut empty);
200        assert_eq!(
201            tracker.active_tracks(),
202            0,
203            "should be dropped once max_age is exceeded"
204        );
205    }
206}