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}