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, Copy, serde::Serialize, serde::Deserialize)]
pub struct RadialHarmonic {
pub c0: f32,
pub c1: f32,
pub c2: f32,
}
impl RadialHarmonic {
#[inline]
pub fn radius_at(&self, theta: f32) -> f32 {
let (s, c) = (2.0 * theta).sin_cos();
self.c0 + self.c1 * c + self.c2 * s
}
#[inline]
pub fn amplitude(&self) -> f32 {
(self.c1 * self.c1 + self.c2 * self.c2).sqrt()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OuterHypothesis {
pub r_outer_px: f32,
pub peak_strength: f32,
pub theta_consistency: f32,
#[serde(default)]
pub radius_model: Option<RadialHarmonic>,
}
#[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)]
#[non_exhaustive]
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 fit_radial_harmonic_lsq(angles: &[f32], radii: &[f32]) -> Option<RadialHarmonic> {
const MIN_POINTS: usize = 8;
debug_assert_eq!(angles.len(), radii.len());
if angles.len() < MIN_POINTS {
return None;
}
let (mut sc, mut ss, mut scc, mut sss, mut scs) = (0.0f64, 0.0, 0.0, 0.0, 0.0);
let (mut sr, mut src, mut srs) = (0.0f64, 0.0, 0.0);
for (&theta, &r) in angles.iter().zip(radii) {
let (s, c) = (2.0 * f64::from(theta)).sin_cos();
let r = f64::from(r);
sc += c;
ss += s;
scc += c * c;
sss += s * s;
scs += c * s;
sr += r;
src += r * c;
srs += r * s;
}
let n = angles.len() as f64;
let a = nalgebra::Matrix3::new(n, sc, ss, sc, scc, scs, ss, scs, sss);
let b = nalgebra::Vector3::new(sr, src, srs);
let x = a.lu().solve(&b)?;
let model = RadialHarmonic {
c0: x[0] as f32,
c1: x[1] as f32,
c2: x[2] as f32,
};
(model.c0.is_finite() && model.c1.is_finite() && model.c2.is_finite()).then_some(model)
}
struct HarmonicFit {
model: RadialHarmonic,
inlier_rms: f32,
}
fn fit_radial_harmonic(angles: &[f32], radii: &[f32], r_step: f32) -> Option<HarmonicFit> {
let first = fit_radial_harmonic_lsq(angles, radii)?;
let mut abs_residuals: Vec<f32> = angles
.iter()
.zip(radii)
.map(|(&theta, &r)| (r - first.radius_at(theta)).abs())
.collect();
let mid = abs_residuals.len() / 2;
let (_, mad, _) = abs_residuals.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
let inlier_tol = (3.0 * *mad).max(r_step);
let mut in_angles = Vec::with_capacity(angles.len());
let mut in_radii = Vec::with_capacity(radii.len());
for (&theta, &r) in angles.iter().zip(radii) {
if (r - first.radius_at(theta)).abs() <= inlier_tol {
in_angles.push(theta);
in_radii.push(r);
}
}
let model = if in_angles.len() == angles.len() {
first
} else {
fit_radial_harmonic_lsq(&in_angles, &in_radii).unwrap_or(first)
};
let mut rms_sq = 0.0f32;
for (&theta, &r) in in_angles.iter().zip(&in_radii) {
let res = r - model.radius_at(theta);
rms_sq += res * res;
}
let inlier_rms = (rms_sq / in_angles.len().max(1) as f32).sqrt();
Some(HarmonicFit { model, inlier_rms })
}
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 theta_consistency_with_model(
per_theta_peaks: &[f32],
angles: &[f32],
model: &RadialHarmonic,
r_step: f32,
min_delta: f32,
) -> f32 {
let delta = (4.0 * r_step).max(min_delta);
let n_close = per_theta_peaks
.iter()
.zip(angles)
.filter(|&(&r, &theta)| (r - model.radius_at(theta)).abs() <= delta)
.count();
n_close as f32 / per_theta_peaks.len().max(1) as f32
}
fn model_applies_to_peak(
fit: &HarmonicFit,
r_star: f32,
window: [f32; 2],
consistency_delta: f32,
) -> bool {
const MAX_RELATIVE_AMPLITUDE: f32 = 0.35;
const MIN_AMPLITUDE_TO_NOISE: f32 = 2.0;
let model = &fit.model;
let half_window = 0.5 * (window[1] - window[0]);
model.c0.is_finite()
&& (model.c0 - r_star).abs() <= 0.5 * half_window
&& model.amplitude() >= consistency_delta
&& model.amplitude() <= MAX_RELATIVE_AMPLITUDE * model.c0
&& model.amplitude() >= MIN_AMPLITUDE_TO_NOISE * fit.inlier_rms
}
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.total_cmp(b.1))
{
peaks.push(idx);
}
}
peaks.sort_by(|&a, &b| score_vec[b].total_cmp(&score_vec[a]));
let per_theta = scan.per_theta_peaks(pol);
let window = [
scan.grid.r_samples[0],
scan.grid.r_samples[scan.grid.r_samples.len() - 1],
];
let consistency_delta = (4.0 * scan.grid.r_step).max(0.75);
let harmonic = fit_radial_harmonic(scan.valid_theta_angles(), per_theta, scan.grid.r_step);
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 constant_consistency =
radial_profile::theta_consistency(per_theta, r_star, scan.grid.r_step, 0.75);
let (radius_model, theta_consistency) = match harmonic
.as_ref()
.filter(|fit| model_applies_to_peak(fit, r_star, window, consistency_delta))
{
Some(fit) => {
let model_consistency = theta_consistency_with_model(
per_theta,
scan.valid_theta_angles(),
&fit.model,
scan.grid.r_step,
0.75,
);
if model_consistency > constant_consistency {
(Some(fit.model), model_consistency)
} else {
(None, constant_consistency)
}
}
None => (None, constant_consistency),
};
if theta_consistency < cfg.min_theta_consistency {
continue;
}
hypotheses.push(OuterHypothesis {
r_outer_px: r_star,
peak_strength,
theta_consistency,
radius_model,
});
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
);
}
#[test]
fn fit_radial_harmonic_recovers_elliptical_signature() {
let truth = RadialHarmonic {
c0: 27.0,
c1: 2.4,
c2: -1.1,
};
let n = 48;
let angles: Vec<f32> = (0..n)
.map(|i| i as f32 * 2.0 * std::f32::consts::PI / n as f32)
.collect();
let radii: Vec<f32> = angles
.iter()
.enumerate()
.map(|(i, &t)| truth.radius_at(t) + if i % 2 == 0 { 0.15 } else { -0.15 })
.collect();
let fit = fit_radial_harmonic(&angles, &radii, 0.4).expect("fit succeeds");
let m = fit.model;
assert!((m.c0 - truth.c0).abs() < 0.1, "c0 {:.3}", m.c0);
assert!((m.c1 - truth.c1).abs() < 0.1, "c1 {:.3}", m.c1);
assert!((m.c2 - truth.c2).abs() < 0.1, "c2 {:.3}", m.c2);
assert!(
fit.inlier_rms <= 0.2,
"clean signal should have small residual RMS, got {:.3}",
fit.inlier_rms
);
}
#[test]
fn fit_radial_harmonic_rejects_contaminated_rays() {
let truth = RadialHarmonic {
c0: 27.0,
c1: 2.0,
c2: 0.0,
};
let n = 48;
let angles: Vec<f32> = (0..n)
.map(|i| i as f32 * 2.0 * std::f32::consts::PI / n as f32)
.collect();
let radii: Vec<f32> = angles
.iter()
.enumerate()
.map(|(i, &t)| {
if i % 6 == 0 {
20.0 } else {
truth.radius_at(t)
}
})
.collect();
let fit = fit_radial_harmonic(&angles, &radii, 0.4).expect("fit succeeds");
let m = fit.model;
assert!(
(m.c0 - truth.c0).abs() < 0.5,
"contaminated c0 {:.3} should stay near 27",
m.c0
);
assert!((m.c1 - truth.c1).abs() < 0.5, "c1 {:.3}", m.c1);
}
#[test]
fn outer_estimator_attaches_model_on_eccentric_ring() {
let w = 160u32;
let h = 160u32;
let cx = 80.0f32;
let cy = 80.0f32;
let a = 30.0f32;
let b = 24.0f32;
let angle = 0.4f32;
let ca = angle.cos();
let sa = angle.sin();
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 xr = ca * dx + sa * dy;
let yr = -sa * dx + ca * dy;
let rho = ((xr / a).powi(2) + (yr / b).powi(2)).sqrt();
let val: f32 = if (0.55..=1.0).contains(&rho) {
0.15
} else {
0.9
};
img.put_pixel(x, y, Luma([(val * 255.0).round() as u8]));
}
}
let img = blur_gray(&img, 1.0);
let r_mean = 0.5 * (a + b);
let cfg = OuterEstimationConfig {
search_halfwidth_px: 6.0,
..OuterEstimationConfig::default()
};
let est =
estimate_outer_from_prior_with_mapper(&img, [cx, cy], r_mean, &cfg, 64, None, false);
assert_eq!(
est.status,
OuterStatus::Ok,
"eccentric outer estimate failed: {:?}",
est.failure
);
let hyp = &est.hypotheses[0];
let model = hyp
.radius_model
.as_ref()
.expect("eccentric ring should attach a radius model");
assert!(
(model.amplitude() - 3.0).abs() < 1.0,
"model amplitude {:.2} should be near 3.0",
model.amplitude()
);
assert!(
hyp.theta_consistency >= cfg.min_theta_consistency,
"model-based consistency {:.2} should pass the gate",
hyp.theta_consistency
);
}
}