Skip to main content

iris/tracking/
mod.rs

1pub mod subtractor;
2
3pub use subtractor::BackgroundSubtractor;
4
5use crate::core::types::Rect;
6use crate::error::{IrisError, Result};
7use crate::image::Image;
8use burn::tensor::backend::Backend;
9
10/// Standard tracker algorithms.
11pub enum TrackerType {
12    KCF,
13    CSRT,
14    MOSSE,
15}
16
17/// MOSSE (Minimum Output Sum of Squared Errors) tracker.
18///
19/// MOSSE is a correlation filter-based tracker that adapts to appearance changes.
20/// It uses element-wise multiplication in the frequency domain for fast correlation.
21struct MosseState {
22    /// Pre-trained filter in frequency domain
23    filter_freq: Vec<f64>,
24    /// Learning rate for online adaptation
25    learning_rate: f64,
26    /// Target size (height, width)
27    target_size: (usize, usize),
28    /// Previous frame grayscale data
29    prev_gray: Option<Vec<f32>>,
30    /// Previous bounding box
31    prev_bbox: Option<Rect<usize>>,
32    /// Width of search window
33    search_w: usize,
34    /// Height of search window
35    search_h: usize,
36}
37
38impl MosseState {
39    fn new() -> Self {
40        Self {
41            filter_freq: Vec::new(),
42            learning_rate: 0.125,
43            target_size: (0, 0),
44            prev_gray: None,
45            prev_bbox: None,
46            search_w: 0,
47            search_h: 0,
48        }
49    }
50
51    fn init(&mut self, gray_data: &[f32], img_w: usize, bbox: Rect<usize>) {
52        let cy = bbox.y + bbox.height / 2;
53        let cx = bbox.x + bbox.width / 2;
54        let patch_h = bbox.height.max(4);
55        let patch_w = bbox.width.max(4);
56
57        // Extract and resize patch to fixed size (use bbox size)
58        let size = patch_h.max(patch_w);
59        self.target_size = (size, size);
60        self.search_w = size * 2;
61        self.search_h = size * 2;
62
63        // Extract patch centered on bbox
64        let patch = extract_patch(gray_data, img_w, img_w, cx, cy, size, size);
65
66        // Compute FFT of patch (simplified - use DFT)
67        let patch_freq = simple_dft_2d(&patch, size, size);
68
69        // Create desired output (Gaussian centered at target)
70        let mut target = vec![0.0f64; size * size];
71        let center = size as f64 / 2.0;
72        let sigma = size as f64 / 4.0;
73        for y in 0..size {
74            for x in 0..size {
75                let dx = x as f64 - center;
76                let dy = y as f64 - center;
77                target[y * size + x] = (-(dx * dx + dy * dy) / (2.0 * sigma * sigma)).exp();
78            }
79        }
80        let target_freq = simple_dft_2d(&target, size, size);
81
82        // Initialize filter: H = G / (A + epsilon) where A accumulates
83        let mut filter = Vec::with_capacity(size * size);
84        let epsilon = 1e-4;
85        for i in 0..size * size {
86            let a_re = patch_freq[i * 2];
87            let a_im = patch_freq[i * 2 + 1];
88            let g_re = target_freq[i * 2];
89            let g_im = target_freq[i * 2 + 1];
90            let denom = a_re * a_re + a_im * a_im + epsilon;
91            filter.push((g_re * a_re + g_im * a_im) / denom);
92            filter.push((g_im * a_re - g_re * a_im) / denom);
93        }
94
95        self.filter_freq = filter;
96        self.prev_gray = Some(gray_data.to_vec());
97        self.prev_bbox = Some(bbox);
98    }
99
100    fn update(&mut self, gray_data: &[f32], img_w: usize) -> Option<Rect<usize>> {
101        let prev_bbox = self.prev_bbox?;
102        let (size, _) = self.target_size;
103
104        let cy = prev_bbox.y + prev_bbox.height / 2;
105        let cx = prev_bbox.x + prev_bbox.width / 2;
106
107        // Search in a window around previous position
108        let search_cx = cx;
109        let search_cy = cy;
110
111        let patch = extract_patch(gray_data, img_w, img_w, search_cx, search_cy, size, size);
112        let patch_freq = simple_dft_2d(&patch, size, size);
113
114        // Correlate: response = IFFT(H * conj(P))
115        let mut response = vec![0.0f64; size * size];
116        let mut max_val = f64::NEG_INFINITY;
117        let mut max_idx = 0;
118
119        for i in 0..size * size {
120            let p_re = patch_freq[i * 2];
121            let p_im = patch_freq[i * 2 + 1];
122            let h_re = self.filter_freq[i * 2];
123            let h_im = self.filter_freq[i * 2 + 1];
124            // H * conj(P)
125            let re = h_re * p_re + h_im * p_im;
126            let _im = h_im * p_re - h_re * p_im;
127            response[i] = re; // Real part of IFFT (simplified)
128            if re > max_val {
129                max_val = re;
130                max_idx = i;
131            }
132        }
133
134        // Find peak location
135        let peak_y = max_idx / size;
136        let peak_x = max_idx % size;
137        let dy = peak_y as i32 - size as i32 / 2;
138        let dx = peak_x as i32 - size as i32 / 2;
139
140        let new_cx = (cx as i32 + dx).max(0) as usize;
141        let new_cy = (cy as i32 + dy).max(0) as usize;
142
143        // Update filter with new appearance
144        let new_patch = extract_patch(gray_data, img_w, img_w, new_cx, new_cy, size, size);
145        let new_freq = simple_dft_2d(&new_patch, size, size);
146
147        let alpha = self.learning_rate;
148        for i in 0..size * size {
149            self.filter_freq[i * 2] =
150                self.filter_freq[i * 2] * (1.0 - alpha) + new_freq[i * 2] * alpha;
151            self.filter_freq[i * 2 + 1] =
152                self.filter_freq[i * 2 + 1] * (1.0 - alpha) + new_freq[i * 2 + 1] * alpha;
153        }
154
155        let new_bbox = Rect::new(
156            new_cx.saturating_sub(prev_bbox.width / 2),
157            new_cy.saturating_sub(prev_bbox.height / 2),
158            prev_bbox.width,
159            prev_bbox.height,
160        );
161
162        self.prev_gray = Some(gray_data.to_vec());
163        self.prev_bbox = Some(new_bbox);
164
165        Some(new_bbox)
166    }
167}
168
169/// Extract a patch from a grayscale image centered at (cx, cy).
170fn extract_patch(
171    data: &[f32],
172    img_w: usize,
173    img_h: usize,
174    cx: usize,
175    cy: usize,
176    patch_w: usize,
177    patch_h: usize,
178) -> Vec<f64> {
179    let mut patch = vec![0.0f64; patch_h * patch_w];
180    let half_w = patch_w / 2;
181    let half_h = patch_h / 2;
182
183    for py in 0..patch_h {
184        for px in 0..patch_w {
185            let sx = cx as i32 + px as i32 - half_w as i32;
186            let sy = cy as i32 + py as i32 - half_h as i32;
187            if sx >= 0 && sx < img_w as i32 && sy >= 0 && sy < img_h as i32 {
188                patch[py * patch_w + px] = data[sy as usize * img_w + sx as usize] as f64;
189            }
190        }
191    }
192    patch
193}
194
195/// Simple 2D DFT (not FFT - uses O(N²) but correct).
196/// Returns interleaved real/imaginary pairs.
197fn simple_dft_2d(data: &[f64], w: usize, h: usize) -> Vec<f64> {
198    let mut result = vec![0.0f64; w * h * 2];
199    let n = w * h;
200
201    for u in 0..h {
202        for v in 0..w {
203            let mut sum_re = 0.0f64;
204            let mut sum_im = 0.0f64;
205
206            for y in 0..h {
207                for x in 0..w {
208                    let angle = -2.0
209                        * std::f64::consts::PI
210                        * ((u * y) as f64 / h as f64 + (v * x) as f64 / w as f64);
211                    let val = data[y * w + x];
212                    sum_re += val * angle.cos();
213                    sum_im += val * angle.sin();
214                }
215            }
216
217            result[(u * w + v) * 2] = sum_re / (n as f64).sqrt();
218            result[(u * w + v) * 2 + 1] = sum_im / (n as f64).sqrt();
219        }
220    }
221
222    result
223}
224
225// ---------------------------------------------------------------------------
226// Mean-Shift Tracker
227// ---------------------------------------------------------------------------
228
229/// A non-parametric kernel-based tracker that locates the densest region
230/// (the target) in successive frames.
231///
232/// The algorithm builds a colour-histogram model of the target region at
233/// initialisation time and then iteratively shifts the window towards the
234/// weighted mean (mean-shift) in each subsequent frame until convergence.
235pub struct MeanShiftTracker {
236    /// Current bounding box.
237    bbox: Option<Rect<usize>>,
238    /// Target colour histogram (normalised, bins per channel).
239    model_hist: Vec<f32>,
240    /// Number of bins per colour channel (quantised 0..bins).
241    bins: usize,
242    /// Spatial bandwidth of the Epanechnikov kernel (radius in pixels).
243    spatial_radius: f32,
244    /// Maximum mean-shift iterations per update.
245    max_iter: usize,
246    /// Convergence threshold: stop when the shift is below this value (pixels).
247    epsilon: f32,
248    /// Width of the last processed image.
249    last_w: usize,
250    /// Height of the last processed image.
251    last_h: usize,
252}
253
254impl Default for MeanShiftTracker {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260impl MeanShiftTracker {
261    /// Creates a new tracker with sensible defaults.
262    #[must_use]
263    pub fn new() -> Self {
264        Self {
265            bbox: None,
266            model_hist: Vec::new(),
267            bins: 16,
268            spatial_radius: 0.0,
269            max_iter: 30,
270            epsilon: 0.5,
271            last_w: 0,
272            last_h: 0,
273        }
274    }
275
276    /// Sets the number of histogram bins per channel.
277    #[must_use]
278    pub fn with_bins(mut self, bins: usize) -> Self {
279        self.bins = bins;
280        self
281    }
282
283    /// Sets the maximum number of mean-shift iterations.
284    #[must_use]
285    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
286        self.max_iter = max_iter;
287        self
288    }
289
290    /// Initialises the tracker with a known region-of-interest in `image`.
291    pub fn init<B: Backend>(&mut self, image: &Image<B>, roi: Rect<usize>) -> Result<()> {
292        let dims = image.tensor.dims();
293        let c = dims[0];
294        let h = dims[1];
295        let w = dims[2];
296
297        if c < 3 {
298            return Err(IrisError::InvalidParameter(
299                "MeanShift requires at least a 3-channel image".into(),
300            ));
301        }
302
303        let tensor_data = image.tensor.clone().into_data();
304        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
305
306        let cx = roi.x + roi.width / 2;
307        let cy = roi.y + roi.height / 2;
308        self.spatial_radius =
309            ((roi.width as f32).powi(2) + (roi.height as f32).powi(2)).sqrt() / 2.0;
310
311        // Quantise each RGB channel into `bins` buckets and build the
312        // normalised histogram of the ROI.
313        let total_bins = self.bins * self.bins * self.bins;
314        let mut hist = vec![0.0f32; total_bins];
315
316        for y in roi.y..(roi.y + roi.height).min(h) {
317            for x in roi.x..(roi.x + roi.width).min(w) {
318                let idx = (y * w + x) * 3;
319                let r = flat_vals[idx];
320                let g = flat_vals[idx + 1];
321                let b = flat_vals[idx + 2];
322
323                let ri = ((r * self.bins as f32) as usize).min(self.bins - 1);
324                let gi = ((g * self.bins as f32) as usize).min(self.bins - 1);
325                let bi = ((b * self.bins as f32) as usize).min(self.bins - 1);
326                let bin = ri * self.bins * self.bins + gi * self.bins + bi;
327
328                // Epanechnikov spatial weight (centred on ROI centre)
329                let dx = x as f32 - cx as f32;
330                let dy = y as f32 - cy as f32;
331                let dist = (dx * dx + dy * dy).sqrt();
332                let weight = if dist <= self.spatial_radius {
333                    1.0 - (dist / self.spatial_radius).powi(2)
334                } else {
335                    0.0
336                };
337                hist[bin] += weight;
338            }
339        }
340
341        // Normalise the histogram
342        let sum: f32 = hist.iter().sum();
343        if sum > 1e-10 {
344            for v in hist.iter_mut() {
345                *v /= sum;
346            }
347        }
348
349        self.model_hist = hist;
350        self.bbox = Some(roi);
351        self.last_w = w;
352        self.last_h = h;
353
354        Ok(())
355    }
356
357    /// Runs one mean-shift iteration and returns the updated bounding box.
358    pub fn update<B: Backend>(&mut self, image: &Image<B>) -> Result<Rect<usize>> {
359        let dims = image.tensor.dims();
360        let c = dims[0];
361        let h = dims[1];
362        let w = dims[2];
363
364        let cur_bbox = self.bbox.ok_or_else(|| {
365            IrisError::Generic("MeanShiftTracker not initialised. Call init first.".into())
366        })?;
367
368        if c < 3 {
369            return Err(IrisError::InvalidParameter(
370                "MeanShift requires at least a 3-channel image".into(),
371            ));
372        }
373
374        let tensor_data = image.tensor.clone().into_data();
375        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
376
377        let mut cx = cur_bbox.x as f32 + cur_bbox.width as f32 / 2.0;
378        let mut cy = cur_bbox.y as f32 + cur_bbox.height as f32 / 2.0;
379        let hw = cur_bbox.width as f32 / 2.0;
380        let hh = cur_bbox.height as f32 / 2.0;
381
382        for _ in 0..self.max_iter {
383            // Accumulate numerator and denominator of the mean-shift vector
384            let mut sum_wx = 0.0f64;
385            let mut sum_wy = 0.0f64;
386            let mut sum_w = 0.0f64;
387
388            let y_min = (cy - hh).max(0.0) as usize;
389            let y_max = (cy + hh).min(h as f32 - 1.0) as usize;
390            let x_min = (cx - hw).max(0.0) as usize;
391            let x_max = (cx + hw).min(w as f32 - 1.0) as usize;
392
393            for y in y_min..=y_max {
394                for x in x_min..=x_max {
395                    let idx = (y * w + x) * 3;
396                    let r = flat_vals[idx];
397                    let g = flat_vals[idx + 1];
398                    let b = flat_vals[idx + 2];
399
400                    let ri = ((r * self.bins as f32) as usize).min(self.bins - 1);
401                    let gi = ((g * self.bins as f32) as usize).min(self.bins - 1);
402                    let bi = ((b * self.bins as f32) as usize).min(self.bins - 1);
403                    let bin = ri * self.bins * self.bins + gi * self.bins + bi;
404
405                    let model_val = self.model_hist[bin];
406                    if model_val < 1e-10 {
407                        continue;
408                    }
409
410                    // Kernel weight (Epanechnikov)
411                    let dx = x as f32 - cx;
412                    let dy = y as f32 - cy;
413                    let dist = (dx * dx + dy * dy).sqrt();
414                    let kernel_weight = if dist <= self.spatial_radius {
415                        1.0 - (dist / self.spatial_radius).powi(2)
416                    } else {
417                        0.0
418                    };
419
420                    let w_i = model_val * kernel_weight;
421                    sum_wx += w_i as f64 * x as f64;
422                    sum_wy += w_i as f64 * y as f64;
423                    sum_w += w_i as f64;
424                }
425            }
426
427            if sum_w < 1e-10 {
428                break;
429            }
430
431            let new_cx = (sum_wx / sum_w) as f32;
432            let new_cy = (sum_wy / sum_w) as f32;
433            let shift_x = new_cx - cx;
434            let shift_y = new_cy - cy;
435
436            cx = new_cx;
437            cy = new_cy;
438
439            if (shift_x * shift_x + shift_y * shift_y).sqrt() < self.epsilon {
440                break;
441            }
442        }
443
444        let new_bbox = Rect::new(
445            (cx - hw).round().max(0.0) as usize,
446            (cy - hh).round().max(0.0) as usize,
447            cur_bbox.width,
448            cur_bbox.height,
449        );
450
451        self.bbox = Some(new_bbox);
452        self.last_w = w;
453        self.last_h = h;
454
455        Ok(new_bbox)
456    }
457}
458
459/// Object tracker pipeline.
460pub struct Tracker<B: Backend> {
461    pub tracker_type: TrackerType,
462    pub bbox: Option<Rect<usize>>,
463    mosse_state: MosseState,
464    _marker: std::marker::PhantomData<B>,
465}
466
467impl<B: Backend> Tracker<B> {
468    /// Creates a new Tracker.
469    #[must_use]
470    pub fn new(tracker_type: TrackerType) -> Self {
471        Self {
472            tracker_type,
473            bbox: None,
474            mosse_state: MosseState::new(),
475            _marker: std::marker::PhantomData,
476        }
477    }
478
479    /// Initializes the tracker with a known bounding box of the target object.
480    pub fn init(&mut self, image: &Image<B>, bbox: Rect<usize>) -> Result<()> {
481        self.bbox = Some(bbox);
482
483        // Initialize MOSSE state
484        let gray = image.grayscale()?;
485        let dims = gray.tensor.dims();
486        let _h = dims[1];
487        let w = dims[2];
488        let tensor_data = gray.tensor.clone().into_data();
489        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
490        self.mosse_state.init(&flat_vals, w, bbox);
491
492        Ok(())
493    }
494
495    /// Updates the tracker, finding the new location of the target in the frame.
496    pub fn update(&mut self, image: &Image<B>) -> Result<Rect<usize>> {
497        let current = self.bbox.ok_or_else(|| {
498            crate::error::IrisError::Generic("Tracker not initialized. Call init first.".into())
499        })?;
500
501        // Try MOSSE update
502        let gray = image.grayscale()?;
503        let dims = gray.tensor.dims();
504        let _h = dims[1];
505        let w = dims[2];
506        let tensor_data = gray.tensor.clone().into_data();
507        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
508
509        if let Some(new_bbox) = self.mosse_state.update(&flat_vals, w) {
510            self.bbox = Some(new_bbox);
511            return Ok(new_bbox);
512        }
513
514        // Fallback: return current position unchanged
515        Ok(current)
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::test_helpers::{TestBackend, test_device};
523    use burn::tensor::{Tensor, TensorData};
524
525    #[test]
526    fn test_mosse_tracker() {
527        let device = test_device();
528        let flat_data = vec![0.5f32; 3 * 32 * 32];
529        let tensor =
530            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 32, 32]), &device);
531        let img = Image::new(tensor);
532
533        let mut tracker = Tracker::new(TrackerType::MOSSE);
534        let init_bbox = Rect::new(8, 8, 16, 16);
535        tracker.init(&img, init_bbox).unwrap();
536
537        let updated = tracker.update(&img).unwrap();
538        // Should return a valid bounding box
539        assert!(updated.width > 0);
540        assert!(updated.height > 0);
541    }
542
543    #[test]
544    fn test_meanshift_tracker_init_and_update() {
545        let device = test_device();
546
547        // Create an 8-channel (RGB) image with a distinct coloured region
548        // (bright red) somewhere that the tracker should lock onto.
549        let mut flat_data = vec![0.2f32; 3 * 32 * 32];
550        // Paint a red block at (12, 12) – (20, 20)
551        for y in 12..20 {
552            for x in 12..20 {
553                let idx = (y * 32 + x) * 3;
554                flat_data[idx] = 1.0; // R
555                flat_data[idx + 1] = 0.0; // G
556                flat_data[idx + 2] = 0.0; // B
557            }
558        }
559        let tensor =
560            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 32, 32]), &device);
561        let img = Image::new(tensor);
562
563        let mut tracker = MeanShiftTracker::new().with_bins(8).with_max_iter(10);
564        let roi = Rect::new(10, 10, 12, 12);
565        tracker.init(&img, roi).unwrap();
566
567        // On the same image, the tracker should converge back to the red block
568        let updated = tracker.update(&img).unwrap();
569        assert!(updated.width > 0);
570        assert!(updated.height > 0);
571        // The centre should be near the red block centre (16, 16)
572        let ucx = updated.x + updated.width / 2;
573        let ucy = updated.y + updated.height / 2;
574        assert!(
575            (ucx as isize - 16).unsigned_abs() <= 2 && (ucy as isize - 16).unsigned_abs() <= 2,
576            "Expected centre near (16,16), got ({ucx},{ucy})"
577        );
578    }
579
580    #[test]
581    fn test_tracker_api() {
582        let device = test_device();
583        let flat_data = vec![0.5f32; 3 * 16 * 16];
584        let tensor =
585            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [3, 16, 16]), &device);
586        let img = Image::new(tensor);
587
588        let mut tracker = Tracker::new(TrackerType::KCF);
589        let init_bbox = Rect::new(2, 2, 8, 8);
590        tracker.init(&img, init_bbox).unwrap();
591
592        let updated = tracker.update(&img).unwrap();
593        assert_eq!(updated.width, 8);
594        assert_eq!(updated.height, 8);
595    }
596}