diff --git a/meteor-edge-client/src/detection/vida/config.rs b/meteor-edge-client/src/detection/vida/config.rs index 73bbe84..87c7d0c 100644 --- a/meteor-edge-client/src/detection/vida/config.rs +++ b/meteor-edge-client/src/detection/vida/config.rs @@ -19,6 +19,9 @@ pub struct VidaConfig { pub star_extraction: StarExtractionConfig, /// Calibration configuration pub calibration: CalibrationSettings, + /// Polygon mask configuration for blocking ground/obstacles + #[serde(default)] + pub mask: super::mask::PolygonMaskConfig, /// Whether to enable star extraction for sky quality check pub enable_star_extraction: bool, /// Minimum number of stars required for detection (skip if cloudy) @@ -34,6 +37,7 @@ impl Default for VidaConfig { line_detector: LineDetector3DConfig::default(), star_extraction: StarExtractionConfig::default(), calibration: CalibrationSettings::default(), + mask: super::mask::PolygonMaskConfig::default(), enable_star_extraction: false, min_stars_for_detection: 500, } diff --git a/meteor-edge-client/src/detection/vida/controller.rs b/meteor-edge-client/src/detection/vida/controller.rs index 002a598..2381658 100644 --- a/meteor-edge-client/src/detection/vida/controller.rs +++ b/meteor-edge-client/src/detection/vida/controller.rs @@ -6,7 +6,8 @@ use crate::detection::vida::{ AccumulatedFrame, AccumulatorConfig, FireballDetection, FireballDetector, - FrameAccumulator, MeteorDetection, MeteorDetector, VidaConfig, + FrameAccumulator, MeteorDetection, MeteorDetector, PolygonMask, SharedPolygonMask, + VidaConfig, }; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -84,8 +85,21 @@ impl VidaDetectionController { /// Create a new detection controller pub fn new(config: VidaConfig, width: u32, height: u32) -> Self { let accumulator = FrameAccumulator::new(width, height, config.accumulator.clone()); - let fireball_detector = FireballDetector::new(config.fireball.clone()); - let meteor_detector = MeteorDetector::new(config.meteor.clone()); + let mut fireball_detector = FireballDetector::new(config.fireball.clone()); + let mut meteor_detector = MeteorDetector::new(config.meteor.clone()); + + // Create and apply polygon mask if configured + if config.mask.enabled && !config.mask.polygons.is_empty() { + let mask = Arc::new(PolygonMask::from_config(config.mask.clone(), width, height)); + let coverage = mask.coverage_ratio() * 100.0; + println!( + "Polygon mask enabled: {} polygons, {:.1}% coverage", + config.mask.polygons.len(), + coverage + ); + fireball_detector.set_mask(Arc::clone(&mask)); + meteor_detector.set_mask(mask); + } Self { config, diff --git a/meteor-edge-client/src/detection/vida/fireball_detector.rs b/meteor-edge-client/src/detection/vida/fireball_detector.rs index 6916af4..ac0830a 100644 --- a/meteor-edge-client/src/detection/vida/fireball_detector.rs +++ b/meteor-edge-client/src/detection/vida/fireball_detector.rs @@ -8,6 +8,7 @@ use crate::detection::vida::{ AccumulatedFrame, FireballDetectorConfig, LineSegment3D, LineSegment3DDetector, Point3D, + SharedPolygonMask, }; use std::collections::HashSet; @@ -48,6 +49,8 @@ impl FireballDetection { pub struct FireballDetector { config: FireballDetectorConfig, line_detector: LineSegment3DDetector, + /// Optional polygon mask for filtering out ground/obstacles + polygon_mask: Option, } impl FireballDetector { @@ -64,6 +67,7 @@ impl FireballDetector { Self { config, line_detector: LineSegment3DDetector::new(line_config), + polygon_mask: None, } } @@ -71,6 +75,16 @@ impl FireballDetector { Self::new(FireballDetectorConfig::default()) } + /// Set the polygon mask for filtering detections + pub fn set_mask(&mut self, mask: SharedPolygonMask) { + self.polygon_mask = Some(mask); + } + + /// Clear the polygon mask + pub fn clear_mask(&mut self) { + self.polygon_mask = None; + } + /// Detect fireballs in an accumulated frame /// /// Algorithm: @@ -85,11 +99,16 @@ impl FireballDetector { /// bottleneck is 3D line detection, not image processing. pub fn detect(&self, frame: &AccumulatedFrame) -> Vec { // Step 1: Threshold to get bright pixels at original resolution - let threshold_mask = frame.threshold_with_intensity( + let mut threshold_mask = frame.threshold_with_intensity( self.config.k1_threshold, self.config.min_intensity, ); + // Apply polygon mask if configured + if let Some(ref mask) = self.polygon_mask { + mask.apply_to_vec(&mut threshold_mask); + } + // Step 2: Build 3D point cloud let mut point_cloud = frame.to_point_cloud(&threshold_mask); diff --git a/meteor-edge-client/src/detection/vida/mask.rs b/meteor-edge-client/src/detection/vida/mask.rs new file mode 100644 index 0000000..929db56 --- /dev/null +++ b/meteor-edge-client/src/detection/vida/mask.rs @@ -0,0 +1,413 @@ +//! Polygon Mask for Vida Detection +//! +//! Provides functionality to mask out regions of the image where detection +//! should be suppressed (e.g., ground, buildings, trees, artificial lights). + +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Polygon mask configuration (serializable for JSON/TOML config files) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolygonMaskConfig { + /// Whether masking is enabled + #[serde(default)] + pub enabled: bool, + + /// Mask mode: exclude or include polygons + #[serde(default)] + pub mode: MaskMode, + + /// List of polygons defining mask regions + #[serde(default)] + pub polygons: Vec, +} + +impl Default for PolygonMaskConfig { + fn default() -> Self { + Self { + enabled: false, + mode: MaskMode::Exclude, + polygons: Vec::new(), + } + } +} + +/// Mask mode determines how polygons affect detection +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MaskMode { + /// Exclude mode: pixels INSIDE polygons are masked (blocked from detection) + /// Use this to mask out ground, buildings, trees, etc. + #[default] + Exclude, + + /// Include mode: pixels OUTSIDE polygons are masked (only detect inside polygons) + /// Use this to limit detection to specific sky regions + Include, +} + +/// A polygon defined by its vertices +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Polygon { + /// Vertex coordinates as (x, y) pixel positions + /// Vertices should form a closed polygon (last vertex connects to first) + pub vertices: Vec<(u32, u32)>, + + /// Optional label for the polygon (e.g., "ground", "building", "tree") + #[serde(default)] + pub label: Option, +} + +impl Polygon { + /// Create a new polygon from vertices + pub fn new(vertices: Vec<(u32, u32)>) -> Self { + Self { + vertices, + label: None, + } + } + + /// Create a new polygon with a label + pub fn with_label(vertices: Vec<(u32, u32)>, label: impl Into) -> Self { + Self { + vertices, + label: Some(label.into()), + } + } + + /// Create a rectangle polygon (convenience method) + pub fn rectangle(x: u32, y: u32, width: u32, height: u32) -> Self { + Self::new(vec![ + (x, y), + (x + width, y), + (x + width, y + height), + (x, y + height), + ]) + } + + /// Check if a point is inside the polygon using ray casting algorithm + /// + /// This implements the even-odd rule: a point is inside if a ray from + /// the point crosses the polygon boundary an odd number of times. + pub fn contains(&self, x: u32, y: u32) -> bool { + if self.vertices.len() < 3 { + return false; + } + + let (px, py) = (x as f32, y as f32); + let n = self.vertices.len(); + let mut inside = false; + + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = (self.vertices[i].0 as f32, self.vertices[i].1 as f32); + let (xj, yj) = (self.vertices[j].0 as f32, self.vertices[j].1 as f32); + + // Check if the ray from (px, py) going right crosses the edge (i, j) + if ((yi > py) != (yj > py)) + && (px < (xj - xi) * (py - yi) / (yj - yi) + xi) + { + inside = !inside; + } + j = i; + } + + inside + } + + /// Get the bounding box of the polygon as (min_x, min_y, max_x, max_y) + pub fn bounding_box(&self) -> Option<(u32, u32, u32, u32)> { + if self.vertices.is_empty() { + return None; + } + + let mut min_x = u32::MAX; + let mut min_y = u32::MAX; + let mut max_x = 0u32; + let mut max_y = 0u32; + + for &(x, y) in &self.vertices { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + + Some((min_x, min_y, max_x, max_y)) + } +} + +/// Pre-computed polygon mask bitmap for efficient runtime application +/// +/// The mask is computed once at startup and stored as a bitmap where +/// `true` means the pixel is valid for detection and `false` means masked. +#[derive(Debug, Clone)] +pub struct PolygonMask { + /// Bitmap: true = valid for detection, false = masked + mask: Vec, + /// Image width + width: u32, + /// Image height + height: u32, + /// Original configuration (for visualization) + config: PolygonMaskConfig, +} + +impl PolygonMask { + /// Create a polygon mask from configuration + /// + /// This pre-computes the mask bitmap by testing each pixel against + /// all polygons. For a 1920x1080 image with a few simple polygons, + /// this takes approximately 50-100ms. + pub fn from_config(config: PolygonMaskConfig, width: u32, height: u32) -> Self { + let size = (width * height) as usize; + let mut mask = vec![true; size]; // Default: all pixels valid + + if !config.enabled || config.polygons.is_empty() { + return Self { + mask, + width, + height, + config, + }; + } + + // Pre-compute bounding boxes for optimization + let bboxes: Vec> = config + .polygons + .iter() + .map(|p| p.bounding_box()) + .collect(); + + for y in 0..height { + for x in 0..width { + // Check if point is in any polygon (with bounding box optimization) + let in_any_polygon = config + .polygons + .iter() + .zip(bboxes.iter()) + .any(|(polygon, bbox)| { + // Quick bounding box check first + if let Some((min_x, min_y, max_x, max_y)) = bbox { + if x < *min_x || x > *max_x || y < *min_y || y > *max_y { + return false; + } + } + polygon.contains(x, y) + }); + + let idx = (y * width + x) as usize; + mask[idx] = match config.mode { + MaskMode::Exclude => !in_any_polygon, // Inside polygon = masked + MaskMode::Include => in_any_polygon, // Outside polygon = masked + }; + } + } + + Self { + mask, + width, + height, + config, + } + } + + /// Create a disabled (pass-through) mask + pub fn disabled(width: u32, height: u32) -> Self { + Self::from_config(PolygonMaskConfig::default(), width, height) + } + + /// Apply the mask to a binary image + /// + /// Pixels that are `true` in the binary image but `false` in the mask + /// will be set to `false`. + #[inline] + pub fn apply(&self, binary: &mut super::BinaryImage) { + debug_assert_eq!( + binary.width, self.width, + "Mask width mismatch: {} vs {}", + binary.width, self.width + ); + debug_assert_eq!( + binary.height, self.height, + "Mask height mismatch: {} vs {}", + binary.height, self.height + ); + + for (i, pixel) in binary.data.iter_mut().enumerate() { + if *pixel && !self.mask[i] { + *pixel = false; + } + } + } + + /// Apply mask to a boolean threshold mask (Vec) + #[inline] + pub fn apply_to_vec(&self, threshold_mask: &mut Vec) { + debug_assert_eq!( + threshold_mask.len(), + self.mask.len(), + "Mask size mismatch" + ); + + for (i, pixel) in threshold_mask.iter_mut().enumerate() { + if *pixel && !self.mask[i] { + *pixel = false; + } + } + } + + /// Check if a single pixel is valid for detection + #[inline] + pub fn is_valid(&self, x: u32, y: u32) -> bool { + if x >= self.width || y >= self.height { + return false; + } + let idx = (y * self.width + x) as usize; + self.mask[idx] + } + + /// Get the mask bitmap directly (for advanced use) + pub fn mask_data(&self) -> &[bool] { + &self.mask + } + + /// Get mask dimensions + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + + /// Get the original configuration + pub fn config(&self) -> &PolygonMaskConfig { + &self.config + } + + /// Count masked pixels + pub fn masked_count(&self) -> usize { + self.mask.iter().filter(|&&v| !v).count() + } + + /// Count valid pixels + pub fn valid_count(&self) -> usize { + self.mask.iter().filter(|&&v| v).count() + } + + /// Get mask coverage ratio (masked pixels / total pixels) + pub fn coverage_ratio(&self) -> f32 { + self.masked_count() as f32 / self.mask.len() as f32 + } +} + +/// Thread-safe shared polygon mask +pub type SharedPolygonMask = Arc; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_polygon_contains_square() { + let square = Polygon::rectangle(100, 100, 200, 200); + + // Inside + assert!(square.contains(150, 150)); + assert!(square.contains(200, 200)); + + // Outside + assert!(!square.contains(50, 50)); + assert!(!square.contains(350, 350)); + + // Edge cases (on boundary - implementation dependent) + assert!(square.contains(100, 150)); // Left edge + assert!(square.contains(299, 150)); // Right edge + } + + #[test] + fn test_polygon_contains_triangle() { + let triangle = Polygon::new(vec![(100, 100), (200, 100), (150, 200)]); + + // Inside + assert!(triangle.contains(150, 150)); + + // Outside + assert!(!triangle.contains(50, 50)); + assert!(!triangle.contains(180, 180)); + } + + #[test] + fn test_mask_exclude_mode() { + let config = PolygonMaskConfig { + enabled: true, + mode: MaskMode::Exclude, + polygons: vec![Polygon::rectangle(0, 50, 100, 50)], // Mask bottom half + }; + + let mask = PolygonMask::from_config(config, 100, 100); + + // Top half should be valid + assert!(mask.is_valid(50, 25)); + + // Bottom half should be masked + assert!(!mask.is_valid(50, 75)); + } + + #[test] + fn test_mask_include_mode() { + let config = PolygonMaskConfig { + enabled: true, + mode: MaskMode::Include, + polygons: vec![Polygon::rectangle(25, 25, 50, 50)], // Only center + }; + + let mask = PolygonMask::from_config(config, 100, 100); + + // Center should be valid + assert!(mask.is_valid(50, 50)); + + // Outside should be masked + assert!(!mask.is_valid(10, 10)); + assert!(!mask.is_valid(90, 90)); + } + + #[test] + fn test_mask_disabled() { + let config = PolygonMaskConfig { + enabled: false, + mode: MaskMode::Exclude, + polygons: vec![Polygon::rectangle(0, 0, 100, 100)], + }; + + let mask = PolygonMask::from_config(config, 100, 100); + + // Everything should be valid when disabled + assert!(mask.is_valid(50, 50)); + assert_eq!(mask.valid_count(), 10000); + } + + #[test] + fn test_polygon_bounding_box() { + let polygon = Polygon::new(vec![(10, 20), (100, 30), (50, 150)]); + let bbox = polygon.bounding_box().unwrap(); + assert_eq!(bbox, (10, 20, 100, 150)); + } + + #[test] + fn test_config_serialization() { + let config = PolygonMaskConfig { + enabled: true, + mode: MaskMode::Exclude, + polygons: vec![ + Polygon::with_label(vec![(0, 900), (1920, 900), (1920, 1080), (0, 1080)], "ground"), + ], + }; + + let json = serde_json::to_string_pretty(&config).unwrap(); + println!("{}", json); + + let parsed: PolygonMaskConfig = serde_json::from_str(&json).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.mode, MaskMode::Exclude); + assert_eq!(parsed.polygons.len(), 1); + assert_eq!(parsed.polygons[0].label, Some("ground".to_string())); + } +} diff --git a/meteor-edge-client/src/detection/vida/meteor_detector.rs b/meteor-edge-client/src/detection/vida/meteor_detector.rs index e7bcb0a..b6ea9ba 100644 --- a/meteor-edge-client/src/detection/vida/meteor_detector.rs +++ b/meteor-edge-client/src/detection/vida/meteor_detector.rs @@ -11,7 +11,8 @@ use crate::detection::vida::{ AccumulatedFrame, BinaryImage, HoughLineDetector, Line2D, LineSegment3D, - LineSegment3DDetector, MeteorDetectorConfig, Morphology, Point3D, TemporalWindow, + LineSegment3DDetector, MeteorDetectorConfig, Morphology, Point3D, PolygonMask, + SharedPolygonMask, TemporalWindow, }; /// Meteor detection result @@ -98,6 +99,8 @@ pub struct MeteorDetector { morphology: Morphology, hough_detector: HoughLineDetector, line_3d_detector: LineSegment3DDetector, + /// Optional polygon mask for filtering out ground/obstacles + polygon_mask: Option, } impl MeteorDetector { @@ -111,6 +114,7 @@ impl MeteorDetector { morphology, hough_detector, line_3d_detector, + polygon_mask: None, } } @@ -118,6 +122,16 @@ impl MeteorDetector { Self::new(MeteorDetectorConfig::default()) } + /// Set the polygon mask for filtering detections + pub fn set_mask(&mut self, mask: SharedPolygonMask) { + self.polygon_mask = Some(mask); + } + + /// Clear the polygon mask + pub fn clear_mask(&mut self) { + self.polygon_mask = None; + } + /// Detect meteors in an accumulated frame pub fn detect(&self, frame: &AccumulatedFrame) -> Vec { // Step 1: Pre-check (white pixel ratio) at original resolution @@ -149,7 +163,12 @@ impl MeteorDetector { // Create binary image at original resolution let window_mask = self.threshold_window_fast(&window_maxpixel, &thresholds); - let window_binary = BinaryImage::from_threshold(window_mask, frame.width, frame.height); + let mut window_binary = BinaryImage::from_threshold(window_mask, frame.width, frame.height); + + // Apply polygon mask if configured (before morphology to avoid edge artifacts) + if let Some(ref mask) = self.polygon_mask { + mask.apply(&mut window_binary); + } // Key optimization: downsample binary image (not FTP data) // OR operation preserves meteor trajectory connectivity diff --git a/meteor-edge-client/src/detection/vida/mod.rs b/meteor-edge-client/src/detection/vida/mod.rs index 88e1f1d..1e2e197 100644 --- a/meteor-edge-client/src/detection/vida/mod.rs +++ b/meteor-edge-client/src/detection/vida/mod.rs @@ -22,6 +22,7 @@ pub mod star_extractor; pub mod ftpdetect; pub mod calibration; pub mod controller; +pub mod mask; pub use accumulated_frame::*; pub use config::*; @@ -34,3 +35,4 @@ pub use star_extractor::*; pub use ftpdetect::*; pub use calibration::*; pub use controller::*; +pub use mask::*; diff --git a/meteor-edge-client/src/main.rs b/meteor-edge-client/src/main.rs index bc85b40..854a3ea 100644 --- a/meteor-edge-client/src/main.rs +++ b/meteor-edge-client/src/main.rs @@ -115,6 +115,10 @@ enum Commands { /// Maximum frames to process (default: all frames) #[arg(long)] max_frames: Option, + + /// Path to polygon mask configuration file (JSON) + #[arg(long)] + mask: Option, }, } @@ -203,8 +207,9 @@ async fn main() -> Result<()> { video, k1, max_frames, + mask, } => { - if let Err(e) = test_vida(video.clone(), *k1, *max_frames).await { + if let Err(e) = test_vida(video.clone(), *k1, *max_frames, mask.clone()).await { eprintln!("❌ Vida test failed: {}", e); std::process::exit(1); } @@ -886,8 +891,8 @@ fn draw_line(img: &mut image::RgbImage, x0: i32, y0: i32, x1: i32, y1: i32, colo /// Test Vida four-frame meteor detection algorithm with a video file /// Uses FFmpeg pipe for zero-disk-IO streaming -async fn test_vida(video_path: String, k1: f32, max_frames: Option) -> Result<()> { - use crate::detection::vida::{VidaConfig, VidaDetectionController, MeteorDetectorConfig}; +async fn test_vida(video_path: String, k1: f32, max_frames: Option, mask_path: Option) -> Result<()> { + use crate::detection::vida::{VidaConfig, VidaDetectionController, MeteorDetectorConfig, PolygonMaskConfig}; use std::process::{Command, Stdio}; use std::io::{BufReader, Read}; use std::time::Instant; @@ -897,6 +902,9 @@ async fn test_vida(video_path: String, k1: f32, max_frames: Option) -> Resu if let Some(max) = max_frames { println!("Max frames: {}", max); } + if let Some(ref mask) = mask_path { + println!("Mask config: {}", mask); + } println!(); // Normalize and validate video path @@ -1073,6 +1081,25 @@ async fn test_vida(video_path: String, k1: f32, max_frames: Option) -> Resu ..config.meteor }; + // Load and apply polygon mask if specified + if let Some(ref mask_file) = mask_path { + let mask_file_path = normalize_path(mask_file); + let mask_path_buf = PathBuf::from(&mask_file_path); + if !mask_path_buf.exists() { + anyhow::bail!("Mask config file not found: {}", mask_file_path); + } + let mask_json = std::fs::read_to_string(&mask_path_buf)?; + let mask_config: PolygonMaskConfig = serde_json::from_str(&mask_json) + .map_err(|e| anyhow::anyhow!("Failed to parse mask config: {}", e))?; + + println!("Loaded mask config: {} polygons, mode: {:?}, enabled: {}", + mask_config.polygons.len(), + mask_config.mode, + mask_config.enabled + ); + config.mask = mask_config; + } + let mut controller = VidaDetectionController::new(config, width, height); // Processing stats diff --git a/meteor-edge-client/test_mask.json b/meteor-edge-client/test_mask.json new file mode 100644 index 0000000..f8aa914 --- /dev/null +++ b/meteor-edge-client/test_mask.json @@ -0,0 +1,10 @@ +{ + "enabled": true, + "mode": "exclude", + "polygons": [ + { + "label": "ground", + "vertices": [[0, 900], [1920, 900], [1920, 1080], [0, 1080]] + } + ] +} diff --git a/meteor-edge-client/video3_mask.json b/meteor-edge-client/video3_mask.json new file mode 100644 index 0000000..38ae567 --- /dev/null +++ b/meteor-edge-client/video3_mask.json @@ -0,0 +1,22 @@ +{ + "enabled": true, + "mode": "exclude", + "polygons": [ + { + "label": "ground_horizon", + "vertices": [[0, 700], [1920, 700], [1920, 1080], [0, 1080]] + }, + { + "label": "text_overlay", + "vertices": [[0, 590], [280, 590], [280, 670], [0, 670]] + }, + { + "label": "tree_right", + "vertices": [[1750, 0], [1920, 0], [1920, 700], [1850, 700], [1800, 500], [1750, 300]] + }, + { + "label": "vegetation_left", + "vertices": [[0, 550], [150, 600], [200, 700], [0, 700]] + } + ] +}