use image::GrayImage;
use crate::marker::{AngularAggregator, GradPolarity};
use crate::pixelmap::PixelMapper;
use super::edge_sample::DistortionAwareSampler;
use super::radial_estimator::{RadialSampleGrid, RadialScanResult, scan_radial_derivatives};
use super::radial_profile;
use super::radial_profile::Polarity;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum OuterStatus {
Ok,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OuterEstimateFailure {
InvalidSearchWindow,
InsufficientThetaCoverage { observed: f32, min_required: f32 },
NoPolarityCandidates,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OuterHypothesis {
pub r_outer_px: f32,
pub peak_strength: f32,
pub theta_consistency: f32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OuterEstimate {
pub r_outer_expected_px: f32,
pub search_window_px: [f32; 2],
pub polarity: Option<Polarity>,
pub hypotheses: Vec<OuterHypothesis>,
pub status: OuterStatus,
pub failure: Option<OuterEstimateFailure>,
pub radial_response_agg: Option<Vec<f32>>,
pub r_samples: Option<Vec<f32>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct OuterEstimationConfig {
pub search_halfwidth_px: f32,
pub radial_samples: usize,
pub aggregator: AngularAggregator,
pub grad_polarity: GradPolarity,
pub min_theta_coverage: f32,
pub min_theta_consistency: f32,
pub allow_two_hypotheses: bool,
pub second_peak_min_rel: f32,
pub refine_halfwidth_px: f32,
}
impl Default for OuterEstimationConfig {
fn default() -> Self {
Self {
search_halfwidth_px: 4.0,
radial_samples: 64,
aggregator: AngularAggregator::Median,
grad_polarity: GradPolarity::DarkToLight,
min_theta_coverage: 0.6,
min_theta_consistency: 0.35,
allow_two_hypotheses: true,
second_peak_min_rel: 0.85,
refine_halfwidth_px: 1.0,
}
}
}
fn failed_outer_estimate(
r_outer_expected_px: f32,
search_window_px: [f32; 2],
failure: OuterEstimateFailure,
r_samples: Option<Vec<f32>>,
) -> OuterEstimate {
OuterEstimate {
r_outer_expected_px,
search_window_px,
polarity: None,
hypotheses: Vec::new(),
status: OuterStatus::Failed,
failure: Some(failure),
radial_response_agg: None,
r_samples,
}
}
fn find_local_peaks(score: &[f32]) -> Vec<usize> {
if score.len() < 3 {
return Vec::new();
}
let mut out = Vec::new();
for i in 1..(score.len() - 1) {
if score[i].is_finite() && score[i] >= score[i - 1] && score[i] >= score[i + 1] {
out.push(i);
}
}
out
}
#[allow(clippy::type_complexity)]
fn setup_outer_search_window(
r_outer_expected_px: f32,
cfg: &OuterEstimationConfig,
) -> Result<([f32; 2], Vec<Polarity>, bool, bool, RadialSampleGrid), OuterEstimate> {
let r_expected = r_outer_expected_px.max(1.0);
let hw = cfg.search_halfwidth_px.max(0.5);
let mut window = [r_expected - hw, r_expected + hw];
window[0] = window[0].max(1.0);
if window[1] <= window[0] + 1e-3 {
return Err(failed_outer_estimate(
r_expected,
window,
OuterEstimateFailure::InvalidSearchWindow,
None,
));
}
let n_r = cfg.radial_samples.max(7);
let polarity_candidates: Vec<Polarity> = match cfg.grad_polarity {
GradPolarity::DarkToLight => vec![Polarity::Pos],
GradPolarity::LightToDark => vec![Polarity::Neg],
GradPolarity::Auto => vec![Polarity::Pos, Polarity::Neg],
};
let track_pos = polarity_candidates.contains(&Polarity::Pos);
let track_neg = polarity_candidates.contains(&Polarity::Neg);
let Some(grid) = RadialSampleGrid::from_window(window, n_r) else {
return Err(failed_outer_estimate(
r_expected,
window,
OuterEstimateFailure::InvalidSearchWindow,
None,
));
};
Ok((window, polarity_candidates, track_pos, track_neg, grid))
}
fn execute_outer_radial_scan(
gray: &GrayImage,
center_prior: [f32; 2],
grid: RadialSampleGrid,
polarity: (usize, bool, bool),
mapper: Option<&dyn PixelMapper>,
cfg: &OuterEstimationConfig,
fail_ctx: (f32, [f32; 2], bool),
) -> Result<RadialScanResult, OuterEstimate> {
let (n_t, track_pos, track_neg) = polarity;
let (r_expected, window, store_response) = fail_ctx;
let sampler = DistortionAwareSampler::new(gray, mapper);
let cx = center_prior[0];
let cy = center_prior[1];
let scan = scan_radial_derivatives(
grid,
n_t,
track_pos,
track_neg,
|ct, st, r_samples, i_vals| {
for (ri, &r) in r_samples.iter().enumerate() {
let x = cx + ct * r;
let y = cy + st * r;
let Some(sample) = sampler.sample_checked(x, y) else {
return false;
};
i_vals[ri] = sample;
}
true
},
);
let coverage = scan.coverage();
if scan.n_valid_theta == 0 || coverage < cfg.min_theta_coverage {
return Err(failed_outer_estimate(
r_expected,
window,
OuterEstimateFailure::InsufficientThetaCoverage {
observed: coverage,
min_required: cfg.min_theta_coverage,
},
store_response.then(|| scan.grid.r_samples.clone()),
));
}
Ok(scan)
}
fn build_hypotheses_for_polarity(
agg_resp: &[f32],
pol: Polarity,
scan: &RadialScanResult,
n_r: usize,
cfg: &OuterEstimationConfig,
) -> Vec<OuterHypothesis> {
let score_vec: Vec<f32> = match pol {
Polarity::Pos => agg_resp.to_vec(),
Polarity::Neg => agg_resp.iter().map(|v| -v).collect(),
};
let mut peaks = find_local_peaks(&score_vec);
if peaks.is_empty() {
if let Some((idx, _)) = score_vec
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
{
peaks.push(idx);
}
}
peaks.sort_by(|&a, &b| score_vec[b].partial_cmp(&score_vec[a]).unwrap());
let per_theta = scan.per_theta_peaks(pol);
let mut hypotheses: Vec<OuterHypothesis> = Vec::new();
let mut best_strength = None::<f32>;
for &pi in &peaks {
if pi == 0 || pi + 1 == n_r {
continue;
}
let r_star = scan.grid.r_samples[pi];
let peak_strength = agg_resp[pi].abs();
if best_strength.is_none() {
best_strength = Some(peak_strength);
} else if !cfg.allow_two_hypotheses {
break;
} else if let Some(bs) = best_strength
&& peak_strength < (cfg.second_peak_min_rel.clamp(0.0, 1.0) * bs)
{
break;
}
let theta_consistency =
radial_profile::theta_consistency(per_theta, r_star, scan.grid.r_step, 0.75);
if theta_consistency < cfg.min_theta_consistency {
continue;
}
hypotheses.push(OuterHypothesis {
r_outer_px: r_star,
peak_strength,
theta_consistency,
});
if hypotheses.len() >= 2 {
break;
}
}
hypotheses
}
pub fn estimate_outer_from_prior_with_mapper(
gray: &GrayImage,
center_prior: [f32; 2],
r_outer_expected_px: f32,
cfg: &OuterEstimationConfig,
theta_samples: usize,
mapper: Option<&dyn PixelMapper>,
store_response: bool,
) -> OuterEstimate {
let r_expected = r_outer_expected_px.max(1.0);
let n_t = theta_samples.max(8);
let (window, polarity_candidates, track_pos, track_neg, grid) =
match setup_outer_search_window(r_outer_expected_px, cfg) {
Ok(v) => v,
Err(est) => return est,
};
let scan = match execute_outer_radial_scan(
gray,
center_prior,
grid,
(n_t, track_pos, track_neg),
mapper,
cfg,
(r_expected, window, store_response),
) {
Ok(s) => s,
Err(est) => return est,
};
let n_r = scan.grid.r_samples.len();
let agg_resp = scan.aggregate_response(&cfg.aggregator);
let mut best: Option<(OuterEstimate, f32)> = None;
for pol in polarity_candidates {
let hypotheses = build_hypotheses_for_polarity(&agg_resp, pol, &scan, n_r, cfg);
if hypotheses.is_empty() {
continue;
}
let primary = &hypotheses[0];
let score = primary.peak_strength * primary.theta_consistency;
let est = OuterEstimate {
r_outer_expected_px: r_expected,
search_window_px: window,
polarity: Some(pol),
hypotheses,
status: OuterStatus::Ok,
failure: None,
radial_response_agg: if store_response {
Some(agg_resp.clone())
} else {
None
},
r_samples: if store_response {
Some(scan.grid.r_samples.clone())
} else {
None
},
};
match &best {
Some((_, best_score)) if *best_score >= score => {}
_ => {
best = Some((est, score));
}
}
}
best.map(|(e, _)| e).unwrap_or_else(|| {
failed_outer_estimate(
r_expected,
window,
OuterEstimateFailure::NoPolarityCandidates,
store_response.then(|| scan.grid.r_samples.clone()),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use image::{GrayImage, Luma};
fn blur_gray(img: &GrayImage, sigma: f32) -> GrayImage {
crate::test_utils::blur_gray(img, sigma)
}
#[test]
fn outer_estimator_prefers_expected_outer_over_strong_inner_edge() {
let w = 128u32;
let h = 128u32;
let cx = 64.0f32;
let cy = 64.0f32;
let r_outer = 30.0f32;
let r_strong = 22.0f32;
let mut img = GrayImage::new(w, h);
for y in 0..h {
for x in 0..w {
let dx = x as f32 - cx;
let dy = y as f32 - cy;
let r = (dx * dx + dy * dy).sqrt();
let mut val = 0.85f32;
if (r - r_outer).abs() <= 1.5 {
val = 0.40;
}
if (r - r_strong).abs() <= 1.5 {
val = 0.05;
}
img.put_pixel(x, y, Luma([(val * 255.0).round() as u8]));
}
}
let img = blur_gray(&img, 1.0);
let cfg = OuterEstimationConfig {
search_halfwidth_px: 4.0,
radial_samples: 64,
aggregator: AngularAggregator::Median,
grad_polarity: GradPolarity::DarkToLight,
min_theta_coverage: 0.5,
min_theta_consistency: 0.35,
allow_two_hypotheses: true,
second_peak_min_rel: 0.7,
refine_halfwidth_px: 1.0,
};
let est =
estimate_outer_from_prior_with_mapper(&img, [cx, cy], r_outer, &cfg, 64, None, true);
assert_eq!(
est.status,
OuterStatus::Ok,
"outer estimate failed: {:?}",
est.failure
);
assert_eq!(est.polarity, Some(Polarity::Pos));
let r_found = est.hypotheses[0].r_outer_px;
assert!(
(r_found - r_outer).abs() < 2.0,
"r_found {:.2} should be near expected {:.2}",
r_found,
r_outer
);
}
}