Skip to main content

oximedia_gpu/
motion_detect.rs

1//! GPU-accelerated motion detection.
2//!
3//! This module provides CPU-fallback simulations of GPU compute operations
4//! for inter-frame motion detection. In production, the pixel difference
5//! computation and reduction would be executed via WGPU compute shaders.
6
7use crate::{GpuError, Result};
8
9// ---------------------------------------------------------------------------
10// Sensitivity
11// ---------------------------------------------------------------------------
12
13/// Motion detection sensitivity level.
14///
15/// Controls the per-pixel difference threshold above which a pixel is
16/// considered to have changed between frames.
17#[derive(Debug, Clone, Copy)]
18pub enum Sensitivity {
19    /// Low sensitivity — threshold = 30.
20    Low,
21    /// Medium sensitivity — threshold = 15.
22    Medium,
23    /// High sensitivity — threshold = 8.
24    High,
25}
26
27impl Sensitivity {
28    /// Return the pixel-difference threshold for this sensitivity level.
29    #[must_use]
30    pub fn threshold(&self) -> u8 {
31        match self {
32            Self::Low => 30,
33            Self::Medium => 15,
34            Self::High => 8,
35        }
36    }
37}
38
39// ---------------------------------------------------------------------------
40// MotionRegion
41// ---------------------------------------------------------------------------
42
43/// Per-region motion information.
44#[derive(Debug, Clone)]
45pub struct MotionRegion {
46    /// X coordinate of the region's top-left corner (in pixels).
47    pub x: u32,
48    /// Y coordinate of the region's top-left corner (in pixels).
49    pub y: u32,
50    /// Width of the region in pixels.
51    pub width: u32,
52    /// Height of the region in pixels.
53    pub height: u32,
54    /// Normalized motion magnitude in `[0.0, 1.0]`; 0 = no motion, 1 = maximum.
55    pub magnitude: f32,
56    /// Number of pixels that exceeded the motion threshold.
57    pub changed_pixels: u32,
58}
59
60// ---------------------------------------------------------------------------
61// MotionAnalysis
62// ---------------------------------------------------------------------------
63
64/// Frame-level motion analysis result.
65#[derive(Debug, Clone)]
66pub struct MotionAnalysis {
67    /// Normalized global motion magnitude in `[0.0, 1.0]`.
68    pub global_magnitude: f32,
69    /// Fraction of pixels that changed (0.0–1.0).
70    pub changed_pixel_ratio: f32,
71    /// Per-region motion information.
72    pub regions: Vec<MotionRegion>,
73    /// `true` if any motion was detected at the current sensitivity level.
74    pub motion_detected: bool,
75}
76
77// ---------------------------------------------------------------------------
78// MotionDetector
79// ---------------------------------------------------------------------------
80
81/// GPU-accelerated motion detector.
82///
83/// Compares successive frames to detect and quantify pixel-level motion.
84/// The first call to [`analyze`] returns a zero-motion result because
85/// there is no previous frame to compare against.
86///
87/// [`analyze`]: MotionDetector::analyze
88pub struct MotionDetector {
89    sensitivity: Sensitivity,
90    /// Number of analysis regions in the horizontal direction.
91    region_count_x: u32,
92    /// Number of analysis regions in the vertical direction.
93    region_count_y: u32,
94    prev_frame: Option<Vec<u8>>,
95}
96
97impl MotionDetector {
98    /// Create a new `MotionDetector`.
99    ///
100    /// # Arguments
101    ///
102    /// * `sensitivity` - Detection sensitivity level.
103    /// * `region_count_x` - Number of sub-regions horizontally (minimum 1).
104    /// * `region_count_y` - Number of sub-regions vertically (minimum 1).
105    #[must_use]
106    pub fn new(sensitivity: Sensitivity, region_count_x: u32, region_count_y: u32) -> Self {
107        Self {
108            sensitivity,
109            region_count_x: region_count_x.max(1),
110            region_count_y: region_count_y.max(1),
111            prev_frame: None,
112        }
113    }
114
115    /// Analyze motion between the previous frame and the current frame.
116    ///
117    /// On the first call (no previous frame), returns a `MotionAnalysis`
118    /// with all metrics at zero. On subsequent calls the current frame is
119    /// compared against the previously stored frame.
120    ///
121    /// # Arguments
122    ///
123    /// * `frame` - Raw pixel data (single channel / luma expected for best
124    ///   results; multi-channel frames work but all bytes are compared).
125    /// * `width` - Frame width in pixels.
126    /// * `height` - Frame height in pixels.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`GpuError::InvalidDimensions`] if `width * height == 0`.
131    /// Returns [`GpuError::InvalidBufferSize`] if a previous frame was stored
132    /// with a different size.
133    pub fn analyze(&mut self, frame: &[u8], width: u32, height: u32) -> Result<MotionAnalysis> {
134        if width == 0 || height == 0 {
135            return Err(GpuError::InvalidDimensions { width, height });
136        }
137
138        // First frame — no motion data available yet.
139        let Some(prev) = self.prev_frame.take() else {
140            self.prev_frame = Some(frame.to_vec());
141            return Ok(MotionAnalysis {
142                global_magnitude: 0.0,
143                changed_pixel_ratio: 0.0,
144                regions: vec![],
145                motion_detected: false,
146            });
147        };
148
149        if prev.len() != frame.len() {
150            // Replace stored frame before returning the error.
151            self.prev_frame = Some(frame.to_vec());
152            return Err(GpuError::InvalidBufferSize {
153                expected: prev.len(),
154                actual: frame.len(),
155            });
156        }
157
158        let threshold = self.sensitivity.threshold();
159        let total_pixels = frame.len() as u32;
160
161        // Global metrics
162        let (total_changed, total_diff_sum) =
163            prev.iter()
164                .zip(frame.iter())
165                .fold((0u32, 0u64), |(changed, sum), (&p, &c)| {
166                    let diff = p.abs_diff(c);
167                    if diff >= threshold {
168                        (changed + 1, sum + u64::from(diff))
169                    } else {
170                        (changed, sum)
171                    }
172                });
173
174        let global_magnitude = if total_pixels > 0 {
175            (total_diff_sum as f64 / (f64::from(total_pixels) * 255.0)) as f32
176        } else {
177            0.0
178        };
179        let changed_pixel_ratio = total_changed as f32 / total_pixels as f32;
180        let motion_detected = total_changed > 0;
181
182        // Per-region metrics
183        let regions = self.compute_regions(frame, &prev, width, height, threshold, total_pixels);
184
185        // Store current frame for next call.
186        self.prev_frame = Some(frame.to_vec());
187
188        Ok(MotionAnalysis {
189            global_magnitude,
190            changed_pixel_ratio,
191            regions,
192            motion_detected,
193        })
194    }
195
196    /// Compute per-region motion statistics.
197    #[allow(clippy::too_many_arguments)]
198    fn compute_regions(
199        &self,
200        frame: &[u8],
201        prev: &[u8],
202        width: u32,
203        height: u32,
204        threshold: u8,
205        total_pixels: u32,
206    ) -> Vec<MotionRegion> {
207        let rx = self.region_count_x;
208        let ry = self.region_count_y;
209
210        // We index pixels by byte position (works for any channel count).
211        let bytes_per_row = frame.len() / height.max(1) as usize;
212
213        let mut regions = Vec::with_capacity((rx * ry) as usize);
214
215        for ry_idx in 0..ry {
216            for rx_idx in 0..rx {
217                let region_x = rx_idx * width / rx;
218                let region_y = ry_idx * height / ry;
219                let region_w = (rx_idx + 1) * width / rx - region_x;
220                let region_h = (ry_idx + 1) * height / ry - region_y;
221
222                let mut changed = 0u32;
223                let mut diff_sum = 0u64;
224                let mut region_pixels = 0u32;
225
226                for row in region_y..(region_y + region_h) {
227                    let row_start = row as usize * bytes_per_row;
228                    // Byte range within row for this region's columns.
229                    let col_byte_start =
230                        row_start + (region_x as usize * bytes_per_row / width.max(1) as usize);
231                    let col_bytes = region_w as usize * bytes_per_row / width.max(1) as usize;
232
233                    let end = (col_byte_start + col_bytes).min(frame.len());
234                    for i in col_byte_start..end {
235                        let diff = frame[i].abs_diff(prev[i]);
236                        region_pixels += 1;
237                        if diff >= threshold {
238                            changed += 1;
239                            diff_sum += u64::from(diff);
240                        }
241                    }
242                }
243
244                let magnitude = if total_pixels > 0 && region_pixels > 0 {
245                    (diff_sum as f64 / (f64::from(region_pixels) * 255.0)) as f32
246                } else {
247                    0.0
248                };
249
250                regions.push(MotionRegion {
251                    x: region_x,
252                    y: region_y,
253                    width: region_w,
254                    height: region_h,
255                    magnitude,
256                    changed_pixels: changed,
257                });
258            }
259        }
260
261        regions
262    }
263
264    /// Reset the motion detector, clearing the stored previous frame.
265    pub fn reset(&mut self) {
266        self.prev_frame = None;
267    }
268
269    /// Return the total number of motion analysis regions (x * y).
270    #[must_use]
271    pub fn region_count(&self) -> u32 {
272        self.region_count_x * self.region_count_y
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Tests
278// ---------------------------------------------------------------------------
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_sensitivity_thresholds() {
286        assert_eq!(Sensitivity::Low.threshold(), 30);
287        assert_eq!(Sensitivity::Medium.threshold(), 15);
288        assert_eq!(Sensitivity::High.threshold(), 8);
289    }
290
291    #[test]
292    fn test_first_frame_returns_no_motion() {
293        let mut detector = MotionDetector::new(Sensitivity::Medium, 2, 2);
294        let frame = vec![100u8; 16]; // 4x4 grayscale
295        let result = detector.analyze(&frame, 4, 4).unwrap();
296
297        assert_eq!(result.global_magnitude, 0.0);
298        assert_eq!(result.changed_pixel_ratio, 0.0);
299        assert!(!result.motion_detected);
300        assert!(result.regions.is_empty());
301    }
302
303    #[test]
304    fn test_identical_frames_returns_no_motion() {
305        let mut detector = MotionDetector::new(Sensitivity::Medium, 2, 2);
306        let frame = vec![100u8; 16];
307
308        // First call stores the frame.
309        detector.analyze(&frame, 4, 4).unwrap();
310
311        // Second call with identical frame.
312        let result = detector.analyze(&frame, 4, 4).unwrap();
313        assert_eq!(result.global_magnitude, 0.0);
314        assert_eq!(result.changed_pixel_ratio, 0.0);
315        assert!(!result.motion_detected);
316    }
317
318    #[test]
319    fn test_different_frames_returns_motion() {
320        let mut detector = MotionDetector::new(Sensitivity::High, 1, 1);
321        let frame_a = vec![0u8; 16];
322        let frame_b = vec![255u8; 16];
323
324        detector.analyze(&frame_a, 4, 4).unwrap();
325        let result = detector.analyze(&frame_b, 4, 4).unwrap();
326
327        assert!(result.motion_detected);
328        assert!(result.global_magnitude > 0.0);
329        assert!(result.changed_pixel_ratio > 0.0);
330        assert_eq!(result.changed_pixel_ratio, 1.0);
331    }
332
333    #[test]
334    fn test_region_count() {
335        let detector = MotionDetector::new(Sensitivity::Low, 4, 3);
336        assert_eq!(detector.region_count(), 12);
337    }
338
339    #[test]
340    fn test_reset_clears_previous_frame() {
341        let mut detector = MotionDetector::new(Sensitivity::Medium, 1, 1);
342        let frame_a = vec![0u8; 16];
343        let frame_b = vec![255u8; 16];
344
345        detector.analyze(&frame_a, 4, 4).unwrap();
346        detector.reset();
347
348        // After reset, the next call should behave as the first call.
349        let result = detector.analyze(&frame_b, 4, 4).unwrap();
350        assert!(!result.motion_detected);
351    }
352
353    #[test]
354    fn test_below_threshold_not_detected() {
355        // Low sensitivity threshold = 30; diff of 10 should not trigger motion.
356        let mut detector = MotionDetector::new(Sensitivity::Low, 1, 1);
357        let frame_a = vec![100u8; 16];
358        let frame_b = vec![110u8; 16]; // diff = 10, below 30
359
360        detector.analyze(&frame_a, 4, 4).unwrap();
361        let result = detector.analyze(&frame_b, 4, 4).unwrap();
362
363        assert!(!result.motion_detected);
364    }
365
366    #[test]
367    fn test_invalid_dimensions() {
368        let mut detector = MotionDetector::new(Sensitivity::Medium, 1, 1);
369        let frame = vec![0u8; 16];
370        assert!(detector.analyze(&frame, 0, 4).is_err());
371        assert!(detector.analyze(&frame, 4, 0).is_err());
372    }
373}