feat(vida): add polygon mask to filter ground/obstacle false detections
Introduce configurable polygon masks that block ground, trees, and other obstacles from triggering false meteor/fireball detections. Masks are applied after thresholding but before morphology and line detection, shared via Arc between both detectors. CLI supports --mask <json> flag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b79cf37d0
commit
66fa00aa1a
@ -19,6 +19,9 @@ pub struct VidaConfig {
|
|||||||
pub star_extraction: StarExtractionConfig,
|
pub star_extraction: StarExtractionConfig,
|
||||||
/// Calibration configuration
|
/// Calibration configuration
|
||||||
pub calibration: CalibrationSettings,
|
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
|
/// Whether to enable star extraction for sky quality check
|
||||||
pub enable_star_extraction: bool,
|
pub enable_star_extraction: bool,
|
||||||
/// Minimum number of stars required for detection (skip if cloudy)
|
/// Minimum number of stars required for detection (skip if cloudy)
|
||||||
@ -34,6 +37,7 @@ impl Default for VidaConfig {
|
|||||||
line_detector: LineDetector3DConfig::default(),
|
line_detector: LineDetector3DConfig::default(),
|
||||||
star_extraction: StarExtractionConfig::default(),
|
star_extraction: StarExtractionConfig::default(),
|
||||||
calibration: CalibrationSettings::default(),
|
calibration: CalibrationSettings::default(),
|
||||||
|
mask: super::mask::PolygonMaskConfig::default(),
|
||||||
enable_star_extraction: false,
|
enable_star_extraction: false,
|
||||||
min_stars_for_detection: 500,
|
min_stars_for_detection: 500,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
use crate::detection::vida::{
|
use crate::detection::vida::{
|
||||||
AccumulatedFrame, AccumulatorConfig, FireballDetection, FireballDetector,
|
AccumulatedFrame, AccumulatorConfig, FireballDetection, FireballDetector,
|
||||||
FrameAccumulator, MeteorDetection, MeteorDetector, VidaConfig,
|
FrameAccumulator, MeteorDetection, MeteorDetector, PolygonMask, SharedPolygonMask,
|
||||||
|
VidaConfig,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@ -84,8 +85,21 @@ impl VidaDetectionController {
|
|||||||
/// Create a new detection controller
|
/// Create a new detection controller
|
||||||
pub fn new(config: VidaConfig, width: u32, height: u32) -> Self {
|
pub fn new(config: VidaConfig, width: u32, height: u32) -> Self {
|
||||||
let accumulator = FrameAccumulator::new(width, height, config.accumulator.clone());
|
let accumulator = FrameAccumulator::new(width, height, config.accumulator.clone());
|
||||||
let fireball_detector = FireballDetector::new(config.fireball.clone());
|
let mut fireball_detector = FireballDetector::new(config.fireball.clone());
|
||||||
let meteor_detector = MeteorDetector::new(config.meteor.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 {
|
Self {
|
||||||
config,
|
config,
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use crate::detection::vida::{
|
use crate::detection::vida::{
|
||||||
AccumulatedFrame, FireballDetectorConfig, LineSegment3D, LineSegment3DDetector, Point3D,
|
AccumulatedFrame, FireballDetectorConfig, LineSegment3D, LineSegment3DDetector, Point3D,
|
||||||
|
SharedPolygonMask,
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@ -48,6 +49,8 @@ impl FireballDetection {
|
|||||||
pub struct FireballDetector {
|
pub struct FireballDetector {
|
||||||
config: FireballDetectorConfig,
|
config: FireballDetectorConfig,
|
||||||
line_detector: LineSegment3DDetector,
|
line_detector: LineSegment3DDetector,
|
||||||
|
/// Optional polygon mask for filtering out ground/obstacles
|
||||||
|
polygon_mask: Option<SharedPolygonMask>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FireballDetector {
|
impl FireballDetector {
|
||||||
@ -64,6 +67,7 @@ impl FireballDetector {
|
|||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
line_detector: LineSegment3DDetector::new(line_config),
|
line_detector: LineSegment3DDetector::new(line_config),
|
||||||
|
polygon_mask: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +75,16 @@ impl FireballDetector {
|
|||||||
Self::new(FireballDetectorConfig::default())
|
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
|
/// Detect fireballs in an accumulated frame
|
||||||
///
|
///
|
||||||
/// Algorithm:
|
/// Algorithm:
|
||||||
@ -85,11 +99,16 @@ impl FireballDetector {
|
|||||||
/// bottleneck is 3D line detection, not image processing.
|
/// bottleneck is 3D line detection, not image processing.
|
||||||
pub fn detect(&self, frame: &AccumulatedFrame) -> Vec<FireballDetection> {
|
pub fn detect(&self, frame: &AccumulatedFrame) -> Vec<FireballDetection> {
|
||||||
// Step 1: Threshold to get bright pixels at original resolution
|
// 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.k1_threshold,
|
||||||
self.config.min_intensity,
|
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
|
// Step 2: Build 3D point cloud
|
||||||
let mut point_cloud = frame.to_point_cloud(&threshold_mask);
|
let mut point_cloud = frame.to_point_cloud(&threshold_mask);
|
||||||
|
|
||||||
|
|||||||
413
meteor-edge-client/src/detection/vida/mask.rs
Normal file
413
meteor-edge-client/src/detection/vida/mask.rs
Normal file
@ -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<Polygon>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>) -> 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<bool>,
|
||||||
|
/// 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<Option<(u32, u32, u32, u32)>> = 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<bool>)
|
||||||
|
#[inline]
|
||||||
|
pub fn apply_to_vec(&self, threshold_mask: &mut Vec<bool>) {
|
||||||
|
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<PolygonMask>;
|
||||||
|
|
||||||
|
#[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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
use crate::detection::vida::{
|
use crate::detection::vida::{
|
||||||
AccumulatedFrame, BinaryImage, HoughLineDetector, Line2D, LineSegment3D,
|
AccumulatedFrame, BinaryImage, HoughLineDetector, Line2D, LineSegment3D,
|
||||||
LineSegment3DDetector, MeteorDetectorConfig, Morphology, Point3D, TemporalWindow,
|
LineSegment3DDetector, MeteorDetectorConfig, Morphology, Point3D, PolygonMask,
|
||||||
|
SharedPolygonMask, TemporalWindow,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Meteor detection result
|
/// Meteor detection result
|
||||||
@ -98,6 +99,8 @@ pub struct MeteorDetector {
|
|||||||
morphology: Morphology,
|
morphology: Morphology,
|
||||||
hough_detector: HoughLineDetector,
|
hough_detector: HoughLineDetector,
|
||||||
line_3d_detector: LineSegment3DDetector,
|
line_3d_detector: LineSegment3DDetector,
|
||||||
|
/// Optional polygon mask for filtering out ground/obstacles
|
||||||
|
polygon_mask: Option<SharedPolygonMask>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MeteorDetector {
|
impl MeteorDetector {
|
||||||
@ -111,6 +114,7 @@ impl MeteorDetector {
|
|||||||
morphology,
|
morphology,
|
||||||
hough_detector,
|
hough_detector,
|
||||||
line_3d_detector,
|
line_3d_detector,
|
||||||
|
polygon_mask: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,6 +122,16 @@ impl MeteorDetector {
|
|||||||
Self::new(MeteorDetectorConfig::default())
|
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
|
/// Detect meteors in an accumulated frame
|
||||||
pub fn detect(&self, frame: &AccumulatedFrame) -> Vec<MeteorDetection> {
|
pub fn detect(&self, frame: &AccumulatedFrame) -> Vec<MeteorDetection> {
|
||||||
// Step 1: Pre-check (white pixel ratio) at original resolution
|
// Step 1: Pre-check (white pixel ratio) at original resolution
|
||||||
@ -149,7 +163,12 @@ impl MeteorDetector {
|
|||||||
|
|
||||||
// Create binary image at original resolution
|
// Create binary image at original resolution
|
||||||
let window_mask = self.threshold_window_fast(&window_maxpixel, &thresholds);
|
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)
|
// Key optimization: downsample binary image (not FTP data)
|
||||||
// OR operation preserves meteor trajectory connectivity
|
// OR operation preserves meteor trajectory connectivity
|
||||||
|
|||||||
@ -22,6 +22,7 @@ pub mod star_extractor;
|
|||||||
pub mod ftpdetect;
|
pub mod ftpdetect;
|
||||||
pub mod calibration;
|
pub mod calibration;
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
|
pub mod mask;
|
||||||
|
|
||||||
pub use accumulated_frame::*;
|
pub use accumulated_frame::*;
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
@ -34,3 +35,4 @@ pub use star_extractor::*;
|
|||||||
pub use ftpdetect::*;
|
pub use ftpdetect::*;
|
||||||
pub use calibration::*;
|
pub use calibration::*;
|
||||||
pub use controller::*;
|
pub use controller::*;
|
||||||
|
pub use mask::*;
|
||||||
|
|||||||
@ -115,6 +115,10 @@ enum Commands {
|
|||||||
/// Maximum frames to process (default: all frames)
|
/// Maximum frames to process (default: all frames)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
max_frames: Option<u32>,
|
max_frames: Option<u32>,
|
||||||
|
|
||||||
|
/// Path to polygon mask configuration file (JSON)
|
||||||
|
#[arg(long)]
|
||||||
|
mask: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,8 +207,9 @@ async fn main() -> Result<()> {
|
|||||||
video,
|
video,
|
||||||
k1,
|
k1,
|
||||||
max_frames,
|
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);
|
eprintln!("❌ Vida test failed: {}", e);
|
||||||
std::process::exit(1);
|
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
|
/// Test Vida four-frame meteor detection algorithm with a video file
|
||||||
/// Uses FFmpeg pipe for zero-disk-IO streaming
|
/// Uses FFmpeg pipe for zero-disk-IO streaming
|
||||||
async fn test_vida(video_path: String, k1: f32, max_frames: Option<u32>) -> Result<()> {
|
async fn test_vida(video_path: String, k1: f32, max_frames: Option<u32>, mask_path: Option<String>) -> Result<()> {
|
||||||
use crate::detection::vida::{VidaConfig, VidaDetectionController, MeteorDetectorConfig};
|
use crate::detection::vida::{VidaConfig, VidaDetectionController, MeteorDetectorConfig, PolygonMaskConfig};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::io::{BufReader, Read};
|
use std::io::{BufReader, Read};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@ -897,6 +902,9 @@ async fn test_vida(video_path: String, k1: f32, max_frames: Option<u32>) -> Resu
|
|||||||
if let Some(max) = max_frames {
|
if let Some(max) = max_frames {
|
||||||
println!("Max frames: {}", max);
|
println!("Max frames: {}", max);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mask) = mask_path {
|
||||||
|
println!("Mask config: {}", mask);
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Normalize and validate video path
|
// Normalize and validate video path
|
||||||
@ -1073,6 +1081,25 @@ async fn test_vida(video_path: String, k1: f32, max_frames: Option<u32>) -> Resu
|
|||||||
..config.meteor
|
..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);
|
let mut controller = VidaDetectionController::new(config, width, height);
|
||||||
|
|
||||||
// Processing stats
|
// Processing stats
|
||||||
|
|||||||
10
meteor-edge-client/test_mask.json
Normal file
10
meteor-edge-client/test_mask.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"mode": "exclude",
|
||||||
|
"polygons": [
|
||||||
|
{
|
||||||
|
"label": "ground",
|
||||||
|
"vertices": [[0, 900], [1920, 900], [1920, 1080], [0, 1080]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
22
meteor-edge-client/video3_mask.json
Normal file
22
meteor-edge-client/video3_mask.json
Normal file
@ -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]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user