Skip to main content

chess_corners/config/
detection.rs

1use box_image_pyramid::PyramidParams;
2use chess_corners_core::{ChessParams, OrientationMethod, RadonDetectorParams, RefinerKind};
3use serde::{Deserialize, Serialize};
4
5use crate::multiscale::CoarseToFineParams;
6use crate::upscale::UpscaleConfig;
7
8use super::{ChessConfig, ChessRefiner, ChessRing, MultiscaleConfig, RadonConfig};
9
10// ---------------------------------------------------------------------------
11// Shared detection params
12// ---------------------------------------------------------------------------
13
14/// Shared non-maximum-suppression and peak-clustering thresholds.
15///
16/// These two knobs have identical meaning for the ChESS and Radon
17/// detectors, so they live once at the [`DetectorConfig`] level rather
18/// than being duplicated inside each strategy config. Both are expressed
19/// in the detector's working-resolution pixels (for Radon, that is after
20/// `image_upsample`). Tune them through
21/// [`DetectorConfig::with_detection`].
22///
23/// The defaults match the ChESS presets; the Radon presets raise
24/// `nms_radius` to `4` to suit the wider Radon response peak.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27#[serde(default)]
28#[non_exhaustive]
29pub struct DetectionParams {
30    /// Non-maximum-suppression half-radius, in working-resolution pixels
31    /// (integer `>= 0`). Only the highest-response pixel within this radius
32    /// is kept: reduce when corners are packed closer than `2·nms_radius`
33    /// pixels, increase to suppress near-duplicates on blurry images.
34    #[cfg_attr(feature = "schemars", schemars(extend("x-unit" = "px")))]
35    pub nms_radius: u32,
36    /// Minimum number of positive-response neighbours within the NMS
37    /// window that a candidate must have to be accepted (integer `>= 0`,
38    /// unitless count). Increase to suppress isolated noise peaks, at the
39    /// cost of possibly missing weak corners near image boundaries.
40    pub min_cluster_size: u32,
41}
42
43impl Default for DetectionParams {
44    fn default() -> Self {
45        // Matches the ChESS presets; `DetectorConfig::default()` is `chess()`.
46        Self {
47            nms_radius: ChessParams::DEFAULT_NMS_RADIUS,
48            min_cluster_size: ChessParams::DEFAULT_MIN_CLUSTER_SIZE,
49        }
50    }
51}
52
53// ---------------------------------------------------------------------------
54// DetectionStrategy
55// ---------------------------------------------------------------------------
56
57/// Top-level detector dispatch. Selects between the ChESS kernel
58/// pipeline and the Radon whole-image detector. The chosen variant
59/// carries all detector-specific tuning; settings that don't apply to
60/// the active detector are simply unreachable, so the type system
61/// enforces correctness instead of silently ignoring fields.
62#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
63#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum DetectionStrategy {
67    /// ChESS kernel detection with optional coarse-to-fine multiscale.
68    Chess(ChessConfig),
69    /// Whole-image Radon (Duda-Frese) detection.
70    Radon(RadonConfig),
71}
72
73impl Default for DetectionStrategy {
74    fn default() -> Self {
75        DetectionStrategy::Chess(ChessConfig::default())
76    }
77}
78
79// ---------------------------------------------------------------------------
80// DetectorConfig
81// ---------------------------------------------------------------------------
82
83/// High-level detection configuration.
84///
85/// Build one with [`DetectorConfig::chess`],
86/// [`DetectorConfig::chess_multiscale`], [`DetectorConfig::radon`], or
87/// [`DetectorConfig::radon_multiscale`] and tweak only the fields you need.
88/// The detector translates this into the low-level [`ChessParams`] /
89/// [`RadonDetectorParams`] consumed by `chess-corners-core` at the detection
90/// boundary.
91///
92/// # Common knobs
93///
94/// These fields are the primary surface for most callers:
95///
96/// - [`strategy`](DetectorConfig::strategy) — choose ChESS or Radon and
97///   configure its parameters.
98/// - [`threshold`](DetectorConfig::threshold) — control how many corners are
99///   returned: lower → more candidates, higher → fewer and stronger. ChESS
100///   reads it as an absolute response floor; Radon as a fraction of the
101///   per-frame maximum.
102/// - [`multiscale`](DetectorConfig::multiscale) — enable coarse-to-fine
103///   pyramid detection (`Pyramid`) or keep it off (`SingleScale`).
104/// - [`upscale`](DetectorConfig::upscale) — pre-pipeline integer bilinear
105///   upscaling for low-resolution inputs where corners have fewer than 5 px
106///   of ring support. `Disabled` by default.
107/// - [`orientation_method`](DetectorConfig::orientation_method) — how corner
108///   axis orientations are estimated when building descriptors.
109///
110/// # Advanced tuning
111///
112/// - [`detection`](DetectorConfig::detection) — shared NMS / clustering
113///   thresholds applied by both strategies. See [`DetectionParams`].
114/// - [`merge_radius`](DetectorConfig::merge_radius) — duplicate-suppression
115///   radius across pyramid levels. See the field docs below.
116#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
117#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
118#[serde(default)]
119#[non_exhaustive]
120pub struct DetectorConfig {
121    /// Detector dispatch: ChESS or Radon, each carrying its own tuning.
122    pub strategy: DetectionStrategy,
123    /// Detector acceptance threshold (`>= 0`).
124    ///
125    /// ChESS reads it as an absolute floor on the raw response
126    /// `R = SR − DR − 16·MR`: a candidate is kept when `R` exceeds it.
127    /// Useful floors run roughly `30..=300` depending on image contrast;
128    /// the default is `30`, which suppresses texture noise while keeping
129    /// well-formed corners.
130    /// Radon reads it as a fraction in `[0.0, 1.0]` of the per-frame
131    /// maximum response, because Radon's `(max − min)²` score scales
132    /// with image size and has no portable absolute scale.
133    #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
134    pub threshold: f32,
135    /// Shared non-maximum-suppression and peak-clustering thresholds.
136    /// Honoured by both strategies. See [`DetectionParams`].
137    pub detection: DetectionParams,
138    /// Coarse-to-fine multiscale configuration. `SingleScale` skips
139    /// the pyramid entirely; `Pyramid` detects seeds on a coarse level and
140    /// refines them into the base image. Honoured by both strategies.
141    pub multiscale: MultiscaleConfig,
142    /// Pre-pipeline integer bilinear upscaling for low-resolution inputs.
143    /// `Disabled` skips the stage; `Fixed(k)` upscales by the unitless
144    /// factor `k` in `{2, 3, 4}`. Output coordinates stay in input pixels.
145    pub upscale: UpscaleConfig,
146    /// Orientation-fit method used when building corner descriptors, or
147    /// `None` to skip the per-corner fit entirely. When `None`, every
148    /// descriptor carries `axes: None`; positions and responses are
149    /// unaffected. Skipping orientation is the cheaper path for consumers
150    /// that derive board geometry themselves.
151    pub orientation_method: Option<OrientationMethod>,
152    /// Advanced tuning. Merge radius for cross-level and cross-seed
153    /// duplicate suppression, in base-image pixels (`>= 0`, default `3.0`).
154    /// After coarse-level seeds are refined into the base image, any two
155    /// refined positions within this radius are merged into one corner:
156    /// increase if you see duplicates of the same physical corner, decrease
157    /// if distinct corners closer than `2·merge_radius` pixels get merged.
158    #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
159    #[cfg_attr(feature = "schemars", schemars(extend("x-unit" = "px")))]
160    pub merge_radius: f32,
161}
162
163impl Default for DetectorConfig {
164    fn default() -> Self {
165        Self::chess()
166    }
167}
168
169impl DetectorConfig {
170    /// Single-scale ChESS preset.
171    pub fn chess() -> Self {
172        Self {
173            strategy: DetectionStrategy::Chess(ChessConfig::default()),
174            // Absolute floor on the ChESS response: suppresses texture
175            // noise while keeping well-formed corners. See the field doc.
176            threshold: 30.0,
177            detection: DetectionParams::default(),
178            multiscale: MultiscaleConfig::SingleScale,
179            upscale: UpscaleConfig::Disabled,
180            orientation_method: Some(OrientationMethod::default()),
181            merge_radius: 3.0,
182        }
183    }
184
185    /// Three-level coarse-to-fine ChESS preset.
186    pub fn chess_multiscale() -> Self {
187        Self {
188            multiscale: MultiscaleConfig::pyramid_default(),
189            ..Self::chess()
190        }
191    }
192
193    /// Whole-image Radon detector preset.
194    /// Single-scale; use [`Self::radon_multiscale`] for coarse-to-fine
195    /// Radon detection on larger frames.
196    pub fn radon() -> Self {
197        Self {
198            strategy: DetectionStrategy::Radon(RadonConfig::default()),
199            threshold: RadonDetectorParams::DEFAULT_THRESHOLD_REL,
200            detection: DetectionParams {
201                nms_radius: RadonDetectorParams::DEFAULT_NMS_RADIUS,
202                min_cluster_size: RadonDetectorParams::DEFAULT_MIN_CLUSTER_SIZE,
203            },
204            multiscale: MultiscaleConfig::SingleScale,
205            ..Self::chess()
206        }
207    }
208
209    /// Coarse-to-fine Radon preset. Measure against [`Self::radon`] on
210    /// your target frame sizes; this preset trades more configuration
211    /// machinery for less full-resolution detector work on large frames.
212    pub fn radon_multiscale() -> Self {
213        Self {
214            multiscale: MultiscaleConfig::pyramid_default(),
215            ..Self::radon()
216        }
217    }
218
219    /// Set the active strategy to ChESS and apply `f` to the nested config.
220    /// If the current strategy is already ChESS, mutate it in place.
221    /// Otherwise, replace the strategy with [`ChessConfig::default`] and apply `f`.
222    ///
223    /// Top-level fields (threshold, multiscale, upscale, orientation_method,
224    /// merge_radius) are untouched. When switching strategies, prefer the
225    /// preset constructors — ChESS reads `threshold` as an absolute response
226    /// floor, Radon as a fraction of the per-frame maximum.
227    pub fn with_chess<F: FnOnce(&mut ChessConfig)>(mut self, f: F) -> Self {
228        let mut chess = match self.strategy {
229            DetectionStrategy::Chess(c) => c,
230            DetectionStrategy::Radon(_) => ChessConfig::default(),
231        };
232        f(&mut chess);
233        self.strategy = DetectionStrategy::Chess(chess);
234        self
235    }
236
237    /// Mirror of [`Self::with_chess`] for the Radon strategy.
238    pub fn with_radon<F: FnOnce(&mut RadonConfig)>(mut self, f: F) -> Self {
239        let mut radon = match self.strategy {
240            DetectionStrategy::Radon(r) => r,
241            DetectionStrategy::Chess(_) => RadonConfig::default(),
242        };
243        f(&mut radon);
244        self.strategy = DetectionStrategy::Radon(radon);
245        self
246    }
247
248    /// Replace the acceptance threshold. See [`DetectorConfig::threshold`]
249    /// for the per-detector interpretation.
250    pub fn with_threshold(mut self, threshold: f32) -> Self {
251        self.threshold = threshold;
252        self
253    }
254    /// Replace the multiscale configuration.
255    pub fn with_multiscale(mut self, multiscale: MultiscaleConfig) -> Self {
256        self.multiscale = multiscale;
257        self
258    }
259    /// Replace the upscale configuration.
260    pub fn with_upscale(mut self, upscale: UpscaleConfig) -> Self {
261        self.upscale = upscale;
262        self
263    }
264    /// Replace the orientation-fit method used when building descriptors.
265    pub fn with_orientation_method(mut self, method: OrientationMethod) -> Self {
266        self.orientation_method = Some(method);
267        self
268    }
269    /// Skip the per-corner orientation fit. Descriptors are still produced
270    /// with subpixel positions and responses, but carry `axes: None`. Use
271    /// this when you derive board geometry yourself and don't need the
272    /// per-corner axes — it removes the dominant per-corner cost.
273    pub fn without_orientation(mut self) -> Self {
274        self.orientation_method = None;
275        self
276    }
277    /// Replace the merge radius for cross-level duplicate suppression.
278    pub fn with_merge_radius(mut self, radius: f32) -> Self {
279        self.merge_radius = radius;
280        self
281    }
282
283    /// Apply `f` to the shared [`DetectionParams`] (NMS / clustering
284    /// thresholds honoured by both strategies) and return the updated
285    /// config.
286    pub fn with_detection<F: FnOnce(&mut DetectionParams)>(mut self, f: F) -> Self {
287        f(&mut self.detection);
288        self
289    }
290
291    /// Lower this config into the [`ChessParams`] consumed by the
292    /// `chess-corners-core` response and detection stages. Use this when
293    /// driving the core stage functions directly instead of through
294    /// [`Detector`](crate::Detector). Only meaningful when
295    /// [`Self::strategy`] is the ChESS variant.
296    ///
297    /// When the active strategy is [`DetectionStrategy::Radon`], the
298    /// ChESS-specific fields fall back to their [`ChessParams::default()`]
299    /// values; callers should route through
300    /// [`Self::radon_detector_params`] instead.
301    pub fn chess_params(&self) -> ChessParams {
302        let mut params = ChessParams::default();
303        params.nms_radius = self.detection.nms_radius;
304        params.min_cluster_size = self.detection.min_cluster_size;
305        if let DetectionStrategy::Chess(chess) = &self.strategy {
306            params.use_radius10 = matches!(chess.ring, ChessRing::Broad);
307            match chess.refiner {
308                ChessRefiner::CenterOfMass(cfg) => params.refiner = RefinerKind::CenterOfMass(cfg),
309                ChessRefiner::Forstner(cfg) => params.refiner = RefinerKind::Forstner(cfg),
310                ChessRefiner::SaddlePoint(cfg) => params.refiner = RefinerKind::SaddlePoint(cfg),
311                // The ML refiner runs in the facade, not core: `Detector`
312                // routes `Ml` to the ONNX pipeline. Core only produces the
313                // candidates (and coarse-level seeds) the ML pass consumes,
314                // using its default refiner — so leave `params.refiner` at
315                // the core default rather than substituting an unrelated
316                // kind.
317                #[cfg(feature = "ml-refiner")]
318                ChessRefiner::Ml => {}
319            }
320        }
321        // ChESS interprets `threshold` as an absolute floor on the raw response.
322        params.threshold = self.threshold;
323        params.orientation_method = self.orientation_method;
324        params
325    }
326
327    /// Lower this config into the [`RadonDetectorParams`] consumed by the
328    /// `chess-corners-core` Radon response and detection stages. Use this
329    /// when driving the core stage functions directly instead of through
330    /// [`Detector`](crate::Detector). Only meaningful when
331    /// [`Self::strategy`] is the Radon variant.
332    ///
333    /// When the active strategy is [`DetectionStrategy::Chess`], the
334    /// Radon-specific fields fall back to their
335    /// [`RadonDetectorParams::default()`] values; callers should route
336    /// through [`Self::chess_params`] instead.
337    pub fn radon_detector_params(&self) -> RadonDetectorParams {
338        let mut params = RadonDetectorParams::default();
339        params.nms_radius = self.detection.nms_radius;
340        params.min_cluster_size = self.detection.min_cluster_size;
341        if let DetectionStrategy::Radon(radon) = &self.strategy {
342            params.ray_radius = radon.ray_radius;
343            params.image_upsample = radon.image_upsample;
344            params.response_blur_radius = radon.response_blur_radius;
345            params.peak_fit = radon.peak_fit;
346        }
347        // Radon interprets `threshold` as a fraction of the per-frame maximum.
348        params.threshold_rel = self.threshold;
349        params
350    }
351
352    /// Lower this config into the [`CoarseToFineParams`] that drive the
353    /// multiscale pipeline. Returns `None` when [`Self::multiscale`]
354    /// is [`MultiscaleConfig::SingleScale`]. Both ChESS and Radon honour
355    /// the same top-level multiscale settings. Use this when composing
356    /// the multiscale stages directly instead of through
357    /// [`Detector`](crate::Detector).
358    pub(crate) fn coarse_to_fine_params(&self) -> Option<CoarseToFineParams> {
359        let MultiscaleConfig::Pyramid {
360            levels,
361            min_size,
362            refinement_radius,
363        } = self.multiscale
364        else {
365            return None;
366        };
367        let mut cfg = CoarseToFineParams::default();
368        let mut pyramid = PyramidParams::default();
369        pyramid.num_levels = levels;
370        pyramid.min_size = min_size;
371        cfg.pyramid = pyramid;
372        cfg.refinement_radius = refinement_radius;
373        cfg.merge_radius = self.merge_radius;
374        Some(cfg)
375    }
376}