mod config;
pub use config::ProposalConfig;
use image::GrayImage;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Proposal {
pub x: f32,
pub y: f32,
pub score: f32,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProposalResult {
pub image_size: [u32; 2],
pub heatmap: Vec<f32>,
pub proposals: Vec<Proposal>,
}
pub fn find_ellipse_centers(gray: &GrayImage, config: &ProposalConfig) -> Vec<Proposal> {
compute_via_radsym(gray, config, false).0
}
pub fn find_ellipse_centers_with_heatmap(
gray: &GrayImage,
config: &ProposalConfig,
) -> ProposalResult {
let (w, h) = gray.dimensions();
let n = w as usize * h as usize;
let (proposals, heatmap) = compute_via_radsym(gray, config, true);
ProposalResult {
image_size: [w, h],
heatmap: heatmap.unwrap_or_else(|| vec![0.0; n]),
proposals,
}
}
fn build_radii(r_min: f32, r_max: f32) -> Vec<u32> {
let lo = (r_min.ceil() as u32).max(1);
let hi = r_max.floor() as u32;
if hi < lo {
return Vec::new();
}
(lo..=hi).collect()
}
fn compute_via_radsym(
gray: &GrayImage,
config: &ProposalConfig,
keep_heatmap: bool,
) -> (Vec<Proposal>, Option<Vec<f32>>) {
let (w, h) = gray.dimensions();
if w < 4 || h < 4 || config.r_max < config.r_min {
let heatmap = keep_heatmap.then(|| vec![0.0; w as usize * h as usize]);
return (Vec::new(), heatmap);
}
let radii = build_radii(config.r_min, config.r_max);
if radii.is_empty() {
let heatmap = keep_heatmap.then(|| vec![0.0; w as usize * h as usize]);
return (Vec::new(), heatmap);
}
let view = radsym::ImageView::from_slice(gray.as_raw(), w as usize, h as usize)
.expect("GrayImage dimensions always valid");
let gradient = match radsym::scharr_gradient(&view) {
Ok(g) => g,
Err(_) => {
let heatmap = keep_heatmap.then(|| vec![0.0; w as usize * h as usize]);
return (Vec::new(), heatmap);
}
};
let max_mag = gradient.max_magnitude();
if max_mag < 1e-6 {
let heatmap = keep_heatmap.then(|| vec![0.0; w as usize * h as usize]);
return (Vec::new(), heatmap);
}
let abs_threshold = config.grad_threshold * max_mag;
let rsd_config = radsym::RsdConfig {
radii,
gradient_threshold: abs_threshold,
polarity: radsym::Polarity::Both,
smoothing_factor: 0.5,
};
let response = match radsym::rsd_response_fused(&gradient, &rsd_config) {
Ok(r) => r,
Err(_) => {
let heatmap = keep_heatmap.then(|| vec![0.0; w as usize * h as usize]);
return (Vec::new(), heatmap);
}
};
let response_max = response
.response()
.data()
.iter()
.copied()
.fold(0.0f32, f32::max);
let nms_threshold = config.min_vote_frac * response_max;
let nms_radius = (config.min_distance as usize).clamp(1, 10);
let initial_budget = config.max_candidates.unwrap_or(4096).max(512);
let nms_config = radsym::NmsConfig {
radius: nms_radius,
threshold: nms_threshold,
max_detections: initial_budget,
};
let radsym_proposals =
radsym::extract_proposals(&response, &nms_config, radsym::Polarity::Both);
let max_detections = config.max_candidates.unwrap_or(initial_budget);
let suppressed = radsym::suppress_proposals_by_distance(
&radsym_proposals,
config.min_distance,
max_detections,
);
let proposals: Vec<Proposal> = suppressed
.iter()
.map(|p| Proposal {
x: p.seed.position.x,
y: p.seed.position.y,
score: p.seed.score,
})
.collect();
let heatmap = if keep_heatmap {
Some(response.into_response().into_data())
} else {
drop(response);
None
};
(proposals, heatmap)
}
#[cfg(test)]
mod tests;