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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
15pub struct FitMetrics {
16 pub n_angles_total: usize,
18 pub n_angles_with_both_edges: usize,
20 pub n_points_outer: usize,
22 pub n_points_inner: usize,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub ransac_inlier_ratio_outer: Option<f32>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub ransac_inlier_ratio_inner: Option<f32>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub rms_residual_outer: Option<f64>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub rms_residual_inner: Option<f64>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub max_angular_gap_outer: Option<f64>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub max_angular_gap_inner: Option<f64>,
42 #[serde(skip_serializing_if = "Option::is_none")]
45 pub inner_fit_status: Option<InnerFitStatus>,
46 #[serde(skip_serializing_if = "Option::is_none")]
49 pub inner_fit_reason: Option<InnerFitReason>,
50 #[serde(skip_serializing_if = "Option::is_none")]
54 pub neighbor_radius_ratio: Option<f32>,
55 #[serde(skip_serializing_if = "Option::is_none")]
59 pub inner_theta_consistency: Option<f32>,
60}
61
62#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
71pub struct DetectedMarker {
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub id: Option<usize>,
75 pub confidence: f32,
77 pub center: [f64; 2],
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub center_mapped: Option<[f64; 2]>,
84 #[serde(skip_serializing_if = "Option::is_none")]
88 pub board_xy_mm: Option<[f64; 2]>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub ellipse_outer: Option<Ellipse>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub ellipse_inner: Option<Ellipse>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub edge_points_outer: Option<Vec<[f64; 2]>>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub edge_points_inner: Option<Vec<[f64; 2]>>,
101 pub fit: FitMetrics,
103 #[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
166pub(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
191pub(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
213pub(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 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 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 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 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 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}