use crate::conic::{self, Ellipse};
use crate::marker::DecodeMetrics;
use crate::marker::decode::DecodeResult;
use crate::ring::edge_sample::EdgeSampleResult;
use super::config::InnerFitConfig;
use super::inner_fit::{InnerFitReason, InnerFitResult, InnerFitStatus};
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum DetectionSource {
#[default]
FitDecoded,
Completion,
SeededPass,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct FitMetrics {
pub n_angles_total: usize,
pub n_angles_with_both_edges: usize,
pub n_points_outer: usize,
pub n_points_inner: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub ransac_inlier_ratio_outer: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ransac_inlier_ratio_inner: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rms_residual_outer: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rms_residual_inner: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_angular_gap_outer: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_angular_gap_inner: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_fit_status: Option<InnerFitStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_fit_reason: Option<InnerFitReason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub neighbor_radius_ratio: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_theta_consistency: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub radii_std_outer_px: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub h_reproj_err_px: Option<f32>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct DetectedMarker {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<usize>,
pub confidence: f32,
pub center: [f64; 2],
#[serde(skip_serializing_if = "Option::is_none")]
pub center_mapped: Option<[f64; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub board_xy_mm: Option<[f64; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ellipse_outer: Option<Ellipse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ellipse_inner: Option<Ellipse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_points_outer: Option<Vec<[f64; 2]>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_points_inner: Option<Vec<[f64; 2]>>,
pub fit: FitMetrics,
#[serde(skip_serializing_if = "Option::is_none")]
pub decode: Option<DecodeMetrics>,
pub source: DetectionSource,
}
#[derive(Debug, Clone, Copy)]
struct InnerFitSummary {
n_points_inner: usize,
ransac_inlier_ratio_inner: Option<f32>,
rms_residual_inner: Option<f64>,
max_angular_gap_inner: Option<f64>,
inner_fit_status: Option<InnerFitStatus>,
inner_fit_reason: Option<InnerFitReason>,
inner_theta_consistency: Option<f32>,
}
impl InnerFitSummary {
fn from_result(inner: &InnerFitResult) -> Self {
Self {
n_points_inner: inner.points_inner.len(),
ransac_inlier_ratio_inner: inner.ransac_inlier_ratio_inner,
rms_residual_inner: inner.rms_residual_inner,
max_angular_gap_inner: inner.max_angular_gap,
inner_fit_status: Some(inner.status),
inner_fit_reason: (inner.status != InnerFitStatus::Ok)
.then_some(inner.reason)
.flatten(),
inner_theta_consistency: inner.theta_consistency,
}
}
}
fn radii_std(radii: &[f32]) -> Option<f32> {
if radii.len() < 2 {
return None;
}
let mean = radii.iter().sum::<f32>() / radii.len() as f32;
let variance = radii.iter().map(|r| (r - mean).powi(2)).sum::<f32>() / radii.len() as f32;
Some(variance.sqrt())
}
fn fit_metrics_from_outer(
edge: &EdgeSampleResult,
outer: &Ellipse,
outer_ransac: Option<&conic::RansacResult>,
inner_summary: &InnerFitSummary,
) -> FitMetrics {
use super::outer_fit::max_angular_gap;
let gap_outer = if edge.outer_points.is_empty() {
None
} else {
Some(max_angular_gap(outer.center(), &edge.outer_points))
};
FitMetrics {
n_angles_total: edge.n_total_rays,
n_angles_with_both_edges: edge.n_good_rays,
n_points_outer: edge.outer_points.len(),
n_points_inner: inner_summary.n_points_inner,
ransac_inlier_ratio_outer: outer_ransac
.map(|r| r.num_inliers as f32 / edge.outer_points.len().max(1) as f32),
ransac_inlier_ratio_inner: inner_summary.ransac_inlier_ratio_inner,
rms_residual_outer: Some(conic::rms_sampson_distance(outer, &edge.outer_points)),
rms_residual_inner: inner_summary.rms_residual_inner,
max_angular_gap_outer: gap_outer,
max_angular_gap_inner: inner_summary.max_angular_gap_inner,
inner_fit_status: inner_summary.inner_fit_status,
inner_fit_reason: inner_summary.inner_fit_reason,
neighbor_radius_ratio: None,
inner_theta_consistency: inner_summary.inner_theta_consistency,
radii_std_outer_px: radii_std(&edge.outer_radii),
h_reproj_err_px: None,
}
}
pub(crate) fn fit_metrics_with_inner(
edge: &EdgeSampleResult,
outer: &Ellipse,
outer_ransac: Option<&conic::RansacResult>,
inner: &InnerFitResult,
) -> FitMetrics {
let inner_summary = InnerFitSummary::from_result(inner);
fit_metrics_from_outer(edge, outer, outer_ransac, &inner_summary)
}
pub(crate) fn decode_metrics_from_result(
decode_result: Option<&DecodeResult>,
) -> Option<DecodeMetrics> {
decode_result.map(|d| DecodeMetrics {
observed_word: d.raw_word,
best_id: d.id,
best_rotation: d.rotation,
best_dist: d.dist,
margin: d.margin,
decode_confidence: d.confidence,
})
}
pub(crate) fn fit_support_score(
edge: &EdgeSampleResult,
outer_ransac: Option<&conic::RansacResult>,
) -> f32 {
let arc_cov = edge.n_good_rays as f32 / edge.n_total_rays.max(1) as f32;
let inlier_ratio = outer_ransac
.map(|r| r.num_inliers as f32 / edge.outer_points.len().max(1) as f32)
.unwrap_or(1.0);
(arc_cov * inlier_ratio).clamp(0.0, 1.0)
}
fn fallback_fit_confidence(
edge: &EdgeSampleResult,
outer_ransac: Option<&conic::RansacResult>,
) -> f32 {
fit_support_score(edge, outer_ransac)
}
pub(crate) fn compute_marker_confidence(
decode_result: Option<&DecodeResult>,
edge: &EdgeSampleResult,
outer_ransac: Option<&conic::RansacResult>,
inner_fit: &InnerFitResult,
fit_metrics: &FitMetrics,
inner_fit_config: &InnerFitConfig,
) -> f32 {
let decode_conf = decode_result
.map(|d| (1.0 - d.dist as f32 / 6.0).clamp(0.0, 1.0))
.unwrap_or_else(|| fallback_fit_confidence(edge, outer_ransac));
let outer_gap = fit_metrics
.max_angular_gap_outer
.unwrap_or(std::f64::consts::TAU);
let angular_outer = (1.0 - outer_gap / std::f64::consts::TAU).clamp(0.0, 1.0) as f32;
let inlier_factor = outer_ransac
.map(|r| r.num_inliers as f32 / edge.outer_points.len().max(1) as f32)
.unwrap_or(1.0)
.clamp(0.0, 1.0);
let inner_factor = if inner_fit.ellipse_inner.is_some() {
let inner_gap = fit_metrics.max_angular_gap_inner.unwrap_or(0.0);
(1.0 - inner_gap / std::f64::consts::TAU).clamp(0.5, 1.0) as f32
} else {
inner_fit_config.miss_confidence_factor
};
let rms_factor = match fit_metrics.rms_residual_outer {
Some(rms) if rms > 0.0 && rms.is_finite() => 1.0 / (1.0 + rms as f32 / 2.0),
_ => 1.0,
};
(decode_conf * angular_outer * inlier_factor * inner_factor * rms_factor).clamp(0.0, 1.0)
}