Skip to main content

chess_corners/
config.rs

1use box_image_pyramid::PyramidParams;
2use chess_corners_core::{
3    CenterOfMassConfig, ChessParams, ForstnerConfig, RadonDetectorParams, RadonPeakConfig,
4    RefinerKind, SaddlePointConfig,
5};
6use serde::{Deserialize, Serialize};
7
8use crate::multiscale::CoarseToFineParams;
9use crate::upscale::UpscaleConfig;
10
11/// Detector kernel selection. `Canonical` and `Broad` are the two
12/// ChESS variants (radius-5 and radius-10 rings); `Radon` picks the
13/// whole-image Duda-Frese detector via
14/// [`chess_corners_core::radon_response_u8`] /
15/// [`chess_corners_core::detect_corners_from_radon`]. The Radon
16/// detector is useful under heavy blur, low contrast, or cells
17/// smaller than the ChESS ring support.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[non_exhaustive]
21pub enum DetectorMode {
22    #[default]
23    Canonical,
24    Broad,
25    Radon,
26}
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum DescriptorMode {
32    #[default]
33    FollowDetector,
34    Canonical,
35    Broad,
36}
37
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40#[non_exhaustive]
41pub enum ThresholdMode {
42    #[default]
43    Relative,
44    Absolute,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum RefinementMethod {
51    #[default]
52    CenterOfMass,
53    Forstner,
54    SaddlePoint,
55    RadonPeak,
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
59#[serde(default)]
60#[non_exhaustive]
61pub struct RefinerConfig {
62    pub kind: RefinementMethod,
63    pub center_of_mass: CenterOfMassConfig,
64    pub forstner: ForstnerConfig,
65    pub saddle_point: SaddlePointConfig,
66    pub radon_peak: RadonPeakConfig,
67}
68
69impl RefinerConfig {
70    /// Construct a [`RefinerConfig`] with all fields specified.
71    #[allow(clippy::too_many_arguments)]
72    pub fn build(
73        kind: RefinementMethod,
74        center_of_mass: CenterOfMassConfig,
75        forstner: ForstnerConfig,
76        saddle_point: SaddlePointConfig,
77        radon_peak: RadonPeakConfig,
78    ) -> Self {
79        Self {
80            kind,
81            center_of_mass,
82            forstner,
83            saddle_point,
84            radon_peak,
85        }
86    }
87
88    /// Preset that selects the center-of-mass (intensity centroid) refiner.
89    /// Fast and stable; best when corners have clear ring support and moderate
90    /// blur. This is the library default.
91    pub fn center_of_mass() -> Self {
92        Self {
93            kind: RefinementMethod::CenterOfMass,
94            ..Self::default()
95        }
96    }
97
98    /// Preset that selects the Förstner corner refiner.
99    /// Uses a structure-tensor moment approach; more accurate than
100    /// center-of-mass on anisotropic corners.
101    pub fn forstner() -> Self {
102        Self {
103            kind: RefinementMethod::Forstner,
104            ..Self::default()
105        }
106    }
107
108    /// Preset that selects the saddle-point refiner.
109    /// Fits a local quadratic and locates the saddle; very accurate on
110    /// clean, symmetric chessboard corners.
111    pub fn saddle_point() -> Self {
112        Self {
113            kind: RefinementMethod::SaddlePoint,
114            ..Self::default()
115        }
116    }
117
118    /// Preset that selects the Radon-peak refiner.
119    /// Reconstructs the corner by projecting intensity along candidate
120    /// axes; robust to heavy blur and low contrast.
121    pub fn radon_peak() -> Self {
122        Self {
123            kind: RefinementMethod::RadonPeak,
124            ..Self::default()
125        }
126    }
127
128    /// Convert this config into the lower-level [`RefinerKind`] used by
129    /// `chess-corners-core`. Each variant carries its own tuning struct
130    /// taken from the corresponding field of this config.
131    pub fn to_refiner_kind(&self) -> RefinerKind {
132        match self.kind {
133            RefinementMethod::CenterOfMass => RefinerKind::CenterOfMass(self.center_of_mass),
134            RefinementMethod::Forstner => RefinerKind::Forstner(self.forstner),
135            RefinementMethod::SaddlePoint => RefinerKind::SaddlePoint(self.saddle_point),
136            RefinementMethod::RadonPeak => RefinerKind::RadonPeak(self.radon_peak),
137        }
138    }
139}
140
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[serde(default)]
143#[non_exhaustive]
144pub struct ChessConfig {
145    pub detector_mode: DetectorMode,
146    pub descriptor_mode: DescriptorMode,
147    pub threshold_mode: ThresholdMode,
148    pub threshold_value: f32,
149    pub nms_radius: u32,
150    pub min_cluster_size: u32,
151    pub refiner: RefinerConfig,
152    pub pyramid_levels: u8,
153    pub pyramid_min_size: usize,
154    pub refinement_radius: u32,
155    pub merge_radius: f32,
156    /// Optional pre-pipeline integer upscaling. Disabled by default.
157    pub upscale: UpscaleConfig,
158    /// Parameters for the whole-image Radon detector. Only consulted
159    /// when [`detector_mode`](Self::detector_mode) is
160    /// [`DetectorMode::Radon`]; otherwise left at its default.
161    pub radon_detector: RadonDetectorParams,
162}
163
164impl Default for ChessConfig {
165    fn default() -> Self {
166        Self {
167            detector_mode: DetectorMode::default(),
168            descriptor_mode: DescriptorMode::default(),
169            // Paper's contract: any strictly positive ChESS response is
170            // a corner candidate. Callers that want an adaptive
171            // fraction-of-max threshold can opt into
172            // `ThresholdMode::Relative` explicitly.
173            threshold_mode: ThresholdMode::Absolute,
174            threshold_value: 0.0,
175            nms_radius: 2,
176            min_cluster_size: 2,
177            refiner: RefinerConfig::default(),
178            pyramid_levels: 1,
179            pyramid_min_size: 128,
180            refinement_radius: 3,
181            merge_radius: 3.0,
182            upscale: UpscaleConfig::default(),
183            radon_detector: RadonDetectorParams::default(),
184        }
185    }
186}
187
188impl ChessConfig {
189    /// Single-scale preset (one pyramid level). Recommended for images
190    /// where the cell size comfortably exceeds the ChESS ring support (~12 px
191    /// diameter) and no multiscale coverage is needed.
192    pub fn single_scale() -> Self {
193        Self::default()
194    }
195
196    /// Three-level coarse-to-fine pyramid preset. Recommended for images
197    /// ≥ 1 MP or whenever cell sizes vary significantly across the frame.
198    /// The pyramid stops at `pyramid_min_size = 128` pixels on the short edge.
199    pub fn multiscale() -> Self {
200        Self {
201            pyramid_levels: 3,
202            pyramid_min_size: 128,
203            ..Self::default()
204        }
205    }
206
207    /// Preset for the whole-image Radon detector. Single-scale by
208    /// construction (pyramidal Radon is deferred — the SAT-based
209    /// detector is already fast enough at base resolution for typical
210    /// calibration frames). Uses the Gaussian peak-fit inherited from
211    /// `RadonDetectorParams`; corners are subpixel-refined by the
212    /// detector's own peak-fit, so `refiner` is effectively a
213    /// pass-through.
214    pub fn radon() -> Self {
215        Self {
216            detector_mode: DetectorMode::Radon,
217            pyramid_levels: 1,
218            ..Self::default()
219        }
220    }
221
222    /// Translate this config into the low-level [`ChessParams`] consumed by
223    /// `chess-corners-core`. This is called internally by the detection
224    /// pipeline; callers that need direct access to `core` primitives can use
225    /// the returned value with [`chess_corners_core::detect`] functions.
226    pub fn to_chess_params(&self) -> ChessParams {
227        let mut params = ChessParams::default();
228        params.use_radius10 = matches!(self.detector_mode, DetectorMode::Broad);
229        params.descriptor_use_radius10 = match self.descriptor_mode {
230            DescriptorMode::FollowDetector => None,
231            DescriptorMode::Canonical => Some(false),
232            DescriptorMode::Broad => Some(true),
233        };
234        match self.threshold_mode {
235            ThresholdMode::Relative => {
236                params.threshold_rel = self.threshold_value;
237                params.threshold_abs = None;
238            }
239            ThresholdMode::Absolute => {
240                params.threshold_abs = Some(self.threshold_value);
241            }
242        }
243        params.nms_radius = self.nms_radius;
244        params.min_cluster_size = self.min_cluster_size;
245        params.refiner = self.refiner.to_refiner_kind();
246        params
247    }
248
249    /// Translate this config into the [`CoarseToFineParams`] that drive the
250    /// multiscale pipeline. Pyramid levels, minimum pyramid size, refinement
251    /// radius, and merge radius are all copied from this struct.
252    pub fn to_coarse_to_fine_params(&self) -> CoarseToFineParams {
253        let mut cfg = CoarseToFineParams::default();
254        let mut pyramid = PyramidParams::default();
255        pyramid.num_levels = self.pyramid_levels;
256        pyramid.min_size = self.pyramid_min_size;
257        cfg.pyramid = pyramid;
258        cfg.refinement_radius = self.refinement_radius;
259        cfg.merge_radius = self.merge_radius;
260        cfg
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn default_config_accepts_any_positive_response() {
270        let cfg = ChessConfig::default();
271        let params = cfg.to_chess_params();
272        let cf = cfg.to_coarse_to_fine_params();
273
274        assert!(!params.use_radius10);
275        assert_eq!(params.descriptor_use_radius10, None);
276        // Paper's contract: accept strictly positive R.
277        assert_eq!(cfg.threshold_mode, ThresholdMode::Absolute);
278        assert_eq!(cfg.threshold_value, 0.0);
279        assert_eq!(params.threshold_abs, Some(0.0));
280        assert_eq!(params.nms_radius, 2);
281        assert_eq!(params.min_cluster_size, 2);
282        assert_eq!(
283            params.refiner,
284            RefinerKind::CenterOfMass(CenterOfMassConfig::default())
285        );
286        assert_eq!(cf.pyramid.num_levels, 1);
287        assert_eq!(cf.pyramid.min_size, 128);
288        assert_eq!(cf.refinement_radius, 3);
289        assert_eq!(cf.merge_radius, 3.0);
290    }
291
292    #[test]
293    fn absolute_threshold_maps_to_internal_params() {
294        let cfg = ChessConfig {
295            threshold_mode: ThresholdMode::Absolute,
296            threshold_value: 7.5,
297            ..ChessConfig::default()
298        };
299
300        let params = cfg.to_chess_params();
301        assert_eq!(params.threshold_abs, Some(7.5));
302        assert_eq!(params.threshold_rel, 0.2);
303    }
304
305    #[test]
306    fn ring_and_refiner_modes_map_to_internal_params() {
307        let cfg = ChessConfig {
308            detector_mode: DetectorMode::Broad,
309            descriptor_mode: DescriptorMode::Canonical,
310            refiner: RefinerConfig {
311                kind: RefinementMethod::Forstner,
312                forstner: ForstnerConfig {
313                    max_offset: 2.0,
314                    ..ForstnerConfig::default()
315                },
316                ..RefinerConfig::default()
317            },
318            ..ChessConfig::default()
319        };
320
321        let params = cfg.to_chess_params();
322        assert!(params.use_radius10);
323        assert_eq!(params.descriptor_use_radius10, Some(false));
324        assert_eq!(
325            params.refiner,
326            RefinerKind::Forstner(ForstnerConfig {
327                max_offset: 2.0,
328                ..ForstnerConfig::default()
329            })
330        );
331    }
332
333    #[test]
334    fn multiscale_preset_has_expected_defaults() {
335        let cfg = ChessConfig::multiscale();
336        assert_eq!(cfg.pyramid_levels, 3);
337        assert_eq!(cfg.pyramid_min_size, 128);
338        assert_eq!(cfg.refinement_radius, 3);
339        assert_eq!(cfg.merge_radius, 3.0);
340    }
341}