Skip to main content

edgefirst_tracker/
bytetrack.rs

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