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#[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, Sensitivity::Medium => 0.05, Sensitivity::High => 0.02, Sensitivity::Custom(value) => *value,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MotionConfig {
47 pub threshold: f64,
49
50 pub min_area_percent: f64,
52
53 pub consecutive_frames: u32,
55
56 #[serde(with = "humantime_serde")]
58 pub cooldown: Duration,
59
60 pub zones: Vec<DetectionZone>,
62
63 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
104mod 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#[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#[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#[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
192pub 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 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 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 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 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 let Some(ref prev_frame) = self.previous_frame else {
260 self.previous_frame = Some(frame.clone());
261 return Ok(None);
262 };
263
264 let motion_mask = self.compute_difference(prev_frame, frame);
266
267 let score = Self::calculate_motion_score(&motion_mask);
269 self.total_score += score;
270
271 if self.stats.frames_processed > 0 {
273 self.stats.avg_score = self.total_score / self.stats.frames_processed as f64;
274 }
275
276 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 self.previous_frame = Some(frame.clone());
288
289 if self.consecutive_count >= self.config.consecutive_frames {
291 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 let mut event = MotionEvent::new(self.frame_count, score);
300
301 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 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; Ok(Some(event))
317 } else {
318 Ok(None)
319 }
320 }
321
322 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 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 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 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 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 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); }
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 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 let result = detector.feed_frame(&frame2).unwrap();
523 assert!(result.is_none());
524
525 let result = detector.feed_frame(&frame1).unwrap();
527 assert!(result.is_none());
528
529 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 let result = detector.feed_frame(&frame2).unwrap();
550 assert!(result.is_some());
551
552 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 let data1 = vec![0u8; 100];
568 let mut data2 = vec![0u8; 100];
569
570 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; let mut detector = MotionDetector::new(config).unwrap();
657
658 let data1 = vec![100u8; 100];
659 let mut data2 = vec![100u8; 100];
660
661 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 assert!(result.is_none());
674 }
675}