Skip to main content

ringgrid/detector/
marker_build.rs

1use crate::conic::{self, Ellipse};
2use crate::marker::decode::DecodeResult;
3use crate::marker::DecodeMetrics;
4use crate::ring::edge_sample::EdgeSampleResult;
5
6use super::config::InnerFitConfig;
7use super::inner_fit::{InnerFitReason, InnerFitResult, InnerFitStatus};
8
9/// Fit quality metrics for a detected marker.
10///
11/// Reports the edge sampling and ellipse fit quality. High RANSAC inlier
12/// ratios (> 0.8) and low RMS Sampson residuals (< 0.5 px) indicate a
13/// precise ellipse fit.
14#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
15pub struct FitMetrics {
16    /// Total number of radial rays cast.
17    pub n_angles_total: usize,
18    /// Number of rays where both inner and outer ring edges were found.
19    pub n_angles_with_both_edges: usize,
20    /// Number of outer edge points used for ellipse fit.
21    pub n_points_outer: usize,
22    /// Number of inner edge points used for ellipse fit.
23    pub n_points_inner: usize,
24    /// RANSAC inlier ratio for outer ellipse fit.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub ransac_inlier_ratio_outer: Option<f32>,
27    /// RANSAC inlier ratio for inner ellipse fit.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub ransac_inlier_ratio_inner: Option<f32>,
30    /// RMS Sampson residual for outer ellipse fit.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub rms_residual_outer: Option<f64>,
33    /// RMS Sampson residual for inner ellipse fit.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub rms_residual_inner: Option<f64>,
36    /// Maximum angular gap (radians) between consecutive outer edge points.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub max_angular_gap_outer: Option<f64>,
39    /// Maximum angular gap (radians) between consecutive inner edge points.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub max_angular_gap_inner: Option<f64>,
42    /// Inner fit outcome: `"ok"`, `"rejected"`, or `"failed"`. Absent when fit
43    /// succeeded without issue.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub inner_fit_status: Option<InnerFitStatus>,
46    /// Inner fit rejection reason code. Present only when `inner_fit_status` is
47    /// `"rejected"` or `"failed"`.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub inner_fit_reason: Option<InnerFitReason>,
50    /// Ratio of this marker's outer radius to the median outer radius of its
51    /// k nearest decoded neighbors. Values well below 1.0 (< 0.75) indicate a
52    /// potential inner-as-outer substitution. Populated in the finalization stage.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub neighbor_radius_ratio: Option<f32>,
55    /// Theta consistency score from the inner estimate stage. Fraction of theta
56    /// samples that agree on the inner edge location. Present when estimation ran,
57    /// including when it failed the quality gate.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub inner_theta_consistency: Option<f32>,
60}
61
62/// A detected marker with its refined center and optional ID.
63///
64/// The `center` field is always in image-pixel coordinates, regardless of
65/// whether a [`PixelMapper`](crate::PixelMapper) was used. When a mapper is
66/// active, `center_mapped` provides the working-frame (undistorted)
67/// coordinates. `board_xy_mm` provides board-space marker coordinates in
68/// millimeters when the decoded `id` is valid for the active [`BoardLayout`](crate::BoardLayout).
69/// Ellipses are in the working frame when a mapper is active.
70#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
71pub struct DetectedMarker {
72    /// Decoded marker ID (codebook index), or None if decoding was rejected.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub id: Option<usize>,
75    /// Combined detection + decode confidence in [0, 1].
76    pub confidence: f32,
77    /// Marker center in raw image pixel coordinates.
78    ///
79    /// This field is always image-space, independent of mapper usage.
80    pub center: [f64; 2],
81    /// Marker center in mapper working coordinates, when a mapper is active.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub center_mapped: Option<[f64; 2]>,
84    /// Marker center on the physical board in millimeters `[x_mm, y_mm]`.
85    ///
86    /// Populated when `id` is present and valid for the active board layout.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub board_xy_mm: Option<[f64; 2]>,
89    /// Outer ellipse parameters.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub ellipse_outer: Option<Ellipse>,
92    /// Inner ellipse parameters.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub ellipse_inner: Option<Ellipse>,
95    /// Raw sub-pixel outer edge inlier points used for ellipse fitting.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub edge_points_outer: Option<Vec<[f64; 2]>>,
98    /// Raw sub-pixel inner edge inlier points used for ellipse fitting.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub edge_points_inner: Option<Vec<[f64; 2]>>,
101    /// Fit quality metrics.
102    pub fit: FitMetrics,
103    /// Decode metrics (present if decoding was attempted).
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub decode: Option<DecodeMetrics>,
106}
107
108#[derive(Debug, Clone, Copy)]
109struct InnerFitSummary {
110    n_points_inner: usize,
111    ransac_inlier_ratio_inner: Option<f32>,
112    rms_residual_inner: Option<f64>,
113    max_angular_gap_inner: Option<f64>,
114    inner_fit_status: Option<InnerFitStatus>,
115    inner_fit_reason: Option<InnerFitReason>,
116    inner_theta_consistency: Option<f32>,
117}
118
119impl InnerFitSummary {
120    fn from_result(inner: &InnerFitResult) -> Self {
121        Self {
122            n_points_inner: inner.points_inner.len(),
123            ransac_inlier_ratio_inner: inner.ransac_inlier_ratio_inner,
124            rms_residual_inner: inner.rms_residual_inner,
125            max_angular_gap_inner: inner.max_angular_gap,
126            inner_fit_status: Some(inner.status),
127            inner_fit_reason: (inner.status != InnerFitStatus::Ok)
128                .then_some(inner.reason)
129                .flatten(),
130            inner_theta_consistency: inner.theta_consistency,
131        }
132    }
133}
134
135fn fit_metrics_from_outer(
136    edge: &EdgeSampleResult,
137    outer: &Ellipse,
138    outer_ransac: Option<&conic::RansacResult>,
139    inner_summary: &InnerFitSummary,
140) -> FitMetrics {
141    use super::outer_fit::max_angular_gap;
142    let gap_outer = if edge.outer_points.is_empty() {
143        None
144    } else {
145        Some(max_angular_gap(outer.center(), &edge.outer_points))
146    };
147    FitMetrics {
148        n_angles_total: edge.n_total_rays,
149        n_angles_with_both_edges: edge.n_good_rays,
150        n_points_outer: edge.outer_points.len(),
151        n_points_inner: inner_summary.n_points_inner,
152        ransac_inlier_ratio_outer: outer_ransac
153            .map(|r| r.num_inliers as f32 / edge.outer_points.len().max(1) as f32),
154        ransac_inlier_ratio_inner: inner_summary.ransac_inlier_ratio_inner,
155        rms_residual_outer: Some(conic::rms_sampson_distance(outer, &edge.outer_points)),
156        rms_residual_inner: inner_summary.rms_residual_inner,
157        max_angular_gap_outer: gap_outer,
158        max_angular_gap_inner: inner_summary.max_angular_gap_inner,
159        inner_fit_status: inner_summary.inner_fit_status,
160        inner_fit_reason: inner_summary.inner_fit_reason,
161        neighbor_radius_ratio: None,
162        inner_theta_consistency: inner_summary.inner_theta_consistency,
163    }
164}
165
166/// Build fit metrics from outer fit + inner fit result, avoiding repeated
167/// field extraction at each call site.
168pub(crate) fn fit_metrics_with_inner(
169    edge: &EdgeSampleResult,
170    outer: &Ellipse,
171    outer_ransac: Option<&conic::RansacResult>,
172    inner: &InnerFitResult,
173) -> FitMetrics {
174    let inner_summary = InnerFitSummary::from_result(inner);
175    fit_metrics_from_outer(edge, outer, outer_ransac, &inner_summary)
176}
177
178pub(crate) fn decode_metrics_from_result(
179    decode_result: Option<&DecodeResult>,
180) -> Option<DecodeMetrics> {
181    decode_result.map(|d| DecodeMetrics {
182        observed_word: d.raw_word,
183        best_id: d.id,
184        best_rotation: d.rotation,
185        best_dist: d.dist,
186        margin: d.margin,
187        decode_confidence: d.confidence,
188    })
189}
190
191/// Composite fit-quality score: arc coverage × RANSAC inlier ratio, clamped to [0, 1].
192///
193/// When no RANSAC result is available (direct-fit path), the inlier ratio defaults to 1.0
194/// so the score degrades gracefully to pure arc coverage.
195pub(crate) fn fit_support_score(
196    edge: &EdgeSampleResult,
197    outer_ransac: Option<&conic::RansacResult>,
198) -> f32 {
199    let arc_cov = edge.n_good_rays as f32 / edge.n_total_rays.max(1) as f32;
200    let inlier_ratio = outer_ransac
201        .map(|r| r.num_inliers as f32 / edge.outer_points.len().max(1) as f32)
202        .unwrap_or(1.0);
203    (arc_cov * inlier_ratio).clamp(0.0, 1.0)
204}
205
206fn fallback_fit_confidence(
207    edge: &EdgeSampleResult,
208    outer_ransac: Option<&conic::RansacResult>,
209) -> f32 {
210    fit_support_score(edge, outer_ransac)
211}
212
213/// Composite confidence score incorporating decode quality, angular coverage,
214/// RANSAC inlier ratio, inner fit quality, and RMS residual. Each factor is
215/// in [0, 1]; multiplicative composition ensures any single failing dimension
216/// pulls confidence toward zero.
217pub(crate) fn compute_marker_confidence(
218    decode_result: Option<&DecodeResult>,
219    edge: &EdgeSampleResult,
220    outer_ransac: Option<&conic::RansacResult>,
221    inner_fit: &InnerFitResult,
222    fit_metrics: &FitMetrics,
223    inner_fit_config: &InnerFitConfig,
224) -> f32 {
225    // 1. Decode signal (base): use normalised Hamming distance only.
226    // d.confidence = (1−dist/6) × (margin/CODEBOOK_MIN_CYCLIC_DIST) which halves
227    // the value for the common margin=1 case.  Using distance alone keeps the
228    // factor consistent across margin values while still penalising close calls.
229    let decode_conf = decode_result
230        .map(|d| (1.0 - d.dist as f32 / 6.0).clamp(0.0, 1.0))
231        .unwrap_or_else(|| fallback_fit_confidence(edge, outer_ransac));
232
233    // 2. Outer angular coverage: linear map gap -> [0, 1]
234    let outer_gap = fit_metrics
235        .max_angular_gap_outer
236        .unwrap_or(std::f64::consts::TAU);
237    let angular_outer = (1.0 - outer_gap / std::f64::consts::TAU).clamp(0.0, 1.0) as f32;
238
239    // 3. RANSAC inlier ratio
240    let inlier_factor = outer_ransac
241        .map(|r| r.num_inliers as f32 / edge.outer_points.len().max(1) as f32)
242        .unwrap_or(1.0)
243        .clamp(0.0, 1.0);
244
245    // 4. Inner fit quality: angular coverage when present, miss penalty otherwise
246    let inner_factor = if inner_fit.ellipse_inner.is_some() {
247        let inner_gap = fit_metrics.max_angular_gap_inner.unwrap_or(0.0);
248        (1.0 - inner_gap / std::f64::consts::TAU).clamp(0.5, 1.0) as f32
249    } else {
250        inner_fit_config.miss_confidence_factor
251    };
252
253    // 5. RMS residual penalty: softer 1/(1+rms/2) so clean fits (rms≈0.2) are
254    // penalised less while still pushing noisy fits (rms>1) toward zero.
255    let rms_factor = match fit_metrics.rms_residual_outer {
256        Some(rms) if rms > 0.0 && rms.is_finite() => 1.0 / (1.0 + rms as f32 / 2.0),
257        _ => 1.0,
258    };
259
260    (decode_conf * angular_outer * inlier_factor * inner_factor * rms_factor).clamp(0.0, 1.0)
261}