Skip to main content

edgefirst_tracker/
kalman.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use nalgebra::{
5    allocator::Allocator, convert, dimension::U4, DVector, DefaultAllocator, Dyn, OMatrix,
6    RealField, SVector, U1, U8,
7};
8
9/// Constant-velocity Kalman filter for tracking a bounding box in XYAH space.
10///
11/// The state vector is `[x, y, a, h, ẋ, ẏ, ȧ, ḣ]` (8 dimensions):
12///
13/// | Component | Meaning |
14/// |-----------|---------|
15/// | `x` | Centre x coordinate |
16/// | `y` | Centre y coordinate |
17/// | `a` | Aspect ratio (`width / height`), kept > 0 by a small epsilon floor |
18/// | `h` | Height |
19/// | `ẋ, ẏ, ȧ, ḣ` | First derivatives of the position components |
20///
21/// Measurements arriving via [`update`](Self::update) are in 4-dimensional
22/// XYAH space (no velocity), projected from the 8-D state by the observation
23/// matrix `H`.  The filter uses a scale-adaptive process-noise covariance:
24/// noise magnitude scales proportionally with the box height `h`, so
25/// fast-moving large objects tolerate wider uncertainty than small static ones.
26///
27/// This is the same Kalman filter parameterisation used in the original
28/// ByteTrack paper (Zhang et al., 2022).
29#[derive(Debug, Clone)]
30pub struct ConstantVelocityXYAHModel2<R>
31where
32    R: RealField,
33    DefaultAllocator: Allocator<U8, U8>,
34    DefaultAllocator: Allocator<U8>,
35{
36    /// Current state estimate: `[x, y, a, h, ẋ, ẏ, ȧ, ḣ]`.
37    pub mean: SVector<R, 8>,
38
39    /// Scale factor for position noise (`1/20` by default). Process noise for
40    /// position components is `std_weight_position * h`.
41    pub std_weight_position: R,
42
43    /// Scale factor for velocity noise (`1/160` by default). Process noise for
44    /// velocity components is `std_weight_velocity * h`.
45    pub std_weight_velocity: R,
46
47    /// Measurement-to-innovation scaling applied in [`update`](Self::update).
48    /// Set via [`ByteTrackBuilder::track_update`](crate::bytetrack::ByteTrackBuilder::track_update).
49    /// Values in `(0, 1]`; lower → smoother (trusts prediction more).
50    pub update_factor: R,
51
52    /// State-transition matrix `F` (identity + velocity block). Projects the
53    /// state forward by one time step.
54    motion_matrix: OMatrix<R, U8, U8>,
55
56    /// Observation matrix `H`. Extracts the `[x, y, a, h]` slice from the
57    /// 8-D state for comparison against incoming measurements.
58    update_matrix: OMatrix<R, U4, U8>,
59
60    /// Current state covariance matrix `P` (8×8). Grows on each
61    /// [`predict`](Self::predict) and shrinks on each [`update`](Self::update).
62    pub covariance: OMatrix<R, U8, U8>,
63}
64
65/// Distance metric used by [`ConstantVelocityXYAHModel2::gating_distance`].
66///
67/// `Mahalanobis` accounts for the current state uncertainty and is the
68/// theoretically correct gating criterion; `Gaussian` (squared Euclidean) is
69/// faster but scale-dependent.  In the current ByteTrack integration only the
70/// IoU-based cost matrix is used for assignment; this enum is available for
71/// future association strategies that want a filter-based gate.
72#[allow(dead_code)]
73pub enum GatingDistanceMetric {
74    /// Squared Euclidean distance (no covariance weighting).
75    Gaussian,
76    /// Mahalanobis distance — normalised by the projected state covariance.
77    Mahalanobis,
78}
79
80impl<R> ConstantVelocityXYAHModel2<R>
81where
82    R: RealField + Copy,
83{
84    /// Initialise the filter from the first observation.
85    ///
86    /// `measurement` is `[x, y, a, h]` in XYAH space.  Velocity components are
87    /// initialised to zero.  The initial covariance is set proportionally to
88    /// the box height so larger boxes start with wider uncertainty.
89    ///
90    /// `update_factor` is stored and applied as a measurement gain in
91    /// [`update`](Self::update).
92    pub fn new(measurement: &[R; 4], update_factor: R) -> Self {
93        let ndim = 4;
94        let dt: R = convert(1.0);
95
96        let mut motion_matrix = OMatrix::<R, U8, U8>::identity();
97        for i in 0..ndim {
98            motion_matrix[(i, ndim + i)] = dt * convert(3.0);
99        }
100        let mut update_matrix = OMatrix::<R, U4, U8>::identity();
101        for i in 0..ndim {
102            update_matrix[(i, ndim + i)] = dt * convert(1.0);
103        }
104        let zero: R = convert(0.0);
105        let two: R = convert(2.0);
106        let ten: R = convert(10.0);
107        let height = measurement[3];
108
109        let mean = SVector::<R, 8>::from_row_slice(&[
110            measurement[0],
111            measurement[1],
112            measurement[2],
113            measurement[3],
114            zero,
115            zero,
116            zero,
117            zero,
118        ]);
119        let std_weight_position = convert(1.0 / 20.0);
120        let std_weight_velocity = convert(1.0 / 160.0);
121        let diag = [
122            two * std_weight_position * height,
123            two * std_weight_position * height,
124            convert(0.01),
125            two * std_weight_position * height,
126            ten * std_weight_velocity * height,
127            ten * std_weight_velocity * height,
128            convert(0.00001),
129            ten * std_weight_velocity * height,
130        ];
131        let diag = SVector::<R, 8>::from_row_slice(&diag);
132
133        let covariance = OMatrix::<R, U8, U8>::from_diagonal(&diag.component_mul(&diag));
134        Self {
135            motion_matrix,
136            update_matrix,
137            mean,
138            covariance,
139            std_weight_position,
140            std_weight_velocity,
141            update_factor,
142        }
143    }
144
145    /// Advance the filter by one time step with no measurement.
146    ///
147    /// Applies the constant-velocity motion model (`mean' = F * mean`,
148    /// `cov' = F * cov * Fᵀ + Q`) using a scale-adaptive process-noise
149    /// covariance `Q` proportional to the current box height.  Called once
150    /// per frame for every active tracklet before the assignment step.
151    pub fn predict(&mut self) {
152        let height = self.mean[3];
153        let diag = [
154            self.std_weight_position * height,
155            self.std_weight_position * height,
156            convert(0.01),
157            self.std_weight_position * height,
158            self.std_weight_velocity * height,
159            self.std_weight_velocity * height,
160            convert(0.00001),
161            self.std_weight_velocity * height,
162        ];
163        let diag = SVector::<R, 8>::from_row_slice(&diag);
164        let motion_cov = OMatrix::<R, U8, U8>::from_diagonal(&diag.component_mul(&diag));
165
166        let mean = (self.mean.transpose() * self.motion_matrix.transpose()).transpose();
167        let covariance =
168            self.motion_matrix * self.covariance * self.motion_matrix.transpose() + motion_cov;
169        self.mean = mean;
170        self.covariance = covariance;
171    }
172
173    /// Project the 8-D state into 4-D measurement space.
174    ///
175    /// Returns `(projected_mean, projected_covariance)` — the expected
176    /// measurement and its uncertainty under the current state estimate.
177    /// Used by [`update`](Self::update) and by
178    /// [`gating_distance`](Self::gating_distance).
179    pub fn project(&self) -> (OMatrix<R, U4, U1>, OMatrix<R, U4, U4>) {
180        let height = self.mean[3];
181        let diag = [
182            self.std_weight_position * height,
183            self.std_weight_position * height,
184            convert(0.01),
185            self.std_weight_position * height,
186        ];
187        let diag = SVector::<R, 4>::from_row_slice(&diag);
188        let innovation_cov = OMatrix::<R, U4, U4>::from_diagonal(&diag.component_mul(&diag));
189        let mean = self.update_matrix * self.mean;
190        let covariance =
191            self.update_matrix * self.covariance * self.update_matrix.transpose() + innovation_cov;
192        (mean, covariance)
193    }
194
195    /// Correct the state estimate with a new measurement.
196    ///
197    /// `measurement` is `[x, y, a, h]` in XYAH space, as produced by
198    /// [`xyxy_to_xyah`](super::bytetrack) in the ByteTrack update loop.
199    ///
200    /// The Kalman gain is computed via Cholesky decomposition of the projected
201    /// covariance.  If the decomposition fails (degenerate covariance) the
202    /// update is silently skipped and the prior state is preserved.
203    ///
204    /// The innovation is scaled by `self.update_factor` before being applied,
205    /// which effectively attenuates the correction — lower values produce
206    /// smoother trajectories at the cost of slower response to abrupt motion.
207    pub fn update(&mut self, measurement: &[R; 4]) {
208        let measurement = SVector::<R, 4>::from_row_slice(&[
209            measurement[0],
210            measurement[1],
211            measurement[2],
212            measurement[3],
213        ]);
214
215        let (projected_mean, projected_cov) = self.project();
216        let cho_factor = match projected_cov.cholesky() {
217            None => return,
218            Some(v) => v,
219        };
220        let kalman_gain = cho_factor
221            .solve(&(self.covariance * self.update_matrix.transpose()).transpose())
222            .transpose();
223
224        let innovation = (measurement - projected_mean).scale(self.update_factor);
225        // println!("kalman_gain={}", kalman_gain);
226        // println!("innovation={}", innovation);
227        let diff = innovation.transpose() * kalman_gain.transpose();
228        self.mean += diff.transpose();
229        self.covariance -= kalman_gain * projected_cov * kalman_gain.transpose();
230        // let new_mean = self.mean + diff.transpose();
231        // let new_cov = self.covariance - kalman_gain * projected_cov *
232        // kalman_gain.transpose();
233    }
234
235    /// Compute per-measurement gating distances from the current state.
236    ///
237    /// Returns a vector of one scalar per row in `measurements`.  Each scalar
238    /// is either the squared Euclidean distance ([`GatingDistanceMetric::Gaussian`])
239    /// or the Mahalanobis distance ([`GatingDistanceMetric::Mahalanobis`]) from
240    /// the projected state mean to that measurement.
241    ///
242    /// `only_position` restricts the comparison to the first two components
243    /// (`x`, `y`), ignoring aspect and height.
244    ///
245    /// Not used by the current ByteTrack integration (which gates via IoU);
246    /// exposed for future association strategies.
247    #[allow(dead_code)]
248    pub fn gating_distance(
249        &self,
250        measurements: &OMatrix<R, Dyn, U4>,
251        only_position: bool,
252        metric: GatingDistanceMetric,
253    ) -> DVector<R> {
254        let (m, cov) = self.project();
255        let ndims = if only_position { 2 } else { 4 };
256        let mean = m.transpose();
257        let mean = mean.columns_range(0..ndims);
258        let covariance = cov.view_range(0..ndims, 0..ndims);
259        let measurements = measurements.columns_range(0..ndims);
260        // let _ = only_position;
261        // let mean = m.transpose();
262        // let covariance = cov;
263        // let measurements = measurements;
264
265        let mut mean_broadcast =
266            OMatrix::<R, Dyn, U4>::from_element(measurements.shape().0, convert(0.0));
267        for mut col in mean_broadcast.row_iter_mut() {
268            col.copy_from(&mean);
269        }
270        let d = measurements - mean_broadcast;
271        match metric {
272            GatingDistanceMetric::Gaussian => d.component_mul(&d).column_sum(),
273            GatingDistanceMetric::Mahalanobis => {
274                let cho_factor = match covariance.cholesky() {
275                    None => return DVector::<R>::zeros(measurements.shape().0),
276                    Some(v) => v,
277                };
278                let z = cho_factor.solve(&d.transpose());
279                z.component_mul(&z).row_sum_tr()
280            }
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use nalgebra::{Dyn, OMatrix, U4};
288
289    use super::{ConstantVelocityXYAHModel2, GatingDistanceMetric};
290    #[test]
291    fn filter() {
292        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
293        t.predict();
294        println!("1. t.mean={}", t.mean);
295        t.update(&[0.4, 0.5, 1.0, 0.5]);
296        t.predict();
297        println!("2. t.mean={}", t.mean);
298        t.update(&[0.3, 0.5, 1.0, 0.5]);
299        t.predict();
300        println!("3. t.mean={}", t.mean);
301        t.update(&[0.2, 0.5, 1.0, 0.5]);
302        t.predict();
303        println!("4. t.mean={}", t.mean);
304        t.update(&[0.2, 0.5, 1.0, 0.5]);
305        t.predict();
306        println!("5. t.mean={}", t.mean);
307        t.update(&[0.3, 0.5, 1.0, 0.5]);
308        t.predict();
309        println!("6. t.mean={}", t.mean);
310        t.update(&[0.4, 0.5, 1.0, 0.5]);
311    }
312
313    #[test]
314    fn gating() {
315        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
316        t.predict();
317        t.update(&[0.49, 0.5, 1.0, 0.5]);
318        t.predict();
319        t.update(&[0.48, 0.5, 1.0, 0.5]);
320        t.predict();
321        t.update(&[0.47, 0.5, 1.0, 0.5]);
322        t.predict();
323        t.update(&[0.46, 0.5, 1.0, 0.5]);
324        t.predict();
325        t.update(&[0.45, 0.5, 1.0, 0.5]);
326        t.predict();
327        t.update(&[0.44, 0.5, 1.0, 0.5]);
328        t.predict();
329        t.update(&[0.43, 0.5, 1.0, 0.5]);
330        t.predict();
331        t.update(&[0.42, 0.5, 1.0, 0.5]);
332        t.predict();
333
334        // distances range from 0 to 1e6 for maha
335        let mut measurements = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
336        measurements.copy_from_slice(&[0.3, 0.5, 1.0, 0.5]);
337
338        let mut distances = OMatrix::<f32, Dyn, Dyn>::from_element(1, 1, 0.0);
339        for mut column in distances.column_iter_mut() {
340            let dist = t.gating_distance(&measurements, false, GatingDistanceMetric::Gaussian);
341            column.copy_from(&dist);
342        }
343        let dist = t.gating_distance(&measurements, false, GatingDistanceMetric::Mahalanobis);
344        println!("Dist(false, maha): {dist}");
345
346        let dist = t.gating_distance(&measurements, false, GatingDistanceMetric::Gaussian);
347        println!("Dist(false, gaussian): {dist}");
348    }
349
350    #[test]
351    fn test_predict_constant_velocity() {
352        // Initialize filter and give it a few updates to establish velocity,
353        // then verify predictions drift in the expected direction.
354        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 0.1, 2.0], 0.25);
355        t.predict();
356        t.update(&[0.5, 0.5, 0.1, 2.0]);
357
358        // Record position after first predict-update cycle
359        let x_before: f32 = t.mean[0];
360        let y_before: f32 = t.mean[1];
361
362        // Run several predict-only cycles to let velocity dominate
363        for _ in 0..5 {
364            t.predict();
365        }
366
367        let x_after: f32 = t.mean[0];
368        let y_after: f32 = t.mean[1];
369        let h_after: f32 = t.mean[3];
370
371        // The position should remain numerically reasonable (no NaN/Inf)
372        assert!(x_after.is_finite(), "x should be finite after predictions");
373        assert!(y_after.is_finite(), "y should be finite after predictions");
374        assert!(
375            h_after.is_finite(),
376            "height should be finite after predictions"
377        );
378
379        // With near-zero velocity the predicted position should not explode
380        assert!(
381            (x_after - x_before).abs() < 5.0,
382            "x drift should be bounded, got delta={}",
383            (x_after - x_before).abs()
384        );
385        assert!(
386            (y_after - y_before).abs() < 5.0,
387            "y drift should be bounded, got delta={}",
388            (y_after - y_before).abs()
389        );
390    }
391
392    #[test]
393    fn test_numerical_stability_1000_cycles() {
394        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
395
396        // Run 1000 predict-only cycles without any update
397        for _ in 0..1000 {
398            t.predict();
399        }
400
401        // Verify no NaN or Inf in the mean vector
402        for i in 0..8 {
403            let val: f32 = t.mean[i];
404            assert!(
405                val.is_finite(),
406                "mean[{i}] should be finite after 1000 predictions, got {val}",
407            );
408        }
409
410        // Verify no NaN or Inf in the covariance matrix
411        for r in 0..8 {
412            for c in 0..8 {
413                let val: f32 = t.covariance[(r, c)];
414                assert!(
415                    val.is_finite(),
416                    "covariance[({r},{c})] should be finite after 1000 predictions, got {val}",
417                );
418            }
419        }
420    }
421
422    #[test]
423    fn test_gating_distance_edge_cases() {
424        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
425        // Run a few predict-update cycles to stabilize
426        for _ in 0..3 {
427            t.predict();
428            t.update(&[0.5, 0.5, 1.0, 0.5]);
429        }
430        t.predict();
431
432        // Measurement exactly at the predicted state -- distance should be near 0
433        let (projected_mean, _) = t.project();
434        let mut meas_close = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
435        meas_close
436            .row_mut(0)
437            .copy_from_slice(projected_mean.as_slice());
438
439        let dist_close = t.gating_distance(&meas_close, false, GatingDistanceMetric::Mahalanobis);
440        assert!(
441            dist_close[0].is_finite(),
442            "Close-measurement distance should be finite"
443        );
444        assert!(
445            dist_close[0] < 1.0,
446            "Distance for exact-match measurement should be near 0, got {}",
447            dist_close[0]
448        );
449
450        // Measurement far away -- distance should be large
451        let mut meas_far = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
452        meas_far.copy_from_slice(&[10.0, 10.0, 5.0, 10.0]);
453
454        let dist_far = t.gating_distance(&meas_far, false, GatingDistanceMetric::Mahalanobis);
455        assert!(
456            dist_far[0].is_finite(),
457            "Far-measurement distance should be finite"
458        );
459        assert!(
460            dist_far[0] > dist_close[0],
461            "Far measurement should have larger distance than close one: {} vs {}",
462            dist_far[0],
463            dist_close[0]
464        );
465    }
466
467    #[test]
468    fn test_update_moves_mean_toward_measurement() {
469        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
470        t.predict();
471
472        let x_before: f32 = t.mean[0];
473        // Update with a measurement shifted to the right
474        t.update(&[0.6, 0.5, 1.0, 0.5]);
475        let x_after: f32 = t.mean[0];
476
477        assert!(
478            x_after > x_before,
479            "Mean x should move toward the measurement (0.6), was {x_before}, now {x_after}"
480        );
481        assert!(
482            x_after <= 0.6,
483            "Mean x should not overshoot the measurement, got {x_after}"
484        );
485    }
486
487    #[test]
488    fn test_covariance_positive_diagonal() {
489        let t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
490
491        // All diagonal elements of the covariance should be positive
492        for i in 0..8 {
493            let val: f32 = t.covariance[(i, i)];
494            assert!(
495                val > 0.0,
496                "Covariance diagonal[{i}] should be positive, got {val}"
497            );
498        }
499    }
500
501    #[test]
502    fn test_predict_increases_uncertainty() {
503        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
504
505        let cov_before: f32 = t.covariance[(0, 0)];
506        t.predict();
507        let cov_after: f32 = t.covariance[(0, 0)];
508
509        assert!(
510            cov_after > cov_before,
511            "Predict should increase position uncertainty: {cov_before} -> {cov_after}"
512        );
513    }
514
515    #[test]
516    fn test_update_decreases_uncertainty() {
517        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
518        t.predict();
519
520        let cov_before: f32 = t.covariance[(0, 0)];
521        t.update(&[0.5, 0.5, 1.0, 0.5]);
522        let cov_after: f32 = t.covariance[(0, 0)];
523
524        assert!(
525            cov_after < cov_before,
526            "Update should decrease position uncertainty: {cov_before} -> {cov_after}"
527        );
528    }
529
530    #[test]
531    fn test_gating_distance_gaussian_vs_mahalanobis() {
532        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
533        for _ in 0..3 {
534            t.predict();
535            t.update(&[0.5, 0.5, 1.0, 0.5]);
536        }
537        t.predict();
538
539        let mut measurements = OMatrix::<f32, Dyn, U4>::from_element(1, 0.0);
540        measurements.copy_from_slice(&[0.6, 0.5, 1.0, 0.5]);
541
542        let dist_gauss = t.gating_distance(&measurements, false, GatingDistanceMetric::Gaussian);
543        let dist_maha = t.gating_distance(&measurements, false, GatingDistanceMetric::Mahalanobis);
544
545        assert!(dist_gauss[0].is_finite());
546        assert!(dist_maha[0].is_finite());
547
548        // Both should be non-negative for a non-zero offset
549        assert!(
550            dist_gauss[0] > 0.0,
551            "Gaussian distance should be > 0 for offset measurement"
552        );
553        assert!(
554            dist_maha[0] > 0.0,
555            "Mahalanobis distance should be > 0 for offset measurement"
556        );
557    }
558
559    #[test]
560    fn test_gating_distance_multiple_measurements() {
561        let mut t = ConstantVelocityXYAHModel2::new(&[0.5, 0.5, 1.0, 0.5], 0.25);
562        t.predict();
563        t.update(&[0.5, 0.5, 1.0, 0.5]);
564        t.predict();
565
566        // Two measurements: one close, one far
567        let mut measurements = OMatrix::<f32, Dyn, U4>::from_element(2, 0.0);
568        measurements
569            .row_mut(0)
570            .copy_from_slice(&[0.5, 0.5, 1.0, 0.5]); // close
571        measurements
572            .row_mut(1)
573            .copy_from_slice(&[5.0, 5.0, 1.0, 0.5]); // far
574
575        let dists = t.gating_distance(&measurements, false, GatingDistanceMetric::Mahalanobis);
576        assert_eq!(dists.len(), 2, "Should return one distance per measurement");
577        assert!(dists[0].is_finite());
578        assert!(dists[1].is_finite());
579        assert!(
580            dists[1] > dists[0],
581            "Far measurement should have larger distance: {} vs {}",
582            dists[1],
583            dists[0]
584        );
585    }
586
587    #[test]
588    fn test_initiate_mean_matches_measurement() {
589        let measurement = [0.3, 0.7, 1.5, 2.0];
590        let t = ConstantVelocityXYAHModel2::new(&measurement, 0.25);
591
592        // Position portion of mean should match the measurement exactly
593        let x: f32 = t.mean[0];
594        let y: f32 = t.mean[1];
595        let a: f32 = t.mean[2];
596        let h: f32 = t.mean[3];
597        assert!((x - 0.3).abs() < 1e-6, "Mean x should be 0.3, got {x}");
598        assert!((y - 0.7).abs() < 1e-6, "Mean y should be 0.7, got {y}");
599        assert!((a - 1.5).abs() < 1e-6, "Mean a should be 1.5, got {a}");
600        assert!((h - 2.0).abs() < 1e-6, "Mean h should be 2.0, got {h}");
601
602        // Velocity portion should be zero
603        for i in 4..8 {
604            let v: f32 = t.mean[i];
605            assert!((v).abs() < 1e-6, "Velocity mean[{i}] should be 0, got {v}");
606        }
607    }
608}