use crate::core::error::Result;
use crate::core::geometry::Circle;
use crate::core::gradient::{compute_gradient, GradientOperator};
use crate::core::image_view::ImageView;
use crate::core::nms::NmsConfig;
use crate::core::polarity::Polarity;
use crate::core::scalar::Scalar;
use crate::propose::extract::extract_proposals;
use crate::propose::frst::{frst_response, FrstConfig};
use crate::refine::circle::{refine_circle, CircleRefineConfig};
use crate::refine::result::RefinementStatus;
use crate::support::score::{score_circle_support, ScoringConfig, SupportScore};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DetectCirclesConfig {
pub frst: FrstConfig,
pub nms: NmsConfig,
pub scoring: ScoringConfig,
pub refinement: CircleRefineConfig,
pub polarity: Polarity,
pub radius_hint: Scalar,
pub min_score: Scalar,
pub gradient_operator: GradientOperator,
}
impl Default for DetectCirclesConfig {
fn default() -> Self {
Self {
frst: FrstConfig::default(),
nms: NmsConfig::default(),
scoring: ScoringConfig::default(),
refinement: CircleRefineConfig::default(),
polarity: Polarity::Both,
radius_hint: 10.0,
min_score: 0.0,
gradient_operator: GradientOperator::default(),
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
feature = "serde",
serde(bound(
serialize = "T: serde::Serialize",
deserialize = "T: serde::de::DeserializeOwned"
))
)]
pub struct Detection<T> {
pub hypothesis: T,
pub score: SupportScore,
pub status: RefinementStatus,
}
pub fn detect_circles(
image: &ImageView<'_, u8>,
config: &DetectCirclesConfig,
) -> Result<Vec<Detection<Circle>>> {
config.refinement.validate()?;
let gradient = compute_gradient(image, config.gradient_operator)?;
let mut frst_config = config.frst.clone();
frst_config.polarity = config.polarity;
let response = frst_response(&gradient, &frst_config)?;
let proposals = extract_proposals(&response, &config.nms, config.polarity);
let mut detections: Vec<Detection<Circle>> = proposals
.iter()
.filter_map(|proposal| {
let circle = Circle::new(proposal.seed.position, config.radius_hint);
let score = score_circle_support(&gradient, &circle, &config.scoring);
if score.is_degenerate || score.total < config.min_score {
return None;
}
let refined = refine_circle(&gradient, &circle, &config.refinement).ok()?;
Some(Detection {
hypothesis: refined.hypothesis,
score,
status: refined.status,
})
})
.collect();
detections.sort_by(|a, b| {
b.score
.total
.partial_cmp(&a.score.total)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(detections)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_circles_finds_synthetic_disk() {
let size = 128;
let cx = 64.0f32;
let cy = 64.0f32;
let radius = 18.0f32;
let mut data = vec![0u8; size * size];
for y in 0..size {
for x in 0..size {
let dx = x as f32 - cx;
let dy = y as f32 - cy;
if (dx * dx + dy * dy).sqrt() <= radius {
data[y * size + x] = 255;
}
}
}
let image = ImageView::from_slice(&data, size, size).unwrap();
let config = DetectCirclesConfig {
frst: FrstConfig {
radii: vec![17, 18, 19],
gradient_threshold: 1.0,
..FrstConfig::default()
},
polarity: Polarity::Bright,
radius_hint: radius,
..DetectCirclesConfig::default()
};
let detections = detect_circles(&image, &config).unwrap();
assert!(!detections.is_empty(), "should detect the synthetic disk");
let best = &detections[0];
let dx = best.hypothesis.center.x - cx;
let dy = best.hypothesis.center.y - cy;
assert!(
(dx * dx + dy * dy).sqrt() < 3.0,
"center should be near ({cx}, {cy}), got ({}, {})",
best.hypothesis.center.x,
best.hypothesis.center.y,
);
}
#[test]
fn invalid_refinement_config_returns_error() {
let size = 64;
let data = vec![128u8; size * size];
let image = ImageView::from_slice(&data, size, size).unwrap();
let config = DetectCirclesConfig {
refinement: CircleRefineConfig {
max_iterations: 0,
..CircleRefineConfig::default()
},
..DetectCirclesConfig::default()
};
let result = detect_circles(&image, &config);
assert!(
matches!(
result,
Err(crate::core::error::RadSymError::InvalidConfig { .. })
),
"expected InvalidConfig error, got {result:?}"
);
}
}