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