Skip to main content

edgefirst_tracker/
bytetrack.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! ByteTrack: two-pass detection-to-tracklet association.
5//!
6//! [`ByteTrack`] is the crate's implementation of the [`Tracker`] trait.
7//! Each [`Tracker::update`] call predicts every tracklet forward with its
8//! Kalman filter, then matches detections in two passes — high-confidence
9//! detections (score >= `track_high_conf`) against all tracklets first, then
10//! the leftover low-confidence detections against whatever went unmatched.
11//! That second pass is what ByteTrack is named for: a briefly occluded object
12//! whose score dips still recovers its original track instead of being
13//! discarded and re-created with a new UUID.
14//!
15//! Both passes solve the assignment optimally with LAPJV over an IoU cost
16//! matrix. Unmatched detections spawn tracklets; tracklets that go
17//! `track_extra_lifespan` without a match are deleted.
18//!
19//! Build one with [`ByteTrackBuilder`]; see the crate root for a worked
20//! example.
21
22use crate::{
23    kalman::ConstantVelocityXYAHModel2, ActiveTrackInfo, DetectionBox, TrackInfo, Tracker,
24};
25use lapjv::{lapjv, Matrix};
26use log::trace;
27use nalgebra::{Dyn, OMatrix, U4};
28use uuid::Uuid;
29
30/// Builder for [`ByteTrack`] with fluent setter methods.
31///
32/// All parameters have defaults tuned for typical 30-fps inference pipelines.
33/// Call [`ByteTrackBuilder::new`] (or `Default::default()`) to start with the
34/// defaults, override only what you need, then call [`ByteTrackBuilder::build`]
35/// to construct the tracker.
36///
37/// # Example
38///
39/// ```rust
40/// use edgefirst_tracker::bytetrack::ByteTrackBuilder;
41///
42/// let tracker = ByteTrackBuilder::new()
43///     .track_high_conf(0.5)          // accept more low-quality detections
44///     .track_extra_lifespan(1_000_000_000) // keep lost tracks for 1 s
45///     .build::<edgefirst_tracker::MockDetection>();
46/// ```
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct ByteTrackBuilder {
49    track_extra_lifespan: u64,
50    track_high_conf: f32,
51    track_iou: f32,
52    track_update: f32,
53}
54
55impl Default for ByteTrackBuilder {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl ByteTrackBuilder {
62    /// Creates a new ByteTrackBuilder with default parameters.
63    /// These defaults are:
64    /// - track_high_conf: 0.7
65    /// - track_iou: 0.25
66    /// - track_update: 0.25
67    /// - track_extra_lifespan: 500_000_000 (0.5 seconds)
68    /// # Examples
69    /// ```rust
70    /// use edgefirst_tracker::{bytetrack::ByteTrackBuilder, Tracker, MockDetection};
71    /// let mut tracker = ByteTrackBuilder::new().build();
72    /// assert_eq!(tracker.track_high_conf, 0.7);
73    /// assert_eq!(tracker.track_iou, 0.25);
74    /// assert_eq!(tracker.track_update, 0.25);
75    /// assert_eq!(tracker.track_extra_lifespan, 500_000_000);
76    /// # let boxes = Vec::<MockDetection>::new();
77    /// # tracker.update(&boxes, 0);
78    /// ```
79    pub fn new() -> Self {
80        Self {
81            track_high_conf: 0.7,
82            track_iou: 0.25,
83            track_update: 0.25,
84            track_extra_lifespan: 500_000_000,
85        }
86    }
87
88    /// Sets the extra lifespan for tracks in nanoseconds.
89    pub fn track_extra_lifespan(mut self, lifespan: u64) -> Self {
90        self.track_extra_lifespan = lifespan;
91        self
92    }
93
94    /// Sets the high confidence threshold for tracking.
95    pub fn track_high_conf(mut self, conf: f32) -> Self {
96        self.track_high_conf = conf;
97        self
98    }
99
100    /// Sets the IOU threshold for tracking.
101    pub fn track_iou(mut self, iou: f32) -> Self {
102        self.track_iou = iou;
103        self
104    }
105
106    /// Sets the update rate for the Kalman filter.
107    pub fn track_update(mut self, update: f32) -> Self {
108        self.track_update = update;
109        self
110    }
111
112    /// Builds the ByteTrack tracker with the specified parameters.
113    /// # Examples
114    /// ```rust
115    /// use edgefirst_tracker::{bytetrack::ByteTrackBuilder, Tracker, MockDetection};
116    /// let mut tracker = ByteTrackBuilder::new()
117    ///     .track_high_conf(0.8)
118    ///     .track_iou(0.3)
119    ///     .track_update(0.2)
120    ///     .track_extra_lifespan(1_000_000_000)
121    ///     .build();
122    /// assert_eq!(tracker.track_high_conf, 0.8);
123    /// assert_eq!(tracker.track_iou, 0.3);
124    /// assert_eq!(tracker.track_update, 0.2);
125    /// assert_eq!(tracker.track_extra_lifespan, 1_000_000_000);
126    /// # let boxes = Vec::<MockDetection>::new();
127    /// # tracker.update(&boxes, 0);
128    /// ```
129    pub fn build<T: DetectionBox>(self) -> ByteTrack<T> {
130        ByteTrack {
131            track_extra_lifespan: self.track_extra_lifespan,
132            track_high_conf: self.track_high_conf,
133            track_iou: self.track_iou,
134            track_update: self.track_update,
135            tracklets: Vec::new(),
136            frame_count: 0,
137        }
138    }
139}
140
141/// ByteTrack multi-object tracker — the concrete [`Tracker`] implementation.
142///
143/// Prefer constructing via [`ByteTrackBuilder`] rather than directly, since the
144/// public fields default to zero-values that are not usable defaults.
145///
146/// # Algorithm overview
147///
148/// Each call to [`Tracker::update`] runs a two-pass IoU matching loop:
149///
150/// 1. **Predict.** Every active tracklet's Kalman filter advances one step,
151///    predicting where the object should appear this frame.
152/// 2. **High-confidence pass.** Detections scoring ≥ `track_high_conf` are
153///    assigned to existing tracklets via LAPJV linear assignment on an IoU cost
154///    matrix (cells below `track_iou` are set to `INVALID_MATCH`).
155/// 3. **Low-confidence pass.** Remaining unmatched tracklets are tried against
156///    all remaining detections (any score) — this recovers briefly occluded
157///    objects without admitting low-quality detections into the high-confidence
158///    pool.
159/// 4. **Expire.** Tracklets whose `last_updated` is more than `track_extra_lifespan`
160///    nanoseconds behind `timestamp` are removed.
161/// 5. **Spawn.** Unmatched high-confidence detections spawn new tracklets.
162///
163/// # Thread safety
164///
165/// `ByteTrack<T>` is `Send + Sync` (provided `T: Send + Sync`). Mutable
166/// methods take `&mut self`; callers that share a tracker across threads must
167/// serialize with an external mutex.
168#[allow(dead_code)]
169#[derive(Default, Debug, Clone)]
170pub struct ByteTrack<T: DetectionBox> {
171    /// How long (in nanoseconds) a tracklet may go unmatched before it is
172    /// deleted. Default: 500 ms (500_000_000 ns).
173    pub track_extra_lifespan: u64,
174
175    /// Minimum detection score to enter the high-confidence matching pass and
176    /// to spawn new tracklets. Default: 0.7.
177    pub track_high_conf: f32,
178
179    /// Minimum IoU required for a detection–tracklet assignment to be accepted.
180    /// Pairs below this threshold are set to `INVALID_MATCH` in the cost matrix.
181    /// Default: 0.25.
182    pub track_iou: f32,
183
184    /// Kalman filter measurement gain (0–1). Lower values trust the Kalman
185    /// prediction more; higher values follow the raw detection more closely.
186    /// Default: 0.25.
187    pub track_update: f32,
188
189    /// All currently active tracklets (matched or unmatched-but-not-expired).
190    pub tracklets: Vec<Tracklet<T>>,
191
192    /// Running count of frames processed since the tracker was created.
193    /// Incremented on every [`Tracker::update`] call, including empty frames.
194    pub frame_count: i32,
195}
196
197/// Internal state for one tracked object.
198///
199/// Not part of the public API contract; exposed as `pub` for test and benchmark
200/// access only. The canonical public view of a tracklet is [`TrackInfo`]
201/// (returned by [`Tracker::update`]) or [`ActiveTrackInfo`] (returned by
202/// [`Tracker::get_active_tracks`]).
203#[derive(Debug, Clone)]
204pub struct Tracklet<T: DetectionBox> {
205    /// Globally unique identifier, assigned at tracklet creation and preserved
206    /// until the tracklet is deleted. Surfaced via [`TrackInfo::uuid`].
207    pub id: Uuid,
208
209    /// Kalman filter modeling the tracklet's position and velocity in XYAH
210    /// space (`[x_center, y_center, aspect, height]` + their derivatives).
211    pub filter: ConstantVelocityXYAHModel2<f32>,
212
213    /// Number of frames this tracklet has been matched to a detection. Starts
214    /// at 1 on the frame of creation. Surfaced via [`TrackInfo::count`].
215    pub count: i32,
216
217    /// Timestamp passed to [`Tracker::update`] on the frame this tracklet was
218    /// created. Surfaced via [`TrackInfo::created`].
219    pub created: u64,
220
221    /// Timestamp passed to [`Tracker::update`] on the most recent frame this
222    /// tracklet was matched to a detection. Tracklets are deleted when
223    /// `timestamp - last_updated > track_extra_lifespan`.
224    pub last_updated: u64,
225
226    /// The raw detection box from the most recent match. Surfaced via
227    /// [`ActiveTrackInfo::last_box`]. Not Kalman-smoothed.
228    pub last_box: T,
229}
230
231impl<T: DetectionBox> Tracklet<T> {
232    fn update(&mut self, detect_box: &T, ts: u64) {
233        self.count += 1;
234        self.last_updated = ts;
235        self.filter.update(&xyxy_to_xyah(&detect_box.bbox()));
236        self.last_box = detect_box.clone();
237    }
238
239    /// Return the current Kalman-predicted bounding box in XYXY format.
240    ///
241    /// Projects the filter's 8-D XYAH mean into 4-D measurement space and
242    /// converts back to `[xmin, ymin, xmax, ymax]`.  This is the value
243    /// surfaced in [`TrackInfo::tracked_location`].
244    pub fn get_predicted_location(&self) -> [f32; 4] {
245        let projected = self.filter.project().0;
246        let predicted_xyah = projected.as_slice();
247        xyah_to_xyxy(predicted_xyah)
248    }
249}
250
251fn xyxy_to_xyah(vaal_box: &[f32; 4]) -> [f32; 4] {
252    let x = (vaal_box[2] + vaal_box[0]) / 2.0;
253    let y = (vaal_box[3] + vaal_box[1]) / 2.0;
254    let w = (vaal_box[2] - vaal_box[0]).max(EPSILON);
255    let h = (vaal_box[3] - vaal_box[1]).max(EPSILON);
256    let a = w / h;
257
258    [x, y, a, h]
259}
260
261fn xyah_to_xyxy(xyah: &[f32]) -> [f32; 4] {
262    assert!(xyah.len() >= 4);
263    let [x, y, a, h] = xyah[0..4] else {
264        unreachable!()
265    };
266    let w = h * a;
267    [x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0]
268}
269
270const INVALID_MATCH: f32 = 1000000.0;
271const EPSILON: f32 = 0.00001;
272
273fn iou(box1: &[f32], box2: &[f32]) -> f32 {
274    let intersection = (box1[2].min(box2[2]) - box1[0].max(box2[0])).max(0.0)
275        * (box1[3].min(box2[3]) - box1[1].max(box2[1])).max(0.0);
276
277    let union = (box1[2] - box1[0]) * (box1[3] - box1[1])
278        + (box2[2] - box2[0]) * (box2[3] - box2[1])
279        - intersection;
280
281    if union <= EPSILON {
282        return 0.0;
283    }
284
285    intersection / union
286}
287
288fn box_cost<T: DetectionBox>(
289    track: &Tracklet<T>,
290    new_box: &T,
291    distance: f32,
292    score_threshold: f32,
293    iou_threshold: f32,
294) -> f32 {
295    let _ = distance;
296
297    if new_box.score() < score_threshold {
298        return INVALID_MATCH;
299    }
300
301    // use iou between predicted box and real box:
302    let predicted_xyah = track.filter.mean.as_slice();
303    let expected = xyah_to_xyxy(predicted_xyah);
304    let iou = iou(&expected, &new_box.bbox());
305    if iou < iou_threshold {
306        return INVALID_MATCH;
307    }
308    (1.5 - new_box.score()) + (1.5 - iou)
309}
310
311impl<T: DetectionBox> ByteTrack<T> {
312    fn compute_costs(
313        &mut self,
314        boxes: &[T],
315        score_threshold: f32,
316        iou_threshold: f32,
317        box_filter: &[bool],
318        track_filter: &[bool],
319    ) -> Matrix<f32> {
320        // costs matrix must be square
321        let dims = boxes.len().max(self.tracklets.len());
322        let mut measurements = OMatrix::<f32, Dyn, U4>::from_element(boxes.len(), 0.0);
323        for (i, mut row) in measurements.row_iter_mut().enumerate() {
324            row.copy_from_slice(&xyxy_to_xyah(&boxes[i].bbox()));
325        }
326
327        // TODO: use matrix math for IOU, should speed up computation, and store it in
328        // distances
329
330        Matrix::from_shape_fn((dims, dims), |(x, y)| {
331            if x < boxes.len() && y < self.tracklets.len() {
332                if box_filter[x] || track_filter[y] {
333                    INVALID_MATCH
334                } else {
335                    box_cost(
336                        &self.tracklets[y],
337                        &boxes[x],
338                        // distances[(x, y)],
339                        0.0,
340                        score_threshold,
341                        iou_threshold,
342                    )
343                }
344            } else {
345                0.0
346            }
347        })
348    }
349
350    /// Process assignments from linear assignment and update tracking state.
351    /// Returns true if any matches were made.
352    #[allow(clippy::too_many_arguments)]
353    fn process_assignments(
354        &mut self,
355        assignments: &[usize],
356        boxes: &[T],
357        costs: &Matrix<f32>,
358        matched: &mut [bool],
359        tracked: &mut [bool],
360        matched_info: &mut [Option<TrackInfo>],
361        timestamp: u64,
362        log_assignments: bool,
363    ) {
364        for (i, &x) in assignments.iter().enumerate() {
365            if i >= boxes.len() || x >= self.tracklets.len() {
366                continue;
367            }
368
369            // Filter out invalid assignments
370            if costs[(i, x)] >= INVALID_MATCH {
371                continue;
372            }
373
374            // Skip already matched boxes/tracklets
375            if matched[i] || tracked[x] {
376                continue;
377            }
378
379            if log_assignments {
380                trace!(
381                    "Cost: {} Box: {:#?} UUID: {} Mean: {}",
382                    costs[(i, x)],
383                    boxes[i],
384                    self.tracklets[x].id,
385                    self.tracklets[x].filter.mean
386                );
387            }
388
389            matched[i] = true;
390            matched_info[i] = Some(TrackInfo {
391                uuid: self.tracklets[x].id,
392                count: self.tracklets[x].count,
393                created: self.tracklets[x].created,
394                tracked_location: self.tracklets[x].get_predicted_location(),
395                last_updated: timestamp,
396            });
397            tracked[x] = true;
398            self.tracklets[x].update(&boxes[i], timestamp);
399        }
400    }
401
402    /// Remove expired tracklets based on timestamp.
403    fn remove_expired_tracklets(&mut self, timestamp: u64) {
404        // must iterate from the back
405        for i in (0..self.tracklets.len()).rev() {
406            let expiry = self.tracklets[i].last_updated + self.track_extra_lifespan;
407            if expiry < timestamp {
408                trace!("Tracklet removed: {:?}", self.tracklets[i].id);
409                let _ = self.tracklets.swap_remove(i);
410            }
411        }
412    }
413
414    /// Create new tracklets from unmatched high-confidence boxes.
415    fn create_new_tracklets(
416        &mut self,
417        boxes: &[T],
418        high_conf_indices: &[usize],
419        matched: &[bool],
420        matched_info: &mut [Option<TrackInfo>],
421        timestamp: u64,
422    ) {
423        for &i in high_conf_indices {
424            if matched[i] {
425                continue;
426            }
427
428            let id = Uuid::new_v4();
429            let new_tracklet = Tracklet {
430                id,
431                filter: ConstantVelocityXYAHModel2::new(
432                    &xyxy_to_xyah(&boxes[i].bbox()),
433                    self.track_update,
434                ),
435                last_updated: timestamp,
436                count: 1,
437                created: timestamp,
438                last_box: boxes[i].clone(),
439            };
440            matched_info[i] = Some(TrackInfo {
441                uuid: new_tracklet.id,
442                count: new_tracklet.count,
443                created: new_tracklet.created,
444                tracked_location: new_tracklet.get_predicted_location(),
445                last_updated: timestamp,
446            });
447            self.tracklets.push(new_tracklet);
448        }
449    }
450}
451
452impl<T> Tracker<T> for ByteTrack<T>
453where
454    T: DetectionBox,
455{
456    fn update(&mut self, boxes: &[T], timestamp: u64) -> Vec<Option<TrackInfo>> {
457        let span = tracing::trace_span!(
458            "tracker.update",
459            n_detections = boxes.len(),
460            n_tracklets = self.tracklets.len(),
461            timestamp,
462        );
463        let _enter = span.enter();
464
465        self.frame_count += 1;
466
467        // Identify high-confidence detections
468        let high_conf_ind: Vec<usize> = boxes
469            .iter()
470            .enumerate()
471            .filter(|(_, b)| b.score() >= self.track_high_conf)
472            .map(|(x, _)| x)
473            .collect();
474
475        let mut matched = vec![false; boxes.len()];
476        let mut tracked = vec![false; self.tracklets.len()];
477        let mut matched_info = vec![None; boxes.len()];
478
479        // First pass: match high-confidence detections
480        if !self.tracklets.is_empty() {
481            let _s = tracing::trace_span!("tracker.update.predict").entered();
482            for track in &mut self.tracklets {
483                track.filter.predict();
484            }
485        }
486
487        if !self.tracklets.is_empty() {
488            let _s = tracing::trace_span!("tracker.update.match_high_conf").entered();
489            let costs = self.compute_costs(
490                boxes,
491                self.track_high_conf,
492                self.track_iou,
493                &matched,
494                &tracked,
495            );
496            if let Ok(ans) = lapjv(&costs) {
497                self.process_assignments(
498                    &ans.0,
499                    boxes,
500                    &costs,
501                    &mut matched,
502                    &mut tracked,
503                    &mut matched_info,
504                    timestamp,
505                    false,
506                );
507            }
508        }
509
510        // Second pass: match remaining tracklets to low-confidence detections
511        if !self.tracklets.is_empty() {
512            let _s = tracing::trace_span!("tracker.update.match_low_conf").entered();
513            let costs = self.compute_costs(boxes, 0.0, self.track_iou, &matched, &tracked);
514            if let Ok(ans) = lapjv(&costs) {
515                self.process_assignments(
516                    &ans.0,
517                    boxes,
518                    &costs,
519                    &mut matched,
520                    &mut tracked,
521                    &mut matched_info,
522                    timestamp,
523                    true,
524                );
525            }
526        }
527
528        // Remove expired tracklets
529        self.remove_expired_tracklets(timestamp);
530
531        // Create new tracklets from unmatched high-confidence boxes
532        self.create_new_tracklets(
533            boxes,
534            &high_conf_ind,
535            &matched,
536            &mut matched_info,
537            timestamp,
538        );
539
540        matched_info
541    }
542
543    fn get_active_tracks(&self) -> Vec<ActiveTrackInfo<T>> {
544        self.tracklets
545            .iter()
546            .map(|t| ActiveTrackInfo {
547                info: TrackInfo {
548                    uuid: t.id,
549                    tracked_location: t.get_predicted_location(),
550                    count: t.count,
551                    created: t.created,
552                    last_updated: t.last_updated,
553                },
554                last_box: t.last_box.clone(),
555            })
556            .collect()
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::*;
564
565    #[test]
566    fn test_vaalbox_xyah_roundtrip() {
567        let box1 = [0.0134, 0.02135, 0.12438, 0.691];
568        let xyah = xyxy_to_xyah(&box1);
569        let box2 = xyah_to_xyxy(&xyah);
570
571        assert!((box1[0] - box2[0]).abs() < f32::EPSILON);
572        assert!((box1[1] - box2[1]).abs() < f32::EPSILON);
573        assert!((box1[2] - box2[2]).abs() < f32::EPSILON);
574        assert!((box1[3] - box2[3]).abs() < f32::EPSILON);
575    }
576
577    #[test]
578    fn test_iou_identical_boxes() {
579        let box1 = [0.1, 0.1, 0.5, 0.5];
580        let box2 = [0.1, 0.1, 0.5, 0.5];
581        let result = iou(&box1, &box2);
582        assert!(
583            (result - 1.0).abs() < 0.001,
584            "IOU of identical boxes should be 1.0"
585        );
586    }
587
588    #[test]
589    fn test_iou_no_overlap() {
590        let box1 = [0.0, 0.0, 0.2, 0.2];
591        let box2 = [0.5, 0.5, 0.7, 0.7];
592        let result = iou(&box1, &box2);
593        assert!(result < 0.001, "IOU of non-overlapping boxes should be ~0");
594    }
595
596    #[test]
597    fn test_iou_partial_overlap() {
598        let box1 = [0.0, 0.0, 0.5, 0.5];
599        let box2 = [0.25, 0.25, 0.75, 0.75];
600        let result = iou(&box1, &box2);
601        // Intersection: 0.25*0.25 = 0.0625, Union: 0.25+0.25-0.0625 = 0.4375
602        assert!(result > 0.1 && result < 0.2, "IOU should be ~0.14");
603    }
604
605    #[test]
606    fn test_bytetrack_new() {
607        let tracker: ByteTrack<MockDetection> = ByteTrackBuilder::new().build();
608        assert_eq!(tracker.frame_count, 0);
609        assert!(tracker.tracklets.is_empty());
610        assert_eq!(tracker.track_high_conf, 0.7);
611        assert_eq!(tracker.track_iou, 0.25);
612    }
613
614    #[test]
615    fn test_bytetrack_single_detection_creates_tracklet() {
616        let mut tracker = ByteTrackBuilder::new().build();
617        let detections = vec![MockDetection::new([0.1, 0.1, 0.3, 0.3], 0.9, 0)];
618
619        let results = tracker.update(&detections, 1000);
620
621        assert_eq!(results.len(), 1);
622        assert!(
623            results[0].is_some(),
624            "High-confidence detection should create tracklet"
625        );
626        assert_eq!(tracker.tracklets.len(), 1);
627        assert_eq!(tracker.frame_count, 1);
628    }
629
630    #[test]
631    fn test_bytetrack_low_confidence_no_tracklet() {
632        let mut tracker = ByteTrackBuilder::new().build();
633        // Score below track_high_conf (0.7)
634        let detections = vec![MockDetection::new([0.1, 0.1, 0.3, 0.3], 0.5, 0)];
635
636        let results = tracker.update(&detections, 1000);
637
638        assert_eq!(results.len(), 1);
639        assert!(
640            results[0].is_none(),
641            "Low-confidence detection should not create tracklet"
642        );
643        assert!(tracker.tracklets.is_empty());
644    }
645
646    #[test]
647    fn test_bytetrack_tracking_across_frames() {
648        let mut tracker = ByteTrackBuilder::new().build();
649
650        // Frame 1: Create tracklet with a larger box that's easier to track
651        let det1 = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.9, 0)];
652        let res1 = tracker.update(&det1, 1000);
653        assert!(res1[0].is_some());
654        let uuid1 = res1[0].unwrap().uuid;
655        assert_eq!(tracker.tracklets.len(), 1);
656        // After creation, tracklet count is 1
657        assert_eq!(tracker.tracklets[0].count, 1);
658
659        // Frame 2: Same location - should match existing tracklet
660        let det2 = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.9, 0)];
661        let res2 = tracker.update(&det2, 2000);
662        assert!(res2[0].is_some());
663        let info2 = res2[0].unwrap();
664
665        // Verify tracklet was matched, not a new one created
666        assert_eq!(tracker.tracklets.len(), 1, "Should still have one tracklet");
667        assert_eq!(info2.uuid, uuid1, "Should match same tracklet");
668        // After second update, the internal tracklet count should be 2
669        assert_eq!(tracker.tracklets[0].count, 2, "Internal count should be 2");
670    }
671
672    #[test]
673    fn test_bytetrack_multiple_detections() {
674        let mut tracker = ByteTrackBuilder::new().build();
675
676        let detections = vec![
677            MockDetection::new([0.1, 0.1, 0.2, 0.2], 0.9, 0),
678            MockDetection::new([0.5, 0.5, 0.6, 0.6], 0.85, 0),
679            MockDetection::new([0.8, 0.8, 0.9, 0.9], 0.95, 0),
680        ];
681
682        let results = tracker.update(&detections, 1000);
683
684        assert_eq!(results.len(), 3);
685        assert!(results.iter().all(|r| r.is_some()));
686        assert_eq!(tracker.tracklets.len(), 3);
687    }
688
689    #[test]
690    fn test_bytetrack_tracklet_expiry() {
691        let mut tracker = ByteTrackBuilder::new().build();
692        tracker.track_extra_lifespan = 1000; // 1 second
693
694        // Create tracklet
695        let det1 = vec![MockDetection::new([0.1, 0.1, 0.3, 0.3], 0.9, 0)];
696        tracker.update(&det1, 1000);
697        assert_eq!(tracker.tracklets.len(), 1);
698
699        // Update with no detections after lifespan expires
700        let empty: Vec<MockDetection> = vec![];
701        tracker.update(&empty, 3000); // 2 seconds later
702
703        assert!(tracker.tracklets.is_empty(), "Tracklet should have expired");
704    }
705
706    #[test]
707    fn test_bytetrack_get_active_tracks() {
708        let mut tracker = ByteTrackBuilder::new().build();
709
710        let detections = vec![
711            MockDetection::new([0.1, 0.1, 0.2, 0.2], 0.9, 0),
712            MockDetection::new([0.5, 0.5, 0.6, 0.6], 0.85, 0),
713        ];
714        tracker.update(&detections, 1000);
715
716        let active = tracker.get_active_tracks();
717        assert_eq!(active.len(), 2);
718        assert!(active.iter().all(|t| t.info.count == 1));
719        assert!(active.iter().all(|t| t.info.created == 1000));
720    }
721
722    #[test]
723    fn test_bytetrack_empty_detections() {
724        let mut tracker = ByteTrackBuilder::new().build();
725        let empty: Vec<MockDetection> = vec![];
726
727        let results = tracker.update(&empty, 1000);
728
729        assert!(results.is_empty());
730        assert!(tracker.tracklets.is_empty());
731        assert_eq!(tracker.frame_count, 1);
732    }
733
734    #[test]
735    fn test_two_stage_matching() {
736        // The core ByteTrack innovation: low-confidence detections are matched
737        // to existing tracklets in a second stage.
738        let mut tracker = ByteTrackBuilder::new().build();
739
740        // Frame 1: high-confidence detection creates a tracklet
741        let det1 = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.9, 0)];
742        let res1 = tracker.update(&det1, 1_000_000);
743        assert!(res1[0].is_some());
744        let uuid1 = res1[0].unwrap().uuid;
745        assert_eq!(tracker.tracklets.len(), 1);
746
747        // Frame 2: same location but low confidence (0.3, below track_high_conf=0.7).
748        // Second-stage matching should still associate it with the existing tracklet.
749        let det2 = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.3, 0)];
750        let res2 = tracker.update(&det2, 2_000_000);
751        assert!(
752            res2[0].is_some(),
753            "Low-conf detection should match existing tracklet via second stage"
754        );
755        assert_eq!(
756            res2[0].unwrap().uuid,
757            uuid1,
758            "Should match the same tracklet"
759        );
760        assert_eq!(
761            tracker.tracklets.len(),
762            1,
763            "No new tracklet should be created"
764        );
765        assert_eq!(
766            tracker.tracklets[0].count, 2,
767            "Tracklet count should increment"
768        );
769    }
770
771    #[test]
772    fn test_builder_track_extra_lifespan() {
773        let lifespan_default = 500_000_000; // 0.5 seconds (default)
774        let lifespan_extended = 2_000_000_000; // 2 seconds
775
776        let mut tracker_default: ByteTrack<MockDetection> = ByteTrackBuilder::new().build();
777        let mut tracker_extended: ByteTrack<MockDetection> = ByteTrackBuilder::new()
778            .track_extra_lifespan(lifespan_extended)
779            .build();
780
781        assert_eq!(tracker_default.track_extra_lifespan, lifespan_default);
782        assert_eq!(tracker_extended.track_extra_lifespan, lifespan_extended);
783
784        let ts_start = 1_000_000_000u64; // 1 second
785        let det = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.9, 0)];
786
787        tracker_default.update(&det, ts_start);
788        tracker_extended.update(&det, ts_start);
789        assert_eq!(tracker_default.tracklets.len(), 1);
790        assert_eq!(tracker_extended.tracklets.len(), 1);
791
792        // Advance to 1s + 1s = 2s. Default lifespan (0.5s) should have expired,
793        // extended lifespan (2s) should still be active.
794        let ts_after = ts_start + 1_000_000_000;
795        let empty: Vec<MockDetection> = vec![];
796        tracker_default.update(&empty, ts_after);
797        tracker_extended.update(&empty, ts_after);
798
799        assert!(
800            tracker_default.tracklets.is_empty(),
801            "Default tracker should have expired the tracklet"
802        );
803        assert_eq!(
804            tracker_extended.tracklets.len(),
805            1,
806            "Extended tracker should still have the tracklet"
807        );
808    }
809
810    #[test]
811    fn test_builder_track_high_conf() {
812        let mut tracker: ByteTrack<MockDetection> =
813            ByteTrackBuilder::new().track_high_conf(0.9).build();
814        assert_eq!(tracker.track_high_conf, 0.9);
815
816        // Detection with score 0.8 is below the 0.9 threshold
817        let det_low = vec![MockDetection::new([0.1, 0.1, 0.3, 0.3], 0.8, 0)];
818        let res = tracker.update(&det_low, 1000);
819        assert!(
820            res[0].is_none(),
821            "Score 0.8 should not create a tracklet with threshold 0.9"
822        );
823        assert!(tracker.tracklets.is_empty());
824
825        // Detection with score 0.95 is above the 0.9 threshold
826        let det_high = vec![MockDetection::new([0.1, 0.1, 0.3, 0.3], 0.95, 0)];
827        let res = tracker.update(&det_high, 2000);
828        assert!(
829            res[0].is_some(),
830            "Score 0.95 should create a tracklet with threshold 0.9"
831        );
832        assert_eq!(tracker.tracklets.len(), 1);
833    }
834
835    #[test]
836    fn test_builder_track_iou() {
837        // Tight IOU threshold: shifted detection should NOT match
838        let mut tracker: ByteTrack<MockDetection> = ByteTrackBuilder::new().track_iou(0.8).build();
839
840        // Frame 1: two well-separated detections
841        let det1 = vec![
842            MockDetection::new([0.1, 0.1, 0.3, 0.3], 0.9, 0),
843            MockDetection::new([0.5, 0.5, 0.7, 0.7], 0.9, 0),
844        ];
845        tracker.update(&det1, 1000);
846        assert_eq!(tracker.tracklets.len(), 2);
847
848        // Frame 2: shift the first detection slightly. With IOU threshold 0.8
849        // the overlap won't be enough for a match, so it creates a new tracklet.
850        let det2 = vec![
851            MockDetection::new([0.15, 0.15, 0.35, 0.35], 0.9, 0),
852            MockDetection::new([0.5, 0.5, 0.7, 0.7], 0.9, 0),
853        ];
854        let res2 = tracker.update(&det2, 2000);
855        assert_eq!(res2.len(), 2);
856
857        // The second detection (unchanged) should still match. The first (shifted)
858        // should fail the tight IOU threshold and create a new tracklet.
859        assert!(
860            tracker.tracklets.len() >= 3,
861            "Shifted detection should create a new tracklet with tight IOU threshold, got {} tracklets",
862            tracker.tracklets.len()
863        );
864    }
865
866    #[test]
867    fn test_degenerate_zero_area_box() {
868        // A zero-area box (xmin == xmax) should not panic
869        let mut tracker = ByteTrackBuilder::new().build();
870        let det = vec![
871            MockDetection::new([0.5, 0.1, 0.5, 0.3], 0.9, 0), // zero width
872            MockDetection::new([0.1, 0.1, 0.3, 0.3], 0.9, 0), // normal box
873        ];
874        let results = tracker.update(&det, 1000);
875        assert_eq!(results.len(), 2);
876
877        // IOU between a zero-area box and a normal box should be 0
878        let zero_box = [0.5, 0.1, 0.5, 0.3];
879        let normal_box = [0.1, 0.1, 0.3, 0.3];
880        let iou_val = iou(&zero_box, &normal_box);
881        assert!(
882            iou_val < EPSILON,
883            "IOU with a zero-area box should be ~0, got {iou_val}"
884        );
885    }
886
887    #[test]
888    fn test_degenerate_high_velocity() {
889        let mut tracker = ByteTrackBuilder::new().build();
890
891        // Frame 1: detection at top-left
892        let det1 = vec![MockDetection::new([0.1, 0.1, 0.2, 0.2], 0.9, 0)];
893        let res1 = tracker.update(&det1, 1_000_000);
894        assert!(res1[0].is_some());
895        let uuid1 = res1[0].unwrap().uuid;
896        assert_eq!(tracker.tracklets.len(), 1);
897
898        // Frame 2: detection at bottom-right (huge displacement)
899        let det2 = vec![MockDetection::new([0.8, 0.8, 0.9, 0.9], 0.9, 0)];
900        let res2 = tracker.update(&det2, 2_000_000);
901        assert!(res2[0].is_some());
902
903        // With default IOU threshold the far-away detection should not match;
904        // a new tracklet is created instead.
905        assert_eq!(
906            tracker.tracklets.len(),
907            2,
908            "Far-displaced detection should create a new tracklet"
909        );
910        assert_ne!(
911            res2[0].unwrap().uuid,
912            uuid1,
913            "New detection should have a different UUID"
914        );
915    }
916
917    #[test]
918    fn test_many_detections_100() {
919        let mut tracker = ByteTrackBuilder::new().build();
920
921        // Generate 100 non-overlapping small boxes spread across [0, 1]
922        let detections: Vec<MockDetection> = (0..100)
923            .map(|i| {
924                let x = (i % 10) as f32 * 0.1;
925                let y = (i / 10) as f32 * 0.1;
926                MockDetection::new([x, y, x + 0.05, y + 0.05], 0.9, 0)
927            })
928            .collect();
929
930        let results = tracker.update(&detections, 1000);
931        assert_eq!(results.len(), 100);
932        assert!(
933            results.iter().all(|r| r.is_some()),
934            "All 100 high-confidence detections should create tracklets"
935        );
936        assert_eq!(
937            tracker.tracklets.len(),
938            100,
939            "Should have 100 active tracklets"
940        );
941    }
942
943    #[test]
944    fn test_tracklet_count_increments_each_frame() {
945        let mut tracker = ByteTrackBuilder::new().build();
946        let det = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.9, 0)];
947
948        for frame in 1..=5 {
949            tracker.update(&det, frame * 1000);
950        }
951
952        assert_eq!(tracker.tracklets.len(), 1);
953        assert_eq!(
954            tracker.tracklets[0].count, 5,
955            "Tracklet count should equal number of frames it was matched"
956        );
957    }
958
959    #[test]
960    fn test_tracklet_created_timestamp_preserved() {
961        let mut tracker = ByteTrackBuilder::new().build();
962        let det = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.9, 0)];
963
964        tracker.update(&det, 1000);
965        tracker.update(&det, 2000);
966        tracker.update(&det, 3000);
967
968        let active = tracker.get_active_tracks();
969        assert_eq!(active.len(), 1);
970        assert_eq!(
971            active[0].info.created, 1000,
972            "Created timestamp should remain at the first frame"
973        );
974        assert_eq!(
975            active[0].info.last_updated, 3000,
976            "Last updated should be the most recent frame"
977        );
978    }
979
980    #[test]
981    fn test_mixed_confidence_detections() {
982        // Mix of high and low confidence detections in a single frame
983        let mut tracker = ByteTrackBuilder::new().build();
984        let det = vec![
985            MockDetection::new([0.1, 0.1, 0.2, 0.2], 0.9, 0), // high
986            MockDetection::new([0.3, 0.3, 0.4, 0.4], 0.3, 0), // low
987            MockDetection::new([0.5, 0.5, 0.6, 0.6], 0.85, 0), // high
988            MockDetection::new([0.7, 0.7, 0.8, 0.8], 0.1, 0), // low
989        ];
990
991        let results = tracker.update(&det, 1000);
992        assert_eq!(results.len(), 4);
993
994        // Only the high-confidence ones should create tracklets
995        assert!(
996            results[0].is_some(),
997            "High-conf detection should create tracklet"
998        );
999        assert!(
1000            results[1].is_none(),
1001            "Low-conf detection should not create tracklet"
1002        );
1003        assert!(
1004            results[2].is_some(),
1005            "High-conf detection should create tracklet"
1006        );
1007        assert!(
1008            results[3].is_none(),
1009            "Low-conf detection should not create tracklet"
1010        );
1011        assert_eq!(tracker.tracklets.len(), 2);
1012    }
1013
1014    #[test]
1015    fn test_iou_contained_box() {
1016        // One box fully contains the other
1017        let outer = [0.0, 0.0, 1.0, 1.0];
1018        let inner = [0.25, 0.25, 0.75, 0.75];
1019        let result = iou(&outer, &inner);
1020        // inner area = 0.25, outer area = 1.0, intersection = 0.25, union = 1.0
1021        assert!(
1022            (result - 0.25).abs() < 0.01,
1023            "IOU of contained box should be inner_area/outer_area = 0.25, got {result}"
1024        );
1025    }
1026
1027    #[test]
1028    fn test_xyxy_to_xyah_square_box() {
1029        // A square box should have aspect ratio 1.0
1030        let square = [0.1, 0.2, 0.3, 0.4];
1031        let xyah = xyxy_to_xyah(&square);
1032        assert!((xyah[0] - 0.2).abs() < 1e-5, "Center x should be 0.2");
1033        assert!((xyah[1] - 0.3).abs() < 1e-5, "Center y should be 0.3");
1034        assert!(
1035            (xyah[2] - 1.0).abs() < 1e-5,
1036            "Aspect ratio of square should be 1.0"
1037        );
1038        assert!((xyah[3] - 0.2).abs() < 1e-5, "Height should be 0.2");
1039    }
1040
1041    #[test]
1042    fn test_frame_count_increments() {
1043        let mut tracker = ByteTrackBuilder::new().build();
1044        let empty: Vec<MockDetection> = vec![];
1045
1046        for _ in 0..10 {
1047            tracker.update(&empty, 0);
1048        }
1049
1050        assert_eq!(
1051            tracker.frame_count, 10,
1052            "Frame count should increment each update"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_tracklet_predicted_location_near_detection() {
1058        let mut tracker = ByteTrackBuilder::new().build();
1059        let det = vec![MockDetection::new([0.2, 0.2, 0.4, 0.4], 0.9, 0)];
1060        tracker.update(&det, 1000);
1061
1062        let pred = tracker.tracklets[0].get_predicted_location();
1063        // The predicted location should be close to the original detection
1064        assert!(
1065            (pred[0] - 0.2).abs() < 0.1,
1066            "Predicted xmin should be near 0.2, got {}",
1067            pred[0]
1068        );
1069        assert!(
1070            (pred[1] - 0.2).abs() < 0.1,
1071            "Predicted ymin should be near 0.2, got {}",
1072            pred[1]
1073        );
1074        assert!(
1075            (pred[2] - 0.4).abs() < 0.1,
1076            "Predicted xmax should be near 0.4, got {}",
1077            pred[2]
1078        );
1079        assert!(
1080            (pred[3] - 0.4).abs() < 0.1,
1081            "Predicted ymax should be near 0.4, got {}",
1082            pred[3]
1083        );
1084    }
1085}