Skip to main content

chess_corners/
config.rs

1use box_image_pyramid::PyramidParams;
2use chess_corners_core::{
3    CenterOfMassConfig, ChessParams, ForstnerConfig, OrientationMethod, PeakFitMode,
4    RadonDetectorParams, RadonPeakConfig, RefinerKind, SaddlePointConfig,
5};
6use serde::{Deserialize, Serialize};
7
8use crate::multiscale::CoarseToFineParams;
9use crate::upscale::UpscaleConfig;
10
11// ---------------------------------------------------------------------------
12// Threshold
13// ---------------------------------------------------------------------------
14
15/// Detector acceptance threshold.
16///
17/// A single, mode-aware enum that replaces the previous `(threshold_mode,
18/// threshold_value)` pair. Both the ChESS and Radon pipelines route through
19/// the same enum, so the user can't set a relative value while the active
20/// detector reads it as absolute.
21///
22/// - For ChESS the response is the paper's `R = SR − DR − 16·MR`.
23///   `Absolute(0.0)` encodes the paper's `R > 0` acceptance contract.
24/// - For Radon the response is the squared range `(max − min)²` of the
25///   ray-sum range across orientations; pick a positive `Absolute(_)` floor
26///   or a `Relative(_)` fraction of the per-frame maximum.
27#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum Threshold {
31    /// Accept responses `≥ value` in the detector's native score units.
32    Absolute(f32),
33    /// Accept responses `≥ frac · max(response)` in the current frame.
34    /// `frac` is a fraction in `[0.0, 1.0]`.
35    Relative(f32),
36}
37
38impl Default for Threshold {
39    fn default() -> Self {
40        // Paper's ChESS contract: any strictly positive response is a corner.
41        // Radon presets override this to `Relative(0.01)`.
42        Threshold::Absolute(0.0)
43    }
44}
45
46// ---------------------------------------------------------------------------
47// Detector kernel / ring selection
48// ---------------------------------------------------------------------------
49
50/// ChESS sampling ring radius. Selects the `r=5` (canonical) or `r=10`
51/// (broad) ring used by the dense response kernel.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54#[non_exhaustive]
55pub enum ChessRing {
56    /// Paper-default radius-5 ring (16 samples).
57    #[default]
58    Canonical,
59    /// Radius-10 ring. Larger support window for callers that want the
60    /// detector to sample farther from the candidate center.
61    Broad,
62}
63
64/// Descriptor sampling ring selection. Independent of the detector ring
65/// chosen by [`ChessRing`].
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum DescriptorRing {
70    /// Use the same ring radius as the detector.
71    #[default]
72    FollowDetector,
73    /// Force the descriptor ring to `r=5`.
74    Canonical,
75    /// Force the descriptor ring to `r=10`.
76    Broad,
77}
78
79// ---------------------------------------------------------------------------
80// Refiner enums (one per detector)
81// ---------------------------------------------------------------------------
82
83/// Subpixel refiner selection for the ChESS detector.
84///
85/// Each variant carries its own tuning struct as a payload: there is
86/// no shared discriminator + parallel-tuning-struct shape, so
87/// switching variants can never leave a stale config field behind.
88#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90#[non_exhaustive]
91pub enum ChessRefiner {
92    /// Center-of-mass (intensity centroid) refinement on the response
93    /// map. Cheapest refiner in the shipped benchmark; the library default.
94    CenterOfMass(CenterOfMassConfig),
95    /// Förstner structure-tensor refinement on the image patch.
96    Forstner(ForstnerConfig),
97    /// Quadratic surface fit at the saddle point.
98    SaddlePoint(SaddlePointConfig),
99    /// ML-backed subpixel refinement. Runs a small ONNX model on a
100    /// normalized intensity patch around each candidate. Requires the
101    /// `ml-refiner` feature.
102    #[cfg(feature = "ml-refiner")]
103    Ml,
104}
105
106impl Default for ChessRefiner {
107    fn default() -> Self {
108        Self::CenterOfMass(CenterOfMassConfig::default())
109    }
110}
111
112impl ChessRefiner {
113    /// Center-of-mass refinement with default tuning.
114    pub fn center_of_mass() -> Self {
115        Self::CenterOfMass(CenterOfMassConfig::default())
116    }
117    /// Förstner structure-tensor refinement with default tuning.
118    pub fn forstner() -> Self {
119        Self::Forstner(ForstnerConfig::default())
120    }
121    /// Saddle-point quadratic fit with default tuning.
122    pub fn saddle_point() -> Self {
123        Self::SaddlePoint(SaddlePointConfig::default())
124    }
125}
126
127/// Subpixel refiner selection for the whole-image Radon detector.
128///
129/// Radon's `detect_corners` already runs a 3-point Gaussian peak fit
130/// on the response map; downstream refiners operate on the original
131/// image patch when meaningful.
132#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum RadonRefiner {
136    /// Radon-projection refinement along candidate axes.
137    RadonPeak(RadonPeakConfig),
138    /// Center-of-mass refinement on the response map. A faster
139    /// alternative when the Radon peak quality is already high.
140    CenterOfMass(CenterOfMassConfig),
141}
142
143impl Default for RadonRefiner {
144    fn default() -> Self {
145        Self::RadonPeak(RadonPeakConfig::default())
146    }
147}
148
149impl RadonRefiner {
150    /// Radon-projection refinement with default tuning.
151    pub fn radon_peak() -> Self {
152        Self::RadonPeak(RadonPeakConfig::default())
153    }
154    /// Center-of-mass refinement with default tuning.
155    pub fn center_of_mass() -> Self {
156        Self::CenterOfMass(CenterOfMassConfig::default())
157    }
158}
159
160// ---------------------------------------------------------------------------
161// Multiscale configuration
162// ---------------------------------------------------------------------------
163
164/// Coarse-to-fine multiscale configuration.
165///
166/// JSON shape mirrors [`Threshold`] and [`UpscaleConfig`]:
167///
168/// - `{ "single_scale": null }` — run the detector once on the full image.
169/// - `{ "pyramid": { "levels": 3, "min_size": 128, "refinement_radius": 3 } }`
170///   — build an image pyramid, detect seeds on the coarsest level, and
171///   refine each seed into the base image. Honoured by both ChESS and
172///   Radon strategies.
173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175#[non_exhaustive]
176pub enum MultiscaleConfig {
177    /// Single-scale detection (no pyramid).
178    #[default]
179    SingleScale,
180    /// Coarse-to-fine pyramid detection.
181    Pyramid {
182        /// Number of pyramid levels (≥ 1). Level 0 is the base image;
183        /// each subsequent level is a 2× box-filter downsample.
184        levels: u8,
185        /// Minimum short-edge length in pixels. The pyramid stops once
186        /// the next level would fall below this size.
187        min_size: usize,
188        /// ROI half-radius at the coarse level used to refine each seed
189        /// into the base image, in coarse-level pixels.
190        refinement_radius: u32,
191    },
192}
193
194impl MultiscaleConfig {
195    /// Three-level pyramid with library defaults (`min_size = 128`, `refinement_radius = 3`).
196    /// Equivalent to the multiscale preset used by [`DetectorConfig::chess_multiscale`]
197    /// and [`DetectorConfig::radon_multiscale`].
198    pub const fn pyramid_default() -> Self {
199        Self::Pyramid {
200            levels: 3,
201            min_size: 128,
202            refinement_radius: 3,
203        }
204    }
205    /// Pyramid with caller-supplied parameters.
206    pub const fn pyramid(levels: u8, min_size: usize, refinement_radius: u32) -> Self {
207        Self::Pyramid {
208            levels,
209            min_size,
210            refinement_radius,
211        }
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Per-strategy configs
217// ---------------------------------------------------------------------------
218
219/// Configuration for the ChESS detector branch of [`DetectionStrategy`].
220///
221/// Carries the detector ring choice, descriptor ring choice, NMS /
222/// clustering thresholds (in input-image pixels), and the subpixel
223/// refiner. Multiscale and upscale live at the top level of
224/// [`DetectorConfig`] and apply to both strategies.
225///
226/// # Common knobs
227///
228/// - [`ring`](ChessConfig::ring) — choose the detector kernel radius.
229/// - [`descriptor_ring`](ChessConfig::descriptor_ring) — choose the
230///   descriptor sampling radius.
231/// - [`refiner`](ChessConfig::refiner) — select and configure the
232///   subpixel refinement backend.
233///
234/// # Advanced tuning
235///
236/// [`nms_radius`](ChessConfig::nms_radius) and
237/// [`min_cluster_size`](ChessConfig::min_cluster_size) control NMS and
238/// peak filtering. The defaults work well across a wide range of image
239/// scales. Reduce `nms_radius` when corners are packed tightly; increase
240/// `min_cluster_size` to suppress isolated noise peaks.
241#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
242#[serde(default)]
243#[non_exhaustive]
244pub struct ChessConfig {
245    /// Detector ring radius. `Canonical` selects the paper's `r=5`,
246    /// `Broad` selects `r=10`.
247    pub ring: ChessRing,
248    /// Descriptor sampling ring. Independent of the detector ring;
249    /// `FollowDetector` mirrors the detector's choice.
250    pub descriptor_ring: DescriptorRing,
251    /// Advanced tuning. Non-maximum-suppression half-radius in
252    /// input-image pixels. Only the highest-response pixel within this
253    /// radius is kept. Default is `2` (5×5 suppression window).
254    /// Reduce when corners are closer together than `2·nms_radius`
255    /// pixels; increase to suppress near-duplicate detections on
256    /// blurry images.
257    pub nms_radius: u32,
258    /// Advanced tuning. Minimum number of positive-response neighbours
259    /// within the NMS window that a candidate must have to be accepted.
260    /// Default is `2`. Increase to require a stronger local cluster of
261    /// response, suppressing isolated noise peaks at the cost of
262    /// potentially missing weak corners near image boundaries.
263    pub min_cluster_size: u32,
264    /// Subpixel refiner. Each variant carries its tuning struct.
265    pub refiner: ChessRefiner,
266}
267
268impl Default for ChessConfig {
269    fn default() -> Self {
270        Self {
271            ring: ChessRing::Canonical,
272            descriptor_ring: DescriptorRing::FollowDetector,
273            nms_radius: 2,
274            min_cluster_size: 2,
275            refiner: ChessRefiner::default(),
276        }
277    }
278}
279
280/// Configuration for the whole-image Radon detector branch of
281/// [`DetectionStrategy`].
282///
283/// All radii and counts are in **working-resolution** pixels (i.e.
284/// after `image_upsample`). Multiscale and upscale live at the top
285/// level of [`DetectorConfig`] and apply to both strategies.
286///
287/// # Common knobs
288///
289/// - [`refiner`](RadonConfig::refiner) — select and configure the
290///   subpixel refinement backend.
291/// - [`image_upsample`](RadonConfig::image_upsample) — `2` (the default)
292///   reproduces the paper's 2× supersampled detection; `1` is faster but
293///   less accurate on low-resolution inputs.
294///
295/// # Advanced tuning
296///
297/// The remaining fields control low-level detection behaviour. The
298/// defaults reproduce the paper's recommended settings and work well
299/// for typical camera images. Adjust them only when you have a specific
300/// reason (e.g. a non-standard image resolution or SNR budget).
301#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
302#[serde(default)]
303#[non_exhaustive]
304pub struct RadonConfig {
305    /// Advanced tuning. Half-length of each Radon ray in
306    /// working-resolution pixels. The ray has `2·ray_radius + 1`
307    /// samples. Paper default at `image_upsample = 2` is `ray_radius = 4`.
308    /// Shorter rays are faster but integrate less signal; longer rays are
309    /// more discriminating but may cross into neighbouring cells.
310    pub ray_radius: u32,
311    /// Image-level supersampling factor applied before ray integration.
312    /// `1` operates on the input grid; `2` (paper default) is equivalent
313    /// to bilinearly upsampling the input first, giving sub-pixel ray
314    /// positioning. Values ≥ 3 are clamped to 2 by the core detector.
315    pub image_upsample: u32,
316    /// Advanced tuning. Half-size of the box blur applied to the Radon
317    /// response map after integration. `0` disables blurring; `1`
318    /// (default) yields a 3×3 box, smoothing quantisation noise in the
319    /// response. Increase only on very high-SNR images where extra
320    /// smoothing is unwanted.
321    pub response_blur_radius: u32,
322    /// Advanced tuning. Peak-fit mode for the 3-point subpixel
323    /// refinement of the response-map argmax. `Gaussian` (default) fits
324    /// on log-response (more accurate near the peak); `Parabolic` fits
325    /// directly on the response values. See [`PeakFitMode`].
326    pub peak_fit: PeakFitMode,
327    /// Advanced tuning. Non-maximum-suppression half-radius in
328    /// working-resolution pixels. Default is `4`. See
329    /// [`ChessConfig::nms_radius`] for guidance; note that these pixels
330    /// are at working resolution (after `image_upsample`).
331    pub nms_radius: u32,
332    /// Advanced tuning. Minimum number of positive-response neighbours
333    /// within the NMS window that a candidate must have to be accepted.
334    /// Default is `2`. See [`ChessConfig::min_cluster_size`] for guidance.
335    pub min_cluster_size: u32,
336    /// Subpixel refiner. Each variant carries its tuning struct.
337    pub refiner: RadonRefiner,
338}
339
340impl Default for RadonConfig {
341    fn default() -> Self {
342        Self {
343            ray_radius: 4,
344            image_upsample: 2,
345            response_blur_radius: 1,
346            peak_fit: PeakFitMode::Gaussian,
347            nms_radius: 4,
348            min_cluster_size: 2,
349            refiner: RadonRefiner::default(),
350        }
351    }
352}
353
354// ---------------------------------------------------------------------------
355// DetectionStrategy
356// ---------------------------------------------------------------------------
357
358/// Top-level detector dispatch. Selects between the ChESS kernel
359/// pipeline and the Radon whole-image detector. The chosen variant
360/// carries all detector-specific tuning; settings that don't apply to
361/// the active detector are simply unreachable, so the type system
362/// enforces correctness instead of silently ignoring fields.
363#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365#[non_exhaustive]
366pub enum DetectionStrategy {
367    /// ChESS kernel detection with optional coarse-to-fine multiscale.
368    Chess(ChessConfig),
369    /// Whole-image Radon (Duda-Frese) detection.
370    Radon(RadonConfig),
371}
372
373impl Default for DetectionStrategy {
374    fn default() -> Self {
375        DetectionStrategy::Chess(ChessConfig::default())
376    }
377}
378
379// ---------------------------------------------------------------------------
380// DetectorConfig
381// ---------------------------------------------------------------------------
382
383/// High-level detection configuration.
384///
385/// Build one with [`DetectorConfig::chess`],
386/// [`DetectorConfig::chess_multiscale`], [`DetectorConfig::radon`], or
387/// [`DetectorConfig::radon_multiscale`] and tweak only the fields you need.
388/// The detector translates this into the low-level [`ChessParams`] /
389/// [`RadonDetectorParams`] consumed by `chess-corners-core` at the detection
390/// boundary.
391///
392/// # Common knobs
393///
394/// These fields are the primary surface for most callers:
395///
396/// - [`strategy`](DetectorConfig::strategy) — choose ChESS or Radon and
397///   configure its parameters.
398/// - [`threshold`](DetectorConfig::threshold) — control how many corners are
399///   returned: lower `Relative` fraction or `Absolute` floor → more
400///   candidates; higher → fewer, stronger ones.
401/// - [`multiscale`](DetectorConfig::multiscale) — enable coarse-to-fine
402///   pyramid detection (`Pyramid`) or keep it off (`SingleScale`).
403/// - [`upscale`](DetectorConfig::upscale) — pre-pipeline integer bilinear
404///   upscaling for low-resolution inputs where corners have fewer than 5 px
405///   of ring support. `Disabled` by default.
406/// - [`orientation_method`](DetectorConfig::orientation_method) — how corner
407///   axis orientations are estimated when building descriptors.
408///
409/// # Advanced tuning
410///
411/// - [`merge_radius`](DetectorConfig::merge_radius) — duplicate-suppression
412///   radius across pyramid levels. See the field docs below.
413#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
414#[serde(default)]
415#[non_exhaustive]
416pub struct DetectorConfig {
417    /// Detector dispatch: ChESS or Radon, each carrying its own tuning.
418    pub strategy: DetectionStrategy,
419    /// Acceptance threshold. Same enum is honoured by both detectors.
420    pub threshold: Threshold,
421    /// Coarse-to-fine multiscale configuration. `SingleScale` skips
422    /// the pyramid entirely. Honoured by both strategies.
423    pub multiscale: MultiscaleConfig,
424    /// Pre-pipeline integer upscaling. `Disabled` skips the stage.
425    pub upscale: UpscaleConfig,
426    /// Orientation-fit method used when building corner descriptors.
427    pub orientation_method: OrientationMethod,
428    /// Advanced tuning. Merge radius in base-image pixels for
429    /// cross-level and cross-seed duplicate suppression. After seeds
430    /// detected at coarser pyramid levels are refined into the base
431    /// image, any two refined positions within this radius are merged
432    /// into a single output corner. Default is `3.0` px. Increase if
433    /// you see duplicate detections near the same physical corner;
434    /// decrease if distinct corners closer than `2·merge_radius` pixels
435    /// are being merged.
436    pub merge_radius: f32,
437}
438
439impl Default for DetectorConfig {
440    fn default() -> Self {
441        Self::chess()
442    }
443}
444
445impl DetectorConfig {
446    /// Single-scale ChESS preset.
447    pub fn chess() -> Self {
448        Self {
449            strategy: DetectionStrategy::Chess(ChessConfig::default()),
450            threshold: Threshold::Absolute(0.0),
451            multiscale: MultiscaleConfig::SingleScale,
452            upscale: UpscaleConfig::Disabled,
453            orientation_method: OrientationMethod::default(),
454            merge_radius: 3.0,
455        }
456    }
457
458    /// Three-level coarse-to-fine ChESS preset.
459    pub fn chess_multiscale() -> Self {
460        Self {
461            multiscale: MultiscaleConfig::pyramid_default(),
462            ..Self::chess()
463        }
464    }
465
466    /// Whole-image Radon detector preset.
467    /// Single-scale; use [`Self::radon_multiscale`] for coarse-to-fine
468    /// Radon detection on larger frames.
469    pub fn radon() -> Self {
470        Self {
471            strategy: DetectionStrategy::Radon(RadonConfig::default()),
472            threshold: Threshold::Relative(0.01),
473            multiscale: MultiscaleConfig::SingleScale,
474            ..Self::chess()
475        }
476    }
477
478    /// Coarse-to-fine Radon preset. Measure against [`Self::radon`] on
479    /// your target frame sizes; this preset trades more configuration
480    /// machinery for less full-resolution detector work on large frames.
481    pub fn radon_multiscale() -> Self {
482        Self {
483            strategy: DetectionStrategy::Radon(RadonConfig::default()),
484            threshold: Threshold::Relative(0.01),
485            multiscale: MultiscaleConfig::pyramid_default(),
486            ..Self::chess()
487        }
488    }
489
490    /// Set the active strategy to ChESS and apply `f` to the nested config.
491    /// If the current strategy is already ChESS, mutate it in place.
492    /// Otherwise, replace the strategy with [`ChessConfig::default`] and apply `f`.
493    ///
494    /// Top-level fields (threshold, multiscale, upscale, orientation_method,
495    /// merge_radius) are untouched. When switching strategies, prefer the
496    /// preset constructors — Radon uses `Relative(0.01)` thresholds while
497    /// ChESS uses `Absolute(0.0)`.
498    pub fn with_chess<F: FnOnce(&mut ChessConfig)>(mut self, f: F) -> Self {
499        let mut chess = match self.strategy {
500            DetectionStrategy::Chess(c) => c,
501            DetectionStrategy::Radon(_) => ChessConfig::default(),
502        };
503        f(&mut chess);
504        self.strategy = DetectionStrategy::Chess(chess);
505        self
506    }
507
508    /// Mirror of [`Self::with_chess`] for the Radon strategy.
509    pub fn with_radon<F: FnOnce(&mut RadonConfig)>(mut self, f: F) -> Self {
510        let mut radon = match self.strategy {
511            DetectionStrategy::Radon(r) => r,
512            DetectionStrategy::Chess(_) => RadonConfig::default(),
513        };
514        f(&mut radon);
515        self.strategy = DetectionStrategy::Radon(radon);
516        self
517    }
518
519    /// Replace the acceptance threshold.
520    pub fn with_threshold(mut self, threshold: Threshold) -> Self {
521        self.threshold = threshold;
522        self
523    }
524    /// Replace the multiscale configuration.
525    pub fn with_multiscale(mut self, multiscale: MultiscaleConfig) -> Self {
526        self.multiscale = multiscale;
527        self
528    }
529    /// Replace the upscale configuration.
530    pub fn with_upscale(mut self, upscale: UpscaleConfig) -> Self {
531        self.upscale = upscale;
532        self
533    }
534    /// Replace the orientation-fit method used when building descriptors.
535    pub fn with_orientation_method(mut self, method: OrientationMethod) -> Self {
536        self.orientation_method = method;
537        self
538    }
539    /// Replace the merge radius for cross-level duplicate suppression.
540    pub fn with_merge_radius(mut self, radius: f32) -> Self {
541        self.merge_radius = radius;
542        self
543    }
544
545    /// Translate this config into the low-level [`ChessParams`] consumed
546    /// by `chess-corners-core`. Only meaningful when
547    /// [`Self::strategy`] is the ChESS variant.
548    ///
549    /// When the active strategy is [`DetectionStrategy::Radon`], the
550    /// ChESS-specific fields fall back to their [`ChessParams::default()`]
551    /// values; callers should route through
552    /// [`Self::radon_detector_params`] instead.
553    pub(crate) fn chess_params(&self) -> ChessParams {
554        let mut params = ChessParams::default();
555        if let DetectionStrategy::Chess(chess) = &self.strategy {
556            params.use_radius10 = matches!(chess.ring, ChessRing::Broad);
557            params.nms_radius = chess.nms_radius;
558            params.min_cluster_size = chess.min_cluster_size;
559            params.descriptor_use_radius10 = match chess.descriptor_ring {
560                DescriptorRing::FollowDetector => None,
561                DescriptorRing::Canonical => Some(false),
562                DescriptorRing::Broad => Some(true),
563            };
564            params.refiner = chess_refiner_to_kind(chess.refiner);
565        }
566        apply_threshold(&mut params, self.threshold);
567        params.orientation_method = self.orientation_method;
568        params
569    }
570
571    /// Translate this config into the low-level [`RadonDetectorParams`]
572    /// consumed by `chess-corners-core`. Only meaningful when
573    /// [`Self::strategy`] is the Radon variant.
574    ///
575    /// When the active strategy is [`DetectionStrategy::Chess`], the
576    /// Radon-specific fields fall back to their
577    /// [`RadonDetectorParams::default()`] values; callers should route
578    /// through [`Self::chess_params`] instead.
579    pub(crate) fn radon_detector_params(&self) -> RadonDetectorParams {
580        let mut params = RadonDetectorParams::default();
581        if let DetectionStrategy::Radon(radon) = &self.strategy {
582            params.ray_radius = radon.ray_radius;
583            params.image_upsample = radon.image_upsample;
584            params.response_blur_radius = radon.response_blur_radius;
585            params.peak_fit = radon.peak_fit;
586            params.nms_radius = radon.nms_radius;
587            params.min_cluster_size = radon.min_cluster_size;
588            params.refiner = radon_refiner_to_kind(radon.refiner);
589        }
590        apply_threshold(&mut params, self.threshold);
591        params
592    }
593
594    /// Translate this config into the [`CoarseToFineParams`] that drive
595    /// the multiscale pipeline. Returns `None` when [`Self::multiscale`]
596    /// is [`MultiscaleConfig::SingleScale`]. Both ChESS and Radon honour
597    /// the same top-level multiscale settings.
598    pub(crate) fn coarse_to_fine_params(&self) -> Option<CoarseToFineParams> {
599        let MultiscaleConfig::Pyramid {
600            levels,
601            min_size,
602            refinement_radius,
603        } = self.multiscale
604        else {
605            return None;
606        };
607        let mut cfg = CoarseToFineParams::default();
608        let mut pyramid = PyramidParams::default();
609        pyramid.num_levels = levels;
610        pyramid.min_size = min_size;
611        cfg.pyramid = pyramid;
612        cfg.refinement_radius = refinement_radius;
613        cfg.merge_radius = self.merge_radius;
614        Some(cfg)
615    }
616}
617
618// ---------------------------------------------------------------------------
619// Refiner-enum → core RefinerKind translation
620// ---------------------------------------------------------------------------
621
622/// Translate a [`ChessRefiner`] into the lower-level [`RefinerKind`] used
623/// by `chess-corners-core`.
624///
625/// The [`ChessRefiner::Ml`] variant (gated on the `ml-refiner` feature)
626/// does not map to a core [`RefinerKind`] variant — the ML refiner
627/// lives in the facade, not the core crate. The translation falls back
628/// to center-of-mass with default tuning so the coarse pass and any
629/// inference-time fallback both stay well-defined.
630pub(crate) fn chess_refiner_to_kind(refiner: ChessRefiner) -> RefinerKind {
631    match refiner {
632        ChessRefiner::CenterOfMass(cfg) => RefinerKind::CenterOfMass(cfg),
633        ChessRefiner::Forstner(cfg) => RefinerKind::Forstner(cfg),
634        ChessRefiner::SaddlePoint(cfg) => RefinerKind::SaddlePoint(cfg),
635        #[cfg(feature = "ml-refiner")]
636        ChessRefiner::Ml => RefinerKind::CenterOfMass(CenterOfMassConfig::default()),
637    }
638}
639
640/// Translate a [`RadonRefiner`] into the lower-level [`RefinerKind`]
641/// used by `chess-corners-core`.
642pub(crate) fn radon_refiner_to_kind(refiner: RadonRefiner) -> RefinerKind {
643    match refiner {
644        RadonRefiner::RadonPeak(cfg) => RefinerKind::RadonPeak(cfg),
645        RadonRefiner::CenterOfMass(cfg) => RefinerKind::CenterOfMass(cfg),
646    }
647}
648
649// ---------------------------------------------------------------------------
650// Threshold → core param translation
651// ---------------------------------------------------------------------------
652
653/// Detector params that carry a `(threshold_abs, threshold_rel)` pair.
654/// Lets [`apply_threshold`] translate a [`Threshold`] uniformly without
655/// duplicating the match arms per detector.
656trait HasThreshold {
657    fn set_threshold_abs(&mut self, value: Option<f32>);
658    fn set_threshold_rel(&mut self, value: f32);
659}
660
661impl HasThreshold for ChessParams {
662    #[inline]
663    fn set_threshold_abs(&mut self, value: Option<f32>) {
664        self.threshold_abs = value;
665    }
666    #[inline]
667    fn set_threshold_rel(&mut self, value: f32) {
668        self.threshold_rel = value;
669    }
670}
671
672impl HasThreshold for RadonDetectorParams {
673    #[inline]
674    fn set_threshold_abs(&mut self, value: Option<f32>) {
675        self.threshold_abs = value;
676    }
677    #[inline]
678    fn set_threshold_rel(&mut self, value: f32) {
679        self.threshold_rel = value;
680    }
681}
682
683/// Translate a [`Threshold`] into the `(threshold_abs, threshold_rel)`
684/// pair carried by [`ChessParams`] and [`RadonDetectorParams`].
685///
686/// `Absolute(v)` sets `threshold_abs = Some(v)` (overrides relative);
687/// `Relative(f)` sets `threshold_abs = None` and `threshold_rel = f`.
688fn apply_threshold<T: HasThreshold>(params: &mut T, threshold: Threshold) {
689    match threshold {
690        Threshold::Absolute(value) => {
691            params.set_threshold_abs(Some(value));
692        }
693        Threshold::Relative(frac) => {
694            params.set_threshold_abs(None);
695            params.set_threshold_rel(frac);
696        }
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    fn assert_strategy_chess(cfg: &DetectorConfig) -> &ChessConfig {
705        match &cfg.strategy {
706            DetectionStrategy::Chess(c) => c,
707            other => panic!("expected ChESS strategy, got {other:?}"),
708        }
709    }
710
711    fn assert_strategy_radon(cfg: &DetectorConfig) -> &RadonConfig {
712        match &cfg.strategy {
713            DetectionStrategy::Radon(r) => r,
714            other => panic!("expected Radon strategy, got {other:?}"),
715        }
716    }
717
718    #[test]
719    fn default_is_single_scale_chess_with_paper_threshold() {
720        let cfg = DetectorConfig::default();
721        let chess = assert_strategy_chess(&cfg);
722        assert_eq!(chess.ring, ChessRing::Canonical);
723        assert_eq!(chess.descriptor_ring, DescriptorRing::FollowDetector);
724        assert_eq!(chess.nms_radius, 2);
725        assert_eq!(chess.min_cluster_size, 2);
726        assert_eq!(
727            chess.refiner,
728            ChessRefiner::CenterOfMass(CenterOfMassConfig::default())
729        );
730        assert_eq!(cfg.multiscale, MultiscaleConfig::SingleScale);
731        assert_eq!(cfg.upscale, UpscaleConfig::Disabled);
732        assert_eq!(cfg.threshold, Threshold::Absolute(0.0));
733        assert_eq!(cfg.merge_radius, 3.0);
734        assert!(cfg.coarse_to_fine_params().is_none());
735
736        let params = cfg.chess_params();
737        assert!(!params.use_radius10);
738        assert_eq!(params.descriptor_use_radius10, None);
739        assert_eq!(params.threshold_abs, Some(0.0));
740        assert_eq!(params.nms_radius, 2);
741        assert_eq!(params.min_cluster_size, 2);
742        assert_eq!(
743            params.refiner,
744            RefinerKind::CenterOfMass(CenterOfMassConfig::default())
745        );
746    }
747
748    #[test]
749    fn relative_threshold_clears_absolute() {
750        let cfg = DetectorConfig {
751            threshold: Threshold::Relative(0.15),
752            ..DetectorConfig::chess()
753        };
754        let params = cfg.chess_params();
755        assert_eq!(params.threshold_abs, None);
756        assert!((params.threshold_rel - 0.15).abs() < f32::EPSILON);
757    }
758
759    #[test]
760    fn absolute_threshold_overrides_relative() {
761        let cfg = DetectorConfig {
762            threshold: Threshold::Absolute(7.5),
763            ..DetectorConfig::chess()
764        };
765        let params = cfg.chess_params();
766        assert_eq!(params.threshold_abs, Some(7.5));
767    }
768
769    #[test]
770    fn chess_multiscale_preset_carries_pyramid_params() {
771        let cfg = DetectorConfig::chess_multiscale();
772        let MultiscaleConfig::Pyramid {
773            levels,
774            min_size,
775            refinement_radius,
776        } = cfg.multiscale
777        else {
778            panic!("chess_multiscale preset must carry Pyramid params");
779        };
780        assert_eq!(levels, 3);
781        assert_eq!(min_size, 128);
782        assert_eq!(refinement_radius, 3);
783
784        let cf = cfg
785            .coarse_to_fine_params()
786            .expect("chess_multiscale config must produce CoarseToFineParams");
787        assert_eq!(cf.pyramid.num_levels, 3);
788        assert_eq!(cf.pyramid.min_size, 128);
789        assert_eq!(cf.refinement_radius, 3);
790        assert_eq!(cf.merge_radius, 3.0);
791    }
792
793    #[test]
794    fn radon_preset_uses_radon_config_and_relative_threshold() {
795        let cfg = DetectorConfig::radon();
796        let radon = assert_strategy_radon(&cfg);
797        assert_eq!(radon.ray_radius, 4);
798        assert_eq!(radon.image_upsample, 2);
799        assert_eq!(radon.response_blur_radius, 1);
800        assert_eq!(radon.peak_fit, PeakFitMode::Gaussian);
801        assert_eq!(radon.nms_radius, 4);
802        assert_eq!(radon.min_cluster_size, 2);
803        assert_eq!(
804            radon.refiner,
805            RadonRefiner::RadonPeak(RadonPeakConfig::default())
806        );
807        assert_eq!(cfg.threshold, Threshold::Relative(0.01));
808        assert_eq!(cfg.multiscale, MultiscaleConfig::SingleScale);
809        assert!(cfg.coarse_to_fine_params().is_none());
810
811        let radon_params = cfg.radon_detector_params();
812        assert_eq!(radon_params.ray_radius, 4);
813        assert_eq!(radon_params.image_upsample, 2);
814        assert_eq!(radon_params.threshold_abs, None);
815        assert!((radon_params.threshold_rel - 0.01).abs() < f32::EPSILON);
816        assert_eq!(
817            radon_params.refiner,
818            RefinerKind::RadonPeak(RadonPeakConfig::default())
819        );
820    }
821
822    #[test]
823    fn radon_multiscale_preset_carries_pyramid_params() {
824        let cfg = DetectorConfig::radon_multiscale();
825        assert_strategy_radon(&cfg);
826        assert_eq!(cfg.threshold, Threshold::Relative(0.01));
827        let MultiscaleConfig::Pyramid {
828            levels,
829            min_size,
830            refinement_radius,
831        } = cfg.multiscale
832        else {
833            panic!("radon_multiscale preset must carry Pyramid params");
834        };
835        assert_eq!(levels, 3);
836        assert_eq!(min_size, 128);
837        assert_eq!(refinement_radius, 3);
838
839        let cf = cfg
840            .coarse_to_fine_params()
841            .expect("radon_multiscale config must produce CoarseToFineParams");
842        assert_eq!(cf.pyramid.num_levels, 3);
843        assert_eq!(cf.pyramid.min_size, 128);
844        assert_eq!(cf.refinement_radius, 3);
845        assert_eq!(cf.merge_radius, 3.0);
846    }
847
848    #[test]
849    fn broad_ring_and_forstner_refiner_propagate_to_params() {
850        let cfg = DetectorConfig {
851            strategy: DetectionStrategy::Chess(ChessConfig {
852                ring: ChessRing::Broad,
853                descriptor_ring: DescriptorRing::Canonical,
854                refiner: ChessRefiner::Forstner(ForstnerConfig {
855                    max_offset: 2.0,
856                    ..ForstnerConfig::default()
857                }),
858                ..ChessConfig::default()
859            }),
860            ..DetectorConfig::chess()
861        };
862
863        let params = cfg.chess_params();
864        assert!(params.use_radius10);
865        assert_eq!(params.descriptor_use_radius10, Some(false));
866        assert_eq!(
867            params.refiner,
868            RefinerKind::Forstner(ForstnerConfig {
869                max_offset: 2.0,
870                ..ForstnerConfig::default()
871            })
872        );
873    }
874
875    #[test]
876    fn radon_center_of_mass_refiner_round_trips_to_params() {
877        let cfg = DetectorConfig {
878            strategy: DetectionStrategy::Radon(RadonConfig {
879                refiner: RadonRefiner::CenterOfMass(CenterOfMassConfig::default()),
880                ..RadonConfig::default()
881            }),
882            ..DetectorConfig::radon()
883        };
884        let params = cfg.radon_detector_params();
885        assert_eq!(
886            params.refiner,
887            RefinerKind::CenterOfMass(CenterOfMassConfig::default())
888        );
889    }
890
891    #[test]
892    fn chess_preset_round_trips_through_serde() {
893        let cfg = DetectorConfig::chess();
894        let json = serde_json::to_string(&cfg).expect("serialize chess config");
895        let decoded: DetectorConfig =
896            serde_json::from_str(&json).expect("deserialize chess config");
897        assert_eq!(decoded, cfg);
898    }
899
900    #[test]
901    fn chess_multiscale_preset_round_trips_through_serde() {
902        let cfg = DetectorConfig::chess_multiscale();
903        let json = serde_json::to_string(&cfg).expect("serialize chess_multiscale config");
904        let decoded: DetectorConfig =
905            serde_json::from_str(&json).expect("deserialize chess_multiscale config");
906        assert_eq!(decoded, cfg);
907    }
908
909    #[test]
910    fn radon_preset_round_trips_through_serde() {
911        let cfg = DetectorConfig::radon();
912        let json = serde_json::to_string(&cfg).expect("serialize radon config");
913        let decoded: DetectorConfig =
914            serde_json::from_str(&json).expect("deserialize radon config");
915        assert_eq!(decoded, cfg);
916    }
917
918    #[test]
919    fn radon_multiscale_preset_round_trips_through_serde() {
920        let cfg = DetectorConfig::radon_multiscale();
921        let json = serde_json::to_string(&cfg).expect("serialize radon_multiscale config");
922        let decoded: DetectorConfig =
923            serde_json::from_str(&json).expect("deserialize radon_multiscale config");
924        assert_eq!(decoded, cfg);
925    }
926
927    #[test]
928    fn threshold_round_trips_with_externally_tagged_payload() {
929        let abs = Threshold::Absolute(3.5);
930        let abs_json = serde_json::to_string(&abs).expect("serialize absolute threshold");
931        assert!(abs_json.contains("absolute"));
932        let abs_decoded: Threshold =
933            serde_json::from_str(&abs_json).expect("deserialize absolute threshold");
934        assert_eq!(abs_decoded, abs);
935
936        let rel = Threshold::Relative(0.42);
937        let rel_json = serde_json::to_string(&rel).expect("serialize relative threshold");
938        assert!(rel_json.contains("relative"));
939        let rel_decoded: Threshold =
940            serde_json::from_str(&rel_json).expect("deserialize relative threshold");
941        assert_eq!(rel_decoded, rel);
942    }
943
944    #[test]
945    fn multiscale_config_round_trips_with_externally_tagged_payload() {
946        let single = MultiscaleConfig::SingleScale;
947        let single_json = serde_json::to_string(&single).expect("serialize single-scale");
948        assert!(single_json.contains("single_scale"));
949        let decoded: MultiscaleConfig =
950            serde_json::from_str(&single_json).expect("deserialize single-scale");
951        assert_eq!(decoded, single);
952
953        let pyramid = MultiscaleConfig::Pyramid {
954            levels: 3,
955            min_size: 128,
956            refinement_radius: 3,
957        };
958        let pyramid_json = serde_json::to_string(&pyramid).expect("serialize pyramid");
959        assert!(pyramid_json.contains("pyramid"));
960        let decoded: MultiscaleConfig =
961            serde_json::from_str(&pyramid_json).expect("deserialize pyramid");
962        assert_eq!(decoded, pyramid);
963    }
964
965    #[test]
966    fn chess_refiner_round_trips_each_variant() {
967        let variants = [
968            ChessRefiner::CenterOfMass(CenterOfMassConfig::default()),
969            ChessRefiner::Forstner(ForstnerConfig::default()),
970            ChessRefiner::SaddlePoint(SaddlePointConfig::default()),
971        ];
972        for v in variants {
973            let json = serde_json::to_string(&v).expect("serialize chess refiner");
974            let decoded: ChessRefiner =
975                serde_json::from_str(&json).expect("deserialize chess refiner");
976            assert_eq!(decoded, v);
977        }
978    }
979
980    #[test]
981    fn radon_refiner_round_trips_each_variant() {
982        let variants = [
983            RadonRefiner::RadonPeak(RadonPeakConfig::default()),
984            RadonRefiner::CenterOfMass(CenterOfMassConfig::default()),
985        ];
986        for v in variants {
987            let json = serde_json::to_string(&v).expect("serialize radon refiner");
988            let decoded: RadonRefiner =
989                serde_json::from_str(&json).expect("deserialize radon refiner");
990            assert_eq!(decoded, v);
991        }
992    }
993
994    #[test]
995    fn unit_enum_variants_serialize_as_bare_strings() {
996        // Codifies the externally-tagged serde encoding for unit variants.
997        // The Python from_dict paths must accept these bare strings produced
998        // by serde so that Rust→JSON→Python round-trips work end-to-end.
999        let json = serde_json::to_string(&MultiscaleConfig::SingleScale).unwrap();
1000        assert_eq!(json, "\"single_scale\"");
1001
1002        let json = serde_json::to_string(&UpscaleConfig::Disabled).unwrap();
1003        assert_eq!(json, "\"disabled\"");
1004    }
1005
1006    #[test]
1007    fn with_chess_mutates_in_place_when_strategy_is_chess() {
1008        let cfg = DetectorConfig::chess().with_chess(|c| c.nms_radius = 7);
1009        let chess = assert_strategy_chess(&cfg);
1010        assert_eq!(chess.nms_radius, 7);
1011        // Other chess fields untouched
1012        assert_eq!(chess.min_cluster_size, 2);
1013    }
1014
1015    #[test]
1016    fn with_chess_replaces_radon_preserves_threshold() {
1017        let cfg = DetectorConfig::radon()
1018            .with_threshold(Threshold::Absolute(5.0))
1019            .with_chess(|c| c.nms_radius = 3);
1020        // Strategy replaced with chess
1021        let chess = assert_strategy_chess(&cfg);
1022        assert_eq!(chess.nms_radius, 3);
1023        // Top-level threshold preserved
1024        assert_eq!(cfg.threshold, Threshold::Absolute(5.0));
1025    }
1026
1027    #[test]
1028    fn with_radon_mutates_in_place_when_strategy_is_radon() {
1029        let cfg = DetectorConfig::radon().with_radon(|r| r.nms_radius = 9);
1030        let radon = assert_strategy_radon(&cfg);
1031        assert_eq!(radon.nms_radius, 9);
1032        // Other radon fields untouched
1033        assert_eq!(radon.min_cluster_size, 2);
1034    }
1035
1036    #[test]
1037    fn with_radon_replaces_chess_preserves_threshold() {
1038        let cfg = DetectorConfig::chess()
1039            .with_threshold(Threshold::Relative(0.5))
1040            .with_radon(|r| r.nms_radius = 6);
1041        let radon = assert_strategy_radon(&cfg);
1042        assert_eq!(radon.nms_radius, 6);
1043        // Threshold preserved
1044        assert_eq!(cfg.threshold, Threshold::Relative(0.5));
1045    }
1046
1047    #[test]
1048    fn chained_builder_produces_expected_state() {
1049        let cfg = DetectorConfig::chess()
1050            .with_threshold(Threshold::Relative(0.15))
1051            .with_chess(|c| c.refiner = ChessRefiner::forstner());
1052        assert_eq!(cfg.threshold, Threshold::Relative(0.15));
1053        let chess = assert_strategy_chess(&cfg);
1054        assert_eq!(
1055            chess.refiner,
1056            ChessRefiner::Forstner(ForstnerConfig::default())
1057        );
1058    }
1059
1060    #[test]
1061    fn with_multiscale_sets_multiscale() {
1062        let cfg = DetectorConfig::chess().with_multiscale(MultiscaleConfig::pyramid_default());
1063        assert_eq!(
1064            cfg.multiscale,
1065            MultiscaleConfig::Pyramid {
1066                levels: 3,
1067                min_size: 128,
1068                refinement_radius: 3
1069            }
1070        );
1071    }
1072
1073    #[test]
1074    fn with_upscale_sets_upscale() {
1075        let cfg = DetectorConfig::chess().with_upscale(UpscaleConfig::Fixed(2));
1076        assert_eq!(cfg.upscale, UpscaleConfig::Fixed(2));
1077    }
1078
1079    #[test]
1080    fn with_orientation_method_sets_method() {
1081        let method = OrientationMethod::DiskFit;
1082        let cfg = DetectorConfig::chess().with_orientation_method(method);
1083        assert_eq!(cfg.orientation_method, method);
1084    }
1085
1086    #[test]
1087    fn with_merge_radius_sets_radius() {
1088        let cfg = DetectorConfig::chess().with_merge_radius(5.0);
1089        assert!((cfg.merge_radius - 5.0).abs() < f32::EPSILON);
1090    }
1091
1092    #[test]
1093    fn chess_refiner_shortcuts_equal_full_constructors() {
1094        assert_eq!(
1095            ChessRefiner::center_of_mass(),
1096            ChessRefiner::CenterOfMass(CenterOfMassConfig::default())
1097        );
1098        assert_eq!(
1099            ChessRefiner::forstner(),
1100            ChessRefiner::Forstner(ForstnerConfig::default())
1101        );
1102        assert_eq!(
1103            ChessRefiner::saddle_point(),
1104            ChessRefiner::SaddlePoint(SaddlePointConfig::default())
1105        );
1106    }
1107
1108    #[test]
1109    fn radon_refiner_shortcuts_equal_full_constructors() {
1110        assert_eq!(
1111            RadonRefiner::radon_peak(),
1112            RadonRefiner::RadonPeak(RadonPeakConfig::default())
1113        );
1114        assert_eq!(
1115            RadonRefiner::center_of_mass(),
1116            RadonRefiner::CenterOfMass(CenterOfMassConfig::default())
1117        );
1118    }
1119
1120    #[test]
1121    fn multiscale_config_pyramid_default_equals_literal() {
1122        assert_eq!(
1123            MultiscaleConfig::pyramid_default(),
1124            MultiscaleConfig::Pyramid {
1125                levels: 3,
1126                min_size: 128,
1127                refinement_radius: 3
1128            }
1129        );
1130    }
1131
1132    #[cfg(feature = "ml-refiner")]
1133    #[test]
1134    fn chess_refiner_ml_serializes_as_bare_string() {
1135        let json = serde_json::to_string(&ChessRefiner::Ml).unwrap();
1136        assert_eq!(json, "\"ml\"");
1137        let decoded: ChessRefiner = serde_json::from_str(&json).expect("deserialize ml refiner");
1138        assert_eq!(decoded, ChessRefiner::Ml);
1139    }
1140}