Skip to main content

scirs2_vision/
video_processing.rs

1//! Video frame buffer and temporal processing utilities.
2//!
3//! This module provides data structures for working with video frame sequences
4//! and temporal algorithms such as per-pixel temporal median filtering and
5//! background subtraction using a Mixture of Gaussians (MoG) model.
6//!
7//! # Overview
8//!
9//! - [`VideoFrame`] -- a single video frame with metadata
10//! - [`FrameBuffer`] -- a circular (ring) buffer of [`VideoFrame`]s
11//! - [`temporal_median_filter`] -- per-pixel median over a window of RGB frames
12//! - [`MogBackground`] / [`background_subtraction_mog`] -- per-pixel MoG foreground detection
13//! - [`frame_interpolation`] -- flow-based frame interpolation
14
15use crate::error::{Result, VisionError};
16use crate::optical_flow_dense::warp_image;
17use scirs2_core::ndarray::{Array2, Array3};
18use std::collections::VecDeque;
19
20// ---------------------------------------------------------------------------
21// VideoFrame
22// ---------------------------------------------------------------------------
23
24/// A single video frame with associated timestamp and spatial metadata.
25#[derive(Debug, Clone)]
26pub struct VideoFrame {
27    /// Pixel data stored as `[height, width, channels]` in `[0, 1]`.
28    pub data: Array3<f64>,
29    /// Timestamp in seconds.
30    pub timestamp: f64,
31    /// Frame width in pixels.
32    pub width: usize,
33    /// Frame height in pixels.
34    pub height: usize,
35}
36
37impl VideoFrame {
38    /// Create a new [`VideoFrame`].
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if the array dimensions are inconsistent with the
43    /// supplied `width` and `height`.
44    pub fn new(data: Array3<f64>, timestamp: f64) -> Result<Self> {
45        let shape = data.dim();
46        let height = shape.0;
47        let width = shape.1;
48        Ok(Self {
49            data,
50            timestamp,
51            width,
52            height,
53        })
54    }
55
56    /// Return the number of colour channels.
57    pub fn channels(&self) -> usize {
58        self.data.dim().2
59    }
60
61    /// Extract a single channel as an `Array2<f64>`.
62    pub fn channel(&self, ch: usize) -> Result<Array2<f64>> {
63        if ch >= self.channels() {
64            return Err(VisionError::InvalidParameter(format!(
65                "channel index {ch} out of range (frame has {} channels)",
66                self.channels()
67            )));
68        }
69        Ok(self
70            .data
71            .slice(scirs2_core::ndarray::s![.., .., ch])
72            .to_owned())
73    }
74
75    /// Convert to grayscale by averaging all channels.
76    pub fn to_grayscale(&self) -> Array2<f64> {
77        let (h, w, c) = self.data.dim();
78        let mut gray = Array2::<f64>::zeros((h, w));
79        let weight = 1.0 / c as f64;
80        for ch in 0..c {
81            for r in 0..h {
82                for col in 0..w {
83                    gray[[r, col]] += self.data[[r, col, ch]] * weight;
84                }
85            }
86        }
87        gray
88    }
89}
90
91// ---------------------------------------------------------------------------
92// FrameBuffer (circular buffer)
93// ---------------------------------------------------------------------------
94
95/// A capacity-bounded circular buffer of [`VideoFrame`]s.
96///
97/// When the buffer is full, the oldest frame is automatically evicted.
98#[derive(Debug, Clone)]
99pub struct FrameBuffer {
100    /// Internal double-ended queue acting as a ring buffer.
101    pub frames: VecDeque<VideoFrame>,
102    /// Maximum number of frames retained.
103    pub capacity: usize,
104}
105
106impl FrameBuffer {
107    /// Create a new `FrameBuffer` with the given capacity.
108    pub fn new(capacity: usize) -> Result<Self> {
109        if capacity == 0 {
110            return Err(VisionError::InvalidParameter(
111                "FrameBuffer: capacity must be at least 1".into(),
112            ));
113        }
114        Ok(Self {
115            frames: VecDeque::with_capacity(capacity),
116            capacity,
117        })
118    }
119
120    /// Push a frame onto the back.  If the buffer is at capacity the oldest
121    /// (front) frame is dropped first.
122    pub fn push(&mut self, frame: VideoFrame) {
123        if self.frames.len() == self.capacity {
124            self.frames.pop_front();
125        }
126        self.frames.push_back(frame);
127    }
128
129    /// Number of frames currently stored.
130    pub fn len(&self) -> usize {
131        self.frames.len()
132    }
133
134    /// Returns `true` if the buffer contains no frames.
135    pub fn is_empty(&self) -> bool {
136        self.frames.is_empty()
137    }
138
139    /// Whether the buffer has reached its maximum capacity.
140    pub fn is_full(&self) -> bool {
141        self.frames.len() == self.capacity
142    }
143
144    /// Retrieve a reference to the most recent frame, if any.
145    pub fn latest(&self) -> Option<&VideoFrame> {
146        self.frames.back()
147    }
148
149    /// Iterate over frames in chronological order.
150    pub fn iter(&self) -> impl Iterator<Item = &VideoFrame> {
151        self.frames.iter()
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Temporal median filter
157// ---------------------------------------------------------------------------
158
159/// Apply a per-pixel temporal median filter over a slice of RGB (or N-channel) frames.
160///
161/// All frames must have the same spatial shape `[H, W, C]`.  The output is
162/// the per-pixel, per-channel median over the supplied window.
163///
164/// # Arguments
165///
166/// * `frames`  – slice of at least 1 frame, each `[H, W, C]` in `[0, 1]`
167/// * `window`  – number of frames to include in the median (capped to
168///   `frames.len()` if larger)
169pub fn temporal_median_filter(frames: &[Array3<f64>], window: usize) -> Result<Array3<f64>> {
170    if frames.is_empty() {
171        return Err(VisionError::InvalidParameter(
172            "temporal_median_filter: frames slice must not be empty".into(),
173        ));
174    }
175    let window = window.min(frames.len()).max(1);
176    let ref_shape = frames[0].dim();
177    for (i, f) in frames.iter().enumerate() {
178        if f.dim() != ref_shape {
179            return Err(VisionError::DimensionMismatch(format!(
180                "temporal_median_filter: frame {i} shape {:?} != reference {:?}",
181                f.dim(),
182                ref_shape
183            )));
184        }
185    }
186
187    let (h, w, c) = ref_shape;
188    let start = frames.len().saturating_sub(window);
189    let window_frames = &frames[start..];
190    let n = window_frames.len();
191    let mut output = Array3::<f64>::zeros((h, w, c));
192
193    for row in 0..h {
194        for col in 0..w {
195            for ch in 0..c {
196                let mut vals: Vec<f64> = window_frames.iter().map(|f| f[[row, col, ch]]).collect();
197                vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
198                output[[row, col, ch]] = if n % 2 == 1 {
199                    vals[n / 2]
200                } else {
201                    (vals[n / 2 - 1] + vals[n / 2]) * 0.5
202                };
203            }
204        }
205    }
206
207    Ok(output)
208}
209
210// ---------------------------------------------------------------------------
211// Mixture of Gaussians background subtraction
212// ---------------------------------------------------------------------------
213
214/// Configuration for the Mixture of Gaussians background model.
215#[derive(Debug, Clone)]
216pub struct MogBackground {
217    /// Number of Gaussian components per pixel.
218    pub n_gaussians: usize,
219    /// Learning rate for updating the model (α).  Typical: 0.005.
220    pub learning_rate: f64,
221    /// Mahalanobis distance threshold for foreground / background decision.
222    pub threshold: f64,
223    // Per-pixel model: [H, W, K] tensors for mean, variance and weight.
224    means: Option<Array3<f64>>,
225    variances: Option<Array3<f64>>,
226    weights: Option<Array3<f64>>,
227}
228
229impl MogBackground {
230    /// Create a new uninitialised MoG background model.
231    ///
232    /// The model is initialised on the first call to [`background_subtraction_mog`].
233    pub fn new(n_gaussians: usize, learning_rate: f64, threshold: f64) -> Result<Self> {
234        if n_gaussians == 0 {
235            return Err(VisionError::InvalidParameter(
236                "MogBackground: n_gaussians must be at least 1".into(),
237            ));
238        }
239        if !(0.0..=1.0).contains(&learning_rate) {
240            return Err(VisionError::InvalidParameter(
241                "MogBackground: learning_rate must be in (0, 1]".into(),
242            ));
243        }
244        Ok(Self {
245            n_gaussians,
246            learning_rate,
247            threshold,
248            means: None,
249            variances: None,
250            weights: None,
251        })
252    }
253}
254
255impl Default for MogBackground {
256    fn default() -> Self {
257        Self {
258            n_gaussians: 3,
259            learning_rate: 0.005,
260            threshold: 2.5,
261            means: None,
262            variances: None,
263            weights: None,
264        }
265    }
266}
267
268/// Apply Mixture of Gaussians background subtraction to a single-channel frame.
269///
270/// Updates `background_model` in place and returns a boolean mask where
271/// `true` indicates a foreground (moving) pixel.
272///
273/// The frame values are expected in `[0, 1]`.
274pub fn background_subtraction_mog(
275    frame: &Array2<f64>,
276    background_model: &mut MogBackground,
277) -> Result<Array2<bool>> {
278    let (rows, cols) = frame.dim();
279    let k = background_model.n_gaussians;
280    let alpha = background_model.learning_rate;
281    let thr = background_model.threshold;
282
283    // Initialise model on first call.
284    if background_model.means.is_none() {
285        // All components start at the current frame value with high variance
286        // and equal weight.
287        let init_weight = 1.0 / k as f64;
288        let mut means = Array3::<f64>::zeros((rows, cols, k));
289        let variances = Array3::<f64>::from_elem((rows, cols, k), 0.01);
290        let weights = Array3::<f64>::from_elem((rows, cols, k), init_weight);
291        // Copy frame pixel into each component mean.
292        for r in 0..rows {
293            for c in 0..cols {
294                for ki in 0..k {
295                    means[[r, c, ki]] = frame[[r, c]];
296                }
297            }
298        }
299        background_model.means = Some(means);
300        background_model.variances = Some(variances);
301        background_model.weights = Some(weights);
302    }
303
304    let means = background_model.means.as_mut().expect("means initialised");
305    let variances = background_model
306        .variances
307        .as_mut()
308        .expect("variances initialised");
309    let weights = background_model
310        .weights
311        .as_mut()
312        .expect("weights initialised");
313
314    let mut fg_mask = Array2::<bool>::from_elem((rows, cols), false);
315
316    for r in 0..rows {
317        for c in 0..cols {
318            let pixel = frame[[r, c]];
319            let mut matched = false;
320            let mut best_ki = 0usize;
321
322            // Find matching component (Mahalanobis distance check).
323            for ki in 0..k {
324                let diff = pixel - means[[r, c, ki]];
325                let var = variances[[r, c, ki]];
326                if var > 1e-12 && diff * diff / var < thr * thr {
327                    // Update matching component.
328                    let rho = alpha / weights[[r, c, ki]].max(1e-12);
329                    means[[r, c, ki]] += rho * diff;
330                    variances[[r, c, ki]] = (1.0 - rho) * var + rho * diff * diff;
331                    weights[[r, c, ki]] = (1.0 - alpha) * weights[[r, c, ki]] + alpha;
332
333                    matched = true;
334                    best_ki = ki;
335                    break;
336                }
337            }
338
339            if !matched {
340                // Replace the least-weighted component.
341                let mut min_w = weights[[r, c, 0]];
342                let mut min_ki = 0;
343                for ki in 1..k {
344                    if weights[[r, c, ki]] < min_w {
345                        min_w = weights[[r, c, ki]];
346                        min_ki = ki;
347                    }
348                }
349                means[[r, c, min_ki]] = pixel;
350                variances[[r, c, min_ki]] = 0.01;
351                weights[[r, c, min_ki]] = alpha;
352                best_ki = min_ki;
353            }
354
355            // Renormalise weights.
356            let mut w_sum = 0.0_f64;
357            for ki in 0..k {
358                w_sum += weights[[r, c, ki]];
359            }
360            if w_sum > 1e-12 {
361                for ki in 0..k {
362                    weights[[r, c, ki]] /= w_sum;
363                }
364            }
365
366            // Foreground decision: pixel belongs to background if its matched
367            // component has weight above 1/K (dominant component check).
368            let is_bg = matched && weights[[r, c, best_ki]] > 1.0 / k as f64;
369            fg_mask[[r, c]] = !is_bg;
370        }
371    }
372
373    Ok(fg_mask)
374}
375
376// ---------------------------------------------------------------------------
377// Flow-based frame interpolation
378// ---------------------------------------------------------------------------
379
380/// Interpolate a frame between `frame1` and `frame2` at time `t ∈ [0, 1]`.
381///
382/// Uses the supplied optical flow `flow` (computed from `frame1` to `frame2`)
383/// to warp `frame1` forward by `t` and `frame2` backward by `(1 - t)`, then
384/// blends the two warped images linearly.
385///
386/// # Arguments
387///
388/// * `frame1` – source frame `[H, W, C]`
389/// * `frame2` – target frame `[H, W, C]`
390/// * `t`      – interpolation parameter in `[0, 1]` (0 → frame1, 1 → frame2)
391/// * `flow`   – `(u, v)` flow field from frame1 to frame2 (shape `[H, W]` each)
392pub fn frame_interpolation(
393    frame1: &Array3<f64>,
394    frame2: &Array3<f64>,
395    t: f64,
396    flow: (&Array2<f64>, &Array2<f64>),
397) -> Result<Array3<f64>> {
398    let shape = frame1.dim();
399    if shape != frame2.dim() {
400        return Err(VisionError::DimensionMismatch(
401            "frame_interpolation: frame1 and frame2 must have identical shapes".into(),
402        ));
403    }
404    let (u, v) = flow;
405    let (h, w, _) = shape;
406    if u.dim() != (h, w) || v.dim() != (h, w) {
407        return Err(VisionError::DimensionMismatch(
408            "frame_interpolation: flow field spatial shape must match frame spatial shape".into(),
409        ));
410    }
411    if !(0.0..=1.0).contains(&t) {
412        return Err(VisionError::InvalidParameter(
413            "frame_interpolation: t must be in [0, 1]".into(),
414        ));
415    }
416
417    let (rows, cols, channels) = shape;
418
419    // Forward warp: scale flow by t.
420    let u_fwd = u.mapv(|x| x * t);
421    let v_fwd = v.mapv(|x| x * t);
422    // Backward warp: scale flow by -(1-t).
423    let u_bwd = u.mapv(|x| -x * (1.0 - t));
424    let v_bwd = v.mapv(|x| -x * (1.0 - t));
425
426    let mut output = Array3::<f64>::zeros((rows, cols, channels));
427
428    for ch in 0..channels {
429        let ch1 = frame1
430            .slice(scirs2_core::ndarray::s![.., .., ch])
431            .to_owned();
432        let ch2 = frame2
433            .slice(scirs2_core::ndarray::s![.., .., ch])
434            .to_owned();
435
436        let warped1 = warp_image(&ch1, &u_fwd, &v_fwd)?;
437        let warped2 = warp_image(&ch2, &u_bwd, &v_bwd)?;
438
439        for r in 0..rows {
440            for c in 0..cols {
441                output[[r, c, ch]] = (1.0 - t) * warped1[[r, c]] + t * warped2[[r, c]];
442            }
443        }
444    }
445
446    Ok(output)
447}
448
449// ---------------------------------------------------------------------------
450// Tests
451// ---------------------------------------------------------------------------
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use scirs2_core::ndarray::{Array2, Array3};
457
458    fn rgb_frame(h: usize, w: usize, val: f64) -> Array3<f64> {
459        Array3::from_elem((h, w, 3), val)
460    }
461
462    #[test]
463    fn frame_buffer_circular_eviction() {
464        let mut buf = FrameBuffer::new(3).expect("FrameBuffer::new failed");
465        for i in 0..5u32 {
466            let data = rgb_frame(4, 4, i as f64 / 10.0);
467            let frame = VideoFrame::new(data, i as f64).expect("VideoFrame::new failed");
468            buf.push(frame);
469        }
470        assert_eq!(buf.len(), 3);
471        // Oldest kept should be frame 2 (timestamp = 2.0).
472        assert!((buf.frames[0].timestamp - 2.0).abs() < 1e-9);
473    }
474
475    #[test]
476    fn temporal_median_filter_single_frame() {
477        let f = rgb_frame(4, 4, 0.7);
478        let out =
479            temporal_median_filter(std::slice::from_ref(&f), 1).expect("median filter failed");
480        for &v in out.iter() {
481            assert!((v - 0.7).abs() < 1e-10);
482        }
483    }
484
485    #[test]
486    fn temporal_median_filter_three_frames() {
487        // Three frames: 0.2, 0.5, 0.8 → median should be 0.5.
488        let f1 = rgb_frame(2, 2, 0.2);
489        let f2 = rgb_frame(2, 2, 0.5);
490        let f3 = rgb_frame(2, 2, 0.8);
491        let out = temporal_median_filter(&[f1, f2, f3], 3).expect("median filter failed");
492        for &v in out.iter() {
493            assert!((v - 0.5).abs() < 1e-10, "expected 0.5, got {v}");
494        }
495    }
496
497    #[test]
498    fn mog_background_first_frame_all_foreground_zero() {
499        // On the very first frame everything is initialised to match the pixel,
500        // so we expect either all background or well-defined behaviour.
501        let frame = Array2::from_elem((4, 4), 0.5_f64);
502        let mut model = MogBackground::new(3, 0.005, 2.5).expect("MogBackground::new failed");
503        let mask = background_subtraction_mog(&frame, &mut model).expect("mog failed");
504        // After first frame init, every pixel "matched" the newly created component
505        // but the component weight equals alpha (< 1/K), so all are foreground.
506        // Either way the mask must have the correct shape.
507        assert_eq!(mask.dim(), (4, 4));
508    }
509
510    #[test]
511    fn mog_background_converges_to_background() {
512        // After many identical frames the model should classify them as background.
513        let frame = Array2::from_elem((4, 4), 0.5_f64);
514        let mut model = MogBackground::new(3, 0.1, 2.5).expect("MogBackground::new failed");
515        for _ in 0..50 {
516            let _ = background_subtraction_mog(&frame, &mut model);
517        }
518        let mask = background_subtraction_mog(&frame, &mut model).expect("mog failed");
519        // All pixels should be background after convergence.
520        for val in mask.iter() {
521            assert!(!val, "expected background, got foreground");
522        }
523    }
524
525    #[test]
526    fn frame_interpolation_at_zero_returns_frame1() {
527        let f1 = rgb_frame(4, 4, 0.2);
528        let f2 = rgb_frame(4, 4, 0.8);
529        let u = Array2::zeros((4, 4));
530        let v = Array2::zeros((4, 4));
531        let out = frame_interpolation(&f1, &f2, 0.0, (&u, &v)).expect("interpolation failed");
532        for &val in out.iter() {
533            assert!((val - 0.2).abs() < 1e-10, "expected 0.2, got {val}");
534        }
535    }
536
537    #[test]
538    fn frame_interpolation_at_one_returns_frame2() {
539        let f1 = rgb_frame(4, 4, 0.2);
540        let f2 = rgb_frame(4, 4, 0.8);
541        let u = Array2::zeros((4, 4));
542        let v = Array2::zeros((4, 4));
543        let out = frame_interpolation(&f1, &f2, 1.0, (&u, &v)).expect("interpolation failed");
544        for &val in out.iter() {
545            assert!((val - 0.8).abs() < 1e-10, "expected 0.8, got {val}");
546        }
547    }
548}