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}