Skip to main content

ringgrid/detector/config/
fit.rs

1//! Inner and outer ellipse fitting configuration.
2
3/// Shared default for the maximum angular gap (radians) between consecutive
4/// edge points in both inner and outer fits: π/2, i.e. up to a quarter of the
5/// ring may be unobserved.
6fn default_max_angular_gap_rad() -> f64 {
7    std::f64::consts::FRAC_PI_2
8}
9
10/// Configuration for robust inner ellipse fitting from outer-fit hints.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12#[serde(default)]
13#[non_exhaustive]
14pub struct InnerFitConfig {
15    /// Minimum number of sampled points required to attempt a fit.
16    pub min_points: usize,
17    /// Minimum accepted inlier ratio when RANSAC is used.
18    pub min_inlier_ratio: f32,
19    /// Maximum accepted RMS Sampson residual (px) of the fitted inner ellipse.
20    pub max_rms_residual: f64,
21    /// Maximum allowed center shift from outer to inner fit center (px).
22    pub max_center_shift_px: f64,
23    /// Maximum allowed absolute error in recovered scale ratio vs radial hint.
24    pub max_ratio_abs_error: f64,
25    /// Local half-width (in radius-sample indices) around the radial hint.
26    pub local_peak_halfwidth_idx: usize,
27    /// RANSAC configuration for robust inner ellipse fitting.
28    pub ransac: crate::conic::RansacConfig,
29    /// Confidence multiplier applied when inner ellipse fit fails or is absent.
30    ///
31    /// Inner fit failure is a reliable signal of poor image quality (heavy blur,
32    /// distortion, or edge contamination). Setting this below 1.0 discounts the
33    /// decode confidence when the inner ring cannot be fitted, making true markers
34    /// in clear regions easier to separate from false detections.
35    ///
36    /// Default: 0.7 (30 % confidence reduction on inner-fit miss).
37    #[serde(default = "InnerFitConfig::default_miss_confidence_factor")]
38    pub miss_confidence_factor: f32,
39    /// Maximum allowed angular gap (radians) between consecutive inner edge
40    /// points. Fits where the largest gap exceeds this are rejected.
41    ///
42    /// Default: π/2 (90 degrees).
43    #[serde(default = "default_max_angular_gap_rad")]
44    pub max_angular_gap_rad: f64,
45    /// When true, markers are hard-rejected (not just penalized) if the inner
46    /// ellipse cannot be fitted. Requires two good ellipses per marker.
47    ///
48    /// Default: false (backward-compatible).
49    #[serde(default = "InnerFitConfig::default_require_inner_fit")]
50    pub require_inner_fit: bool,
51}
52
53impl InnerFitConfig {
54    fn default_miss_confidence_factor() -> f32 {
55        0.7
56    }
57    fn default_require_inner_fit() -> bool {
58        false
59    }
60}
61
62impl Default for InnerFitConfig {
63    fn default() -> Self {
64        Self {
65            min_points: 20,
66            min_inlier_ratio: 0.5,
67            max_rms_residual: 1.0,
68            max_center_shift_px: 12.0,
69            max_ratio_abs_error: 0.15,
70            local_peak_halfwidth_idx: 3,
71            ransac: crate::conic::RansacConfig {
72                max_iters: 200,
73                inlier_threshold: 1.5,
74                min_inliers: 8,
75                seed: 43,
76            },
77            miss_confidence_factor: 0.7,
78            max_angular_gap_rad: default_max_angular_gap_rad(),
79            require_inner_fit: false,
80        }
81    }
82}
83
84/// Configuration for robust outer ellipse fitting from sampled edge points.
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
86#[serde(default)]
87#[non_exhaustive]
88pub struct OuterFitConfig {
89    /// Minimum number of sampled points required to attempt direct LS fit.
90    pub min_direct_fit_points: usize,
91    /// Minimum sampled points required before attempting RANSAC.
92    pub min_ransac_points: usize,
93    /// RANSAC configuration for robust outer ellipse fitting.
94    pub ransac: crate::conic::RansacConfig,
95    /// Relative weight of size agreement in outer-hypothesis scoring.
96    ///
97    /// The score combines decode quality, fit support, size agreement, and
98    /// residual quality. This weight controls the size-agreement term and is
99    /// normalized with the other terms at runtime.
100    ///
101    /// Default: `0.15` (preserves legacy behavior).
102    #[serde(default = "OuterFitConfig::default_size_score_weight")]
103    pub size_score_weight: f32,
104    /// Maximum allowed angular gap (radians) between consecutive outer edge
105    /// points. Fits where the largest gap exceeds this are rejected.
106    ///
107    /// Default: π/2 (90 degrees).
108    #[serde(default = "default_max_angular_gap_rad")]
109    pub max_angular_gap_rad: f64,
110}
111
112impl OuterFitConfig {
113    fn default_size_score_weight() -> f32 {
114        0.15
115    }
116}
117
118impl Default for OuterFitConfig {
119    fn default() -> Self {
120        Self {
121            min_direct_fit_points: 6,
122            min_ransac_points: 8,
123            ransac: crate::conic::RansacConfig {
124                max_iters: 200,
125                inlier_threshold: 1.5,
126                min_inliers: 6,
127                seed: 42,
128            },
129            size_score_weight: Self::default_size_score_weight(),
130            max_angular_gap_rad: default_max_angular_gap_rad(),
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn inner_fit_config_defaults_are_stable() {
141        let core = InnerFitConfig::default();
142        assert_eq!(core.min_points, 20);
143        assert!((core.min_inlier_ratio - 0.5).abs() < 1e-6);
144        assert!((core.max_rms_residual - 1.0).abs() < 1e-9);
145        assert!((core.max_center_shift_px - 12.0).abs() < 1e-9);
146        assert!((core.max_ratio_abs_error - 0.15).abs() < 1e-9);
147        assert_eq!(core.local_peak_halfwidth_idx, 3);
148        assert_eq!(core.ransac.max_iters, 200);
149        assert!((core.ransac.inlier_threshold - 1.5).abs() < 1e-9);
150        assert_eq!(core.ransac.min_inliers, 8);
151        assert_eq!(core.ransac.seed, 43);
152        assert!((core.miss_confidence_factor - 0.7).abs() < 1e-6);
153        assert!(
154            (core.max_angular_gap_rad - std::f64::consts::FRAC_PI_2).abs() < 1e-9,
155            "inner max_angular_gap_rad"
156        );
157        assert!(!core.require_inner_fit);
158    }
159
160    #[test]
161    fn outer_fit_config_defaults_are_stable() {
162        let core = OuterFitConfig::default();
163        assert_eq!(core.min_direct_fit_points, 6);
164        assert_eq!(core.min_ransac_points, 8);
165        assert_eq!(core.ransac.max_iters, 200);
166        assert!((core.ransac.inlier_threshold - 1.5).abs() < 1e-9);
167        assert_eq!(core.ransac.min_inliers, 6);
168        assert_eq!(core.ransac.seed, 42);
169        assert!((core.size_score_weight - 0.15).abs() < 1e-6);
170        assert!(
171            (core.max_angular_gap_rad - std::f64::consts::FRAC_PI_2).abs() < 1e-9,
172            "outer max_angular_gap_rad"
173        );
174    }
175
176    #[test]
177    fn outer_fit_config_deserialize_missing_size_weight_uses_default() {
178        let json = r#"{
179            "min_direct_fit_points": 6,
180            "min_ransac_points": 8,
181            "ransac": {
182                "max_iters": 200,
183                "inlier_threshold": 1.5,
184                "min_inliers": 6,
185                "seed": 42
186            }
187        }"#;
188        let cfg: OuterFitConfig = serde_json::from_str(json).expect("deserialize outer fit config");
189        assert!((cfg.size_score_weight - 0.15).abs() < 1e-6);
190    }
191}