use image::GrayImage;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use super::inner_fit;
use super::marker_build::{
compute_marker_confidence, decode_metrics_from_result, fit_metrics_with_inner,
};
use super::outer_fit::{OuterFitCandidate, OuterFitRejectReason, fit_outer_candidate_from_prior};
use super::{DetectConfig, dedup_by_id, dedup_markers};
use crate::detector::DetectedMarker;
use crate::detector::marker_build::DetectionSource;
use crate::pixelmap::PixelMapper;
use crate::proposal::Proposal;
use crate::ring::edge_sample::DistortionAwareSampler;
#[inline]
fn duration_ms(duration: Duration) -> f64 {
duration.as_secs_f64() * 1_000.0
}
#[inline]
fn mean_duration_ms(duration: Duration, n: usize) -> f64 {
if n == 0 {
0.0
} else {
duration_ms(duration) / n as f64
}
}
struct CandidateProcessContext<'a> {
gray: &'a GrayImage,
config: &'a DetectConfig,
mapper: Option<&'a dyn PixelMapper>,
sampler: DistortionAwareSampler<'a>,
source: DetectionSource,
}
#[derive(Default)]
struct CandidateTimingStats {
n_candidates: usize,
n_outer_fit_attempted: usize,
n_inner_fit_attempted: usize,
outer_fit: Duration,
inner_fit: Duration,
candidate_total: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum CandidateRejectReason {
ProposalUnmappable,
OuterFit(OuterFitRejectReason),
InnerFitRequired,
}
impl CandidateRejectReason {
const fn code(self) -> &'static str {
match self {
Self::ProposalUnmappable => "proposal_unmappable",
Self::OuterFit(reason) => reason.code(),
Self::InnerFitRequired => "inner_fit_required",
}
}
}
impl std::fmt::Display for CandidateRejectReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
fn select_proposals_for_fit(
mut proposals: Vec<Proposal>,
max_candidates: Option<usize>,
) -> Vec<Proposal> {
let Some(max_candidates) = max_candidates else {
return proposals;
};
if proposals.len() <= max_candidates {
return proposals;
}
proposals.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal));
proposals.truncate(max_candidates);
proposals
}
fn process_candidate(
proposal: Proposal,
ctx: &CandidateProcessContext<'_>,
timing: &mut CandidateTimingStats,
) -> Result<DetectedMarker, CandidateRejectReason> {
let Some(center_prior) = ctx.sampler.image_to_working_xy([proposal.x, proposal.y]) else {
return Err(CandidateRejectReason::ProposalUnmappable);
};
timing.n_outer_fit_attempted += 1;
let outer_fit_start = Instant::now();
let fit = match fit_outer_candidate_from_prior(
ctx.gray,
center_prior,
ctx.config.marker_scale.nominal_outer_radius_px(),
ctx.config,
ctx.mapper,
) {
Ok(v) => v,
Err(reject) => {
timing.outer_fit += outer_fit_start.elapsed();
tracing::trace!(
"outer fit rejected at proposal ({:.1},{:.1}): reason={} context={:?}",
proposal.x,
proposal.y,
reject.reason,
reject.context
);
return Err(CandidateRejectReason::OuterFit(reject.reason));
}
};
timing.outer_fit += outer_fit_start.elapsed();
let OuterFitCandidate {
edge,
outer,
outer_ransac,
decode_result,
..
} = fit;
let center = outer.center();
timing.n_inner_fit_attempted += 1;
let inner_fit_start = Instant::now();
let inner_fit = inner_fit::fit_inner_ellipse_from_outer_hint(
ctx.gray,
&outer,
&ctx.config.marker_spec,
ctx.mapper,
&ctx.config.inner_fit,
false,
);
timing.inner_fit += inner_fit_start.elapsed();
if inner_fit.status != inner_fit::InnerFitStatus::Ok {
let reason_code = inner_fit.reason.map(|reason| reason.code());
tracing::trace!(
"inner fit rejected/failed at proposal ({:.1},{:.1}): status={:?}, reason={:?}, context={:?}",
proposal.x,
proposal.y,
inner_fit.status,
reason_code,
inner_fit.reason_context
);
}
if ctx.config.inner_fit.require_inner_fit && inner_fit.ellipse_inner.is_none() {
return Err(CandidateRejectReason::InnerFitRequired);
}
let fit_metrics = fit_metrics_with_inner(&edge, &outer, outer_ransac.as_ref(), &inner_fit);
let confidence = compute_marker_confidence(
decode_result.as_ref(),
&edge,
outer_ransac.as_ref(),
&inner_fit,
&fit_metrics,
&ctx.config.inner_fit,
);
let decode_metrics = decode_metrics_from_result(decode_result.as_ref());
let marker_id = decode_result.as_ref().map(|d| d.id);
let outer_points = edge.outer_points;
let inner_points = inner_fit.points_inner;
Ok(DetectedMarker {
id: marker_id,
confidence,
center,
ellipse_outer: Some(outer),
ellipse_inner: inner_fit.ellipse_inner,
edge_points_outer: Some(outer_points),
edge_points_inner: Some(inner_points),
fit: fit_metrics,
decode: decode_metrics,
source: ctx.source,
..DetectedMarker::default()
})
}
pub(super) fn run(
gray: &GrayImage,
config: &DetectConfig,
mapper: Option<&dyn PixelMapper>,
proposals: Vec<Proposal>,
source: DetectionSource,
) -> Vec<DetectedMarker> {
let total_start = Instant::now();
let input_count = proposals.len();
tracing::info!("{} proposals found", input_count);
let select_start = Instant::now();
let proposals = select_proposals_for_fit(proposals, config.proposal.max_candidates);
let select_elapsed = select_start.elapsed();
if proposals.len() != input_count {
tracing::info!(
"proposal cap active: evaluating {} / {} proposals",
proposals.len(),
input_count
);
}
let sampler = DistortionAwareSampler::new(gray, mapper);
let ctx = CandidateProcessContext {
gray,
config,
mapper,
sampler,
source,
};
let mut markers: Vec<DetectedMarker> = Vec::new();
let mut reject_reasons: HashMap<CandidateRejectReason, usize> = HashMap::new();
let mut timing = CandidateTimingStats::default();
for proposal in proposals {
timing.n_candidates += 1;
let candidate_start = Instant::now();
match process_candidate(proposal, &ctx, &mut timing) {
Ok(marker) => markers.push(marker),
Err(reason) => *reject_reasons.entry(reason).or_insert(0) += 1,
}
timing.candidate_total += candidate_start.elapsed();
}
if !reject_reasons.is_empty() {
let mut summary: Vec<(CandidateRejectReason, usize)> = reject_reasons.into_iter().collect();
summary.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.code().cmp(b.0.code())));
let rejected_total: usize = summary.iter().map(|(_, n)| *n).sum();
let top = summary
.iter()
.take(4)
.map(|(reason, n)| format!("{}={n}", reason.code()))
.collect::<Vec<_>>()
.join(", ");
tracing::debug!(
"fit/decode rejected {} proposals (top reasons: {})",
rejected_total,
top
);
}
let accepted_before_dedup = markers.len();
let dedup_start = Instant::now();
markers = dedup_markers(markers, config.dedup_radius);
dedup_by_id(&mut markers);
let dedup_elapsed = dedup_start.elapsed();
tracing::info!("{} markers detected after dedup", markers.len());
let outer_inner_total = timing.outer_fit + timing.inner_fit;
tracing::info!(
input_proposals = input_count,
evaluated_proposals = timing.n_candidates,
accepted_before_dedup,
markers_after_dedup = markers.len(),
outer_fit_attempted = timing.n_outer_fit_attempted,
inner_fit_attempted = timing.n_inner_fit_attempted,
select_ms = duration_ms(select_elapsed),
candidate_loop_ms = duration_ms(timing.candidate_total),
candidate_overhead_ms =
duration_ms(timing.candidate_total.saturating_sub(outer_inner_total)),
outer_fit_ms = duration_ms(timing.outer_fit),
mean_outer_fit_ms = mean_duration_ms(timing.outer_fit, timing.n_outer_fit_attempted),
inner_fit_ms = duration_ms(timing.inner_fit),
mean_inner_fit_ms = mean_duration_ms(timing.inner_fit, timing.n_inner_fit_attempted),
dedup_ms = duration_ms(dedup_elapsed),
total_ms = duration_ms(total_start.elapsed()),
"fit/decode timing summary"
);
markers
}
#[cfg(test)]
mod tests {
use super::*;
fn draw_ring_image(
w: u32,
h: u32,
center: [f32; 2],
outer_radius: f32,
inner_radius: f32,
) -> GrayImage {
crate::test_utils::draw_ring_image(w, h, center, outer_radius, inner_radius, 24, 230)
}
fn nearest_marker(markers: &[DetectedMarker], center: [f64; 2]) -> Option<&DetectedMarker> {
markers.iter().min_by(|a, b| {
let da = (a.center[0] - center[0]) * (a.center[0] - center[0])
+ (a.center[1] - center[1]) * (a.center[1] - center[1]);
let db = (b.center[0] - center[0]) * (b.center[0] - center[0])
+ (b.center[1] - center[1]) * (b.center[1] - center[1]);
da.partial_cmp(&db).unwrap_or(Ordering::Equal)
})
}
#[test]
fn fit_decode_honors_inner_fit_config() {
let center = [64.0f32, 64.0f32];
let outer_radius = 24.0f32;
let inner_radius = 11.75f32; let img = draw_ring_image(128, 128, center, outer_radius, inner_radius);
let proposals = vec![
Proposal {
x: center[0],
y: center[1],
score: 10.0,
},
Proposal {
x: center[0] + 1.0,
y: center[1],
score: 9.0,
},
Proposal {
x: center[0],
y: center[1] + 1.0,
score: 8.0,
},
];
let mut relaxed = DetectConfig::default();
relaxed.set_marker_diameter_hint_px(outer_radius * 2.0);
relaxed.inner_fit.min_points = 1;
relaxed.inner_fit.min_inlier_ratio = 0.0;
relaxed.inner_fit.max_rms_residual = f64::INFINITY;
relaxed.inner_fit.max_center_shift_px = f64::INFINITY;
relaxed.inner_fit.max_ratio_abs_error = f64::INFINITY;
let relaxed_out = run(
&img,
&relaxed,
None,
proposals.clone(),
DetectionSource::FitDecoded,
);
assert!(
!relaxed_out.is_empty(),
"expected at least one marker with relaxed inner-fit params"
);
let relaxed_marker = nearest_marker(&relaxed_out, [center[0] as f64, center[1] as f64])
.expect("nearest marker");
assert!(
relaxed_marker.ellipse_inner.is_some(),
"expected inner ellipse with relaxed inner-fit params"
);
let mut strict = relaxed.clone();
strict.inner_fit.min_points = usize::MAX;
let strict_out = run(&img, &strict, None, proposals, DetectionSource::FitDecoded);
assert!(
!strict_out.is_empty(),
"expected marker to remain present when inner-fit is disabled by strict gate"
);
let strict_marker = nearest_marker(&strict_out, [center[0] as f64, center[1] as f64])
.expect("nearest marker");
assert!(
strict_marker.ellipse_inner.is_none(),
"expected no inner ellipse when min_points gate is impossible"
);
}
#[test]
fn fit_decode_respects_proposal_cap() {
let center = [64.0f32, 64.0f32];
let outer_radius = 24.0f32;
let inner_radius = 11.75f32;
let img = draw_ring_image(128, 128, center, outer_radius, inner_radius);
let proposals = vec![Proposal {
x: center[0],
y: center[1],
score: 10.0,
}];
let mut cfg = DetectConfig::default();
cfg.set_marker_diameter_hint_px(outer_radius * 2.0);
cfg.proposal.max_candidates = Some(0);
let out = run(&img, &cfg, None, proposals, DetectionSource::FitDecoded);
assert!(
out.is_empty(),
"expected no markers when proposal cap is zero"
);
}
#[test]
fn candidate_reject_reason_codes_are_stable() {
assert_eq!(
CandidateRejectReason::ProposalUnmappable.code(),
"proposal_unmappable"
);
assert_eq!(
CandidateRejectReason::OuterFit(OuterFitRejectReason::NoValidHypothesis).code(),
"no_valid_hypothesis"
);
}
}