Skip to main content

camgrab_core/motion/
detector.rs

1use crate::motion::zones::{DetectionZone, ZoneManager};
2use chrono::{DateTime, Utc};
3use image::GrayImage;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::time::{Duration, Instant};
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum MotionError {
11    #[error("Frame dimensions do not match: expected {expected_width}x{expected_height}, got {actual_width}x{actual_height}")]
12    DimensionMismatch {
13        expected_width: u32,
14        expected_height: u32,
15        actual_width: u32,
16        actual_height: u32,
17    },
18    #[error("Invalid configuration: {0}")]
19    InvalidConfig(String),
20    #[error("Zone validation error: {0}")]
21    ZoneError(#[from] crate::motion::zones::ZoneError),
22}
23
24/// Sensitivity presets for motion detection
25#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
26pub enum Sensitivity {
27    Low,
28    Medium,
29    High,
30    Custom(f64),
31}
32
33impl Sensitivity {
34    pub fn threshold(&self) -> f64 {
35        match self {
36            Sensitivity::Low => 0.10,    // 10% change required
37            Sensitivity::Medium => 0.05, // 5% change required
38            Sensitivity::High => 0.02,   // 2% change required
39            Sensitivity::Custom(value) => *value,
40        }
41    }
42}
43
44/// Configuration for motion detection
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MotionConfig {
47    /// Pixel difference threshold (0.0-1.0), how different a pixel must be
48    pub threshold: f64,
49
50    /// Minimum percentage of changed area to trigger detection (0.0-100.0)
51    pub min_area_percent: f64,
52
53    /// Number of consecutive frames above threshold required
54    pub consecutive_frames: u32,
55
56    /// Minimum time between motion events
57    #[serde(with = "humantime_serde")]
58    pub cooldown: Duration,
59
60    /// Optional detection zones
61    pub zones: Vec<DetectionZone>,
62
63    /// Sensitivity preset
64    pub sensitivity: Sensitivity,
65}
66
67impl Default for MotionConfig {
68    fn default() -> Self {
69        Self {
70            threshold: 0.05,
71            min_area_percent: 1.0,
72            consecutive_frames: 2,
73            cooldown: Duration::from_secs(5),
74            zones: Vec::new(),
75            sensitivity: Sensitivity::Medium,
76        }
77    }
78}
79
80impl MotionConfig {
81    pub fn validate(&self) -> Result<(), MotionError> {
82        if !(0.0..=1.0).contains(&self.threshold) {
83            return Err(MotionError::InvalidConfig(
84                "threshold must be between 0.0 and 1.0".to_string(),
85            ));
86        }
87
88        if !(0.0..=100.0).contains(&self.min_area_percent) {
89            return Err(MotionError::InvalidConfig(
90                "min_area_percent must be between 0.0 and 100.0".to_string(),
91            ));
92        }
93
94        if self.consecutive_frames == 0 {
95            return Err(MotionError::InvalidConfig(
96                "consecutive_frames must be at least 1".to_string(),
97            ));
98        }
99
100        Ok(())
101    }
102}
103
104// Custom serde module for Duration
105mod humantime_serde {
106    use serde::{Deserialize, Deserializer, Serializer};
107    use std::time::Duration;
108
109    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
110    where
111        S: Serializer,
112    {
113        serializer.serialize_u64(duration.as_secs())
114    }
115
116    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
117    where
118        D: Deserializer<'de>,
119    {
120        let secs = u64::deserialize(deserializer)?;
121        Ok(Duration::from_secs(secs))
122    }
123}
124
125/// Bounding box of motion region
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127pub struct BoundingBox {
128    pub x: u32,
129    pub y: u32,
130    pub width: u32,
131    pub height: u32,
132}
133
134impl BoundingBox {
135    pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
136        Self {
137            x,
138            y,
139            width,
140            height,
141        }
142    }
143
144    pub fn area(&self) -> u64 {
145        self.width as u64 * self.height as u64
146    }
147}
148
149/// Motion detection event
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct MotionEvent {
152    pub timestamp: DateTime<Utc>,
153    pub score: f64,
154    pub zone_scores: HashMap<String, f64>,
155    pub bounding_box: Option<BoundingBox>,
156    pub frame_index: u64,
157}
158
159impl MotionEvent {
160    pub fn new(frame_index: u64, score: f64) -> Self {
161        Self {
162            timestamp: Utc::now(),
163            score,
164            zone_scores: HashMap::new(),
165            bounding_box: None,
166            frame_index,
167        }
168    }
169
170    #[must_use]
171    pub fn with_zones(mut self, zone_scores: HashMap<String, f64>) -> Self {
172        self.zone_scores = zone_scores;
173        self
174    }
175
176    #[must_use]
177    pub fn with_bounding_box(mut self, bbox: BoundingBox) -> Self {
178        self.bounding_box = Some(bbox);
179        self
180    }
181}
182
183/// Statistics about the detector
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185pub struct DetectorStats {
186    pub frames_processed: u64,
187    pub events_triggered: u64,
188    pub avg_score: f64,
189    pub uptime: Duration,
190}
191
192/// Core motion detection engine
193pub struct MotionDetector {
194    config: MotionConfig,
195    previous_frame: Option<GrayImage>,
196    frame_dimensions: Option<(u32, u32)>,
197    consecutive_count: u32,
198    last_event_time: Option<Instant>,
199    zone_manager: Option<ZoneManager>,
200    stats: DetectorStats,
201    start_time: Instant,
202    frame_count: u64,
203    total_score: f64,
204}
205
206impl MotionDetector {
207    /// Create a new motion detector with the given configuration
208    pub fn new(config: MotionConfig) -> Result<Self, MotionError> {
209        config.validate()?;
210
211        Ok(Self {
212            config,
213            previous_frame: None,
214            frame_dimensions: None,
215            consecutive_count: 0,
216            last_event_time: None,
217            zone_manager: None,
218            stats: DetectorStats::default(),
219            start_time: Instant::now(),
220            frame_count: 0,
221            total_score: 0.0,
222        })
223    }
224
225    /// Feed a frame to the detector and check for motion
226    pub fn feed_frame(&mut self, frame: &GrayImage) -> Result<Option<MotionEvent>, MotionError> {
227        self.frame_count = self.frame_count.saturating_add(1);
228        self.stats.frames_processed = self.stats.frames_processed.saturating_add(1);
229        self.stats.uptime = self.start_time.elapsed();
230
231        let width = frame.width();
232        let height = frame.height();
233
234        // Initialize dimensions and zone manager on first frame
235        if self.frame_dimensions.is_none() {
236            self.frame_dimensions = Some((width, height));
237
238            if !self.config.zones.is_empty() {
239                let zone_manager = ZoneManager::new(self.config.zones.clone(), width, height);
240                zone_manager.validate_zones()?;
241                self.zone_manager = Some(zone_manager);
242            }
243        }
244
245        // Check dimensions match
246        let (expected_width, expected_height) = self
247            .frame_dimensions
248            .expect("frame_dimensions must be initialized above on first frame");
249        if width != expected_width || height != expected_height {
250            return Err(MotionError::DimensionMismatch {
251                expected_width,
252                expected_height,
253                actual_width: width,
254                actual_height: height,
255            });
256        }
257
258        // Need at least 2 frames to detect motion
259        let Some(ref prev_frame) = self.previous_frame else {
260            self.previous_frame = Some(frame.clone());
261            return Ok(None);
262        };
263
264        // Compute frame difference
265        let motion_mask = self.compute_difference(prev_frame, frame);
266
267        // Calculate motion score
268        let score = Self::calculate_motion_score(&motion_mask);
269        self.total_score += score;
270
271        // Update average score
272        if self.stats.frames_processed > 0 {
273            self.stats.avg_score = self.total_score / self.stats.frames_processed as f64;
274        }
275
276        // Check if motion detected
277        let threshold = self.config.sensitivity.threshold();
278        let motion_detected = score >= threshold && (score * 100.0) >= self.config.min_area_percent;
279
280        if motion_detected {
281            self.consecutive_count = self.consecutive_count.saturating_add(1);
282        } else {
283            self.consecutive_count = 0;
284        }
285
286        // Update previous frame
287        self.previous_frame = Some(frame.clone());
288
289        // Check if we should trigger an event
290        if self.consecutive_count >= self.config.consecutive_frames {
291            // Check cooldown
292            if let Some(last_time) = self.last_event_time {
293                if last_time.elapsed() < self.config.cooldown {
294                    return Ok(None);
295                }
296            }
297
298            // Create motion event
299            let mut event = MotionEvent::new(self.frame_count, score);
300
301            // Compute zone scores if zones are configured
302            if let Some(ref zone_manager) = self.zone_manager {
303                let zone_scores = zone_manager.compute_zone_scores(&motion_mask);
304                event = event.with_zones(zone_scores);
305            }
306
307            // Calculate bounding box
308            if let Some(bbox) = Self::calculate_bounding_box(&motion_mask) {
309                event = event.with_bounding_box(bbox);
310            }
311
312            self.last_event_time = Some(Instant::now());
313            self.stats.events_triggered = self.stats.events_triggered.saturating_add(1);
314            self.consecutive_count = 0; // Reset after triggering
315
316            Ok(Some(event))
317        } else {
318            Ok(None)
319        }
320    }
321
322    /// Compute absolute difference between frames
323    fn compute_difference(&self, prev: &GrayImage, current: &GrayImage) -> GrayImage {
324        let width = current.width();
325        let height = current.height();
326        let mut diff = GrayImage::new(width, height);
327
328        let threshold_u8 = (self.config.threshold * 255.0) as u8;
329
330        for y in 0..height {
331            for x in 0..width {
332                let prev_pixel = prev.get_pixel(x, y)[0];
333                let curr_pixel = current.get_pixel(x, y)[0];
334
335                let diff_val = prev_pixel.abs_diff(curr_pixel);
336
337                // Apply threshold to create binary mask
338                let binary_val = if diff_val > threshold_u8 { 255 } else { 0 };
339                diff.put_pixel(x, y, image::Luma([binary_val]));
340            }
341        }
342
343        diff
344    }
345
346    /// Calculate percentage of pixels with motion
347    fn calculate_motion_score(motion_mask: &GrayImage) -> f64 {
348        let width = motion_mask.width();
349        let height = motion_mask.height();
350        let total_pixels = width as u64 * height as u64;
351
352        if total_pixels == 0 {
353            return 0.0;
354        }
355
356        let mut motion_pixels = 0u64;
357
358        for y in 0..height {
359            for x in 0..width {
360                if motion_mask.get_pixel(x, y)[0] > 0 {
361                    motion_pixels = motion_pixels.saturating_add(1);
362                }
363            }
364        }
365
366        motion_pixels as f64 / total_pixels as f64
367    }
368
369    /// Calculate bounding box of motion region
370    fn calculate_bounding_box(motion_mask: &GrayImage) -> Option<BoundingBox> {
371        let width = motion_mask.width();
372        let height = motion_mask.height();
373
374        let mut min_x = width;
375        let mut min_y = height;
376        let mut max_x = 0u32;
377        let mut max_y = 0u32;
378        let mut found = false;
379
380        for y in 0..height {
381            for x in 0..width {
382                if motion_mask.get_pixel(x, y)[0] > 0 {
383                    found = true;
384                    min_x = min_x.min(x);
385                    min_y = min_y.min(y);
386                    max_x = max_x.max(x);
387                    max_y = max_y.max(y);
388                }
389            }
390        }
391
392        if !found {
393            return None;
394        }
395
396        let bbox_width = max_x.saturating_sub(min_x).saturating_add(1);
397        let bbox_height = max_y.saturating_sub(min_y).saturating_add(1);
398
399        Some(BoundingBox::new(min_x, min_y, bbox_width, bbox_height))
400    }
401
402    /// Reset detector state
403    pub fn reset(&mut self) {
404        self.previous_frame = None;
405        self.frame_dimensions = None;
406        self.consecutive_count = 0;
407        self.last_event_time = None;
408        self.zone_manager = None;
409        self.stats = DetectorStats::default();
410        self.start_time = Instant::now();
411        self.frame_count = 0;
412        self.total_score = 0.0;
413    }
414
415    /// Get detector statistics
416    pub fn stats(&self) -> DetectorStats {
417        let mut stats = self.stats.clone();
418        stats.uptime = self.start_time.elapsed();
419        stats
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_motion_config_validation() {
429        let mut config = MotionConfig::default();
430        assert!(config.validate().is_ok());
431
432        config.threshold = 1.5;
433        assert!(config.validate().is_err());
434
435        config.threshold = 0.5;
436        config.min_area_percent = 150.0;
437        assert!(config.validate().is_err());
438
439        config.min_area_percent = 5.0;
440        config.consecutive_frames = 0;
441        assert!(config.validate().is_err());
442    }
443
444    #[test]
445    fn test_sensitivity_threshold() {
446        assert_eq!(Sensitivity::Low.threshold(), 0.10);
447        assert_eq!(Sensitivity::Medium.threshold(), 0.05);
448        assert_eq!(Sensitivity::High.threshold(), 0.02);
449        assert_eq!(Sensitivity::Custom(0.15).threshold(), 0.15);
450    }
451
452    #[test]
453    fn test_bounding_box_area() {
454        let bbox = BoundingBox::new(10, 10, 50, 30);
455        assert_eq!(bbox.area(), 1500);
456    }
457
458    #[test]
459    fn test_detector_creation() {
460        let config = MotionConfig::default();
461        let detector = MotionDetector::new(config);
462        assert!(detector.is_ok());
463
464        let mut invalid_config = MotionConfig::default();
465        invalid_config.threshold = 2.0;
466        let detector = MotionDetector::new(invalid_config);
467        assert!(detector.is_err());
468    }
469
470    #[test]
471    fn test_no_motion_on_identical_frames() {
472        let config = MotionConfig::default();
473        let mut detector = MotionDetector::new(config).unwrap();
474
475        let frame1 = GrayImage::from_raw(10, 10, vec![100u8; 100]).unwrap();
476        let frame2 = GrayImage::from_raw(10, 10, vec![100u8; 100]).unwrap();
477
478        let result1 = detector.feed_frame(&frame1);
479        assert!(result1.is_ok());
480        assert!(result1.unwrap().is_none());
481
482        let result2 = detector.feed_frame(&frame2);
483        assert!(result2.is_ok());
484        assert!(result2.unwrap().is_none());
485    }
486
487    #[test]
488    fn test_motion_detection_on_different_frames() {
489        let mut config = MotionConfig::default();
490        config.threshold = 0.05;
491        config.min_area_percent = 1.0;
492        config.consecutive_frames = 1;
493
494        let mut detector = MotionDetector::new(config).unwrap();
495
496        let frame1 = GrayImage::from_raw(10, 10, vec![0u8; 100]).unwrap();
497        let frame2 = GrayImage::from_raw(10, 10, vec![255u8; 100]).unwrap();
498
499        detector.feed_frame(&frame1).unwrap();
500        let result = detector.feed_frame(&frame2).unwrap();
501
502        assert!(result.is_some());
503        let event = result.unwrap();
504        assert_eq!(event.score, 1.0); // 100% different
505    }
506
507    #[test]
508    fn test_consecutive_frames_requirement() {
509        let mut config = MotionConfig::default();
510        config.consecutive_frames = 3;
511        config.threshold = 0.05;
512
513        let mut detector = MotionDetector::new(config).unwrap();
514
515        // Use alternating frames to ensure continuous motion detection
516        let frame1 = GrayImage::from_raw(10, 10, vec![100u8; 100]).unwrap();
517        let frame2 = GrayImage::from_raw(10, 10, vec![200u8; 100]).unwrap();
518
519        detector.feed_frame(&frame1).unwrap();
520
521        // First motion frame - no event yet (count = 1)
522        let result = detector.feed_frame(&frame2).unwrap();
523        assert!(result.is_none());
524
525        // Second motion frame - no event yet (count = 2)
526        let result = detector.feed_frame(&frame1).unwrap();
527        assert!(result.is_none());
528
529        // Third motion frame - should trigger (count = 3)
530        let result = detector.feed_frame(&frame2).unwrap();
531        assert!(result.is_some());
532    }
533
534    #[test]
535    fn test_cooldown_enforcement() {
536        let mut config = MotionConfig::default();
537        config.consecutive_frames = 1;
538        config.cooldown = Duration::from_secs(2);
539        config.threshold = 0.05;
540
541        let mut detector = MotionDetector::new(config).unwrap();
542
543        let frame1 = GrayImage::from_raw(10, 10, vec![0u8; 100]).unwrap();
544        let frame2 = GrayImage::from_raw(10, 10, vec![255u8; 100]).unwrap();
545
546        detector.feed_frame(&frame1).unwrap();
547
548        // First motion event
549        let result = detector.feed_frame(&frame2).unwrap();
550        assert!(result.is_some());
551
552        // Second motion event should be blocked by cooldown
553        detector.feed_frame(&frame1).unwrap();
554        let result = detector.feed_frame(&frame2).unwrap();
555        assert!(result.is_none());
556    }
557
558    #[test]
559    fn test_bounding_box_calculation() {
560        let mut config = MotionConfig::default();
561        config.consecutive_frames = 1;
562        config.threshold = 0.05;
563
564        let mut detector = MotionDetector::new(config).unwrap();
565
566        // Create a frame with motion in specific region
567        let data1 = vec![0u8; 100];
568        let mut data2 = vec![0u8; 100];
569
570        // Add motion in region (5,5) to (7,7)
571        for y in 5..8 {
572            for x in 5..8 {
573                data2[y * 10 + x] = 255;
574            }
575        }
576
577        let frame1 = GrayImage::from_raw(10, 10, data1).unwrap();
578        let frame2 = GrayImage::from_raw(10, 10, data2).unwrap();
579
580        detector.feed_frame(&frame1).unwrap();
581        let result = detector.feed_frame(&frame2).unwrap();
582
583        assert!(result.is_some());
584        let event = result.unwrap();
585        assert!(event.bounding_box.is_some());
586
587        let bbox = event.bounding_box.unwrap();
588        assert_eq!(bbox.x, 5);
589        assert_eq!(bbox.y, 5);
590        assert_eq!(bbox.width, 3);
591        assert_eq!(bbox.height, 3);
592    }
593
594    #[test]
595    fn test_frame_dimension_mismatch() {
596        let config = MotionConfig::default();
597        let mut detector = MotionDetector::new(config).unwrap();
598
599        let frame1 = GrayImage::from_raw(10, 10, vec![0u8; 100]).unwrap();
600        let frame2 = GrayImage::from_raw(20, 20, vec![0u8; 400]).unwrap();
601
602        detector.feed_frame(&frame1).unwrap();
603        let result = detector.feed_frame(&frame2);
604
605        assert!(result.is_err());
606        assert!(matches!(
607            result.unwrap_err(),
608            MotionError::DimensionMismatch { .. }
609        ));
610    }
611
612    #[test]
613    fn test_detector_reset() {
614        let config = MotionConfig::default();
615        let mut detector = MotionDetector::new(config).unwrap();
616
617        let frame = GrayImage::from_raw(10, 10, vec![100u8; 100]).unwrap();
618        detector.feed_frame(&frame).unwrap();
619
620        assert_eq!(detector.stats().frames_processed, 1);
621
622        detector.reset();
623
624        assert_eq!(detector.stats().frames_processed, 0);
625        assert!(detector.previous_frame.is_none());
626        assert!(detector.frame_dimensions.is_none());
627    }
628
629    #[test]
630    fn test_detector_stats() {
631        let mut config = MotionConfig::default();
632        config.consecutive_frames = 1;
633        config.threshold = 0.05;
634
635        let mut detector = MotionDetector::new(config).unwrap();
636
637        let frame1 = GrayImage::from_raw(10, 10, vec![0u8; 100]).unwrap();
638        let frame2 = GrayImage::from_raw(10, 10, vec![255u8; 100]).unwrap();
639
640        detector.feed_frame(&frame1).unwrap();
641        detector.feed_frame(&frame2).unwrap();
642
643        let stats = detector.stats();
644        assert_eq!(stats.frames_processed, 2);
645        assert_eq!(stats.events_triggered, 1);
646        assert!(stats.avg_score > 0.0);
647    }
648
649    #[test]
650    fn test_partial_frame_change() {
651        let mut config = MotionConfig::default();
652        config.consecutive_frames = 1;
653        config.threshold = 0.05;
654        config.min_area_percent = 5.0; // Need at least 5% change
655
656        let mut detector = MotionDetector::new(config).unwrap();
657
658        let data1 = vec![100u8; 100];
659        let mut data2 = vec![100u8; 100];
660
661        // Change only 3 pixels (3% of 100)
662        data2[0] = 200;
663        data2[1] = 200;
664        data2[2] = 200;
665
666        let frame1 = GrayImage::from_raw(10, 10, data1).unwrap();
667        let frame2 = GrayImage::from_raw(10, 10, data2).unwrap();
668
669        detector.feed_frame(&frame1).unwrap();
670        let result = detector.feed_frame(&frame2).unwrap();
671
672        // Should not trigger (only 3% changed, need 5%)
673        assert!(result.is_none());
674    }
675}