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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
226#[serde(default)]
227#[non_exhaustive]
228pub struct ChessConfig {
229    /// Detector ring radius. `Canonical` selects the paper's `r=5`,
230    /// `Broad` selects `r=10`.
231    pub ring: ChessRing,
232    /// Descriptor sampling ring. Independent of the detector ring;
233    /// `FollowDetector` mirrors the detector's choice.
234    pub descriptor_ring: DescriptorRing,
235    /// Non-maximum-suppression half-radius, in input-image pixels.
236    pub nms_radius: u32,
237    /// Minimum count of positive-response neighbours in the NMS window
238    /// required to accept a peak.
239    pub min_cluster_size: u32,
240    /// Subpixel refiner. Each variant carries its tuning struct.
241    pub refiner: ChessRefiner,
242}
243
244impl Default for ChessConfig {
245    fn default() -> Self {
246        Self {
247            ring: ChessRing::Canonical,
248            descriptor_ring: DescriptorRing::FollowDetector,
249            nms_radius: 2,
250            min_cluster_size: 2,
251            refiner: ChessRefiner::default(),
252        }
253    }
254}
255
256/// Configuration for the whole-image Radon detector branch of
257/// [`DetectionStrategy`].
258///
259/// All radii and counts are in **working-resolution** pixels (i.e.
260/// after `image_upsample`). Multiscale and upscale live at the top
261/// level of [`DetectorConfig`] and apply to both strategies.
262#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
263#[serde(default)]
264#[non_exhaustive]
265pub struct RadonConfig {
266    /// Half-length of each ray (working-resolution pixels). The ray has
267    /// `2·ray_radius + 1` samples. Paper default at `image_upsample = 2`
268    /// is `ray_radius = 4`.
269    pub ray_radius: u32,
270    /// Image-level supersampling factor. `1` operates on the input grid;
271    /// `2` bilinearly upsamples first (paper default). Values ≥ 3 are
272    /// clamped to 2 by the core detector.
273    pub image_upsample: u32,
274    /// Half-size of the box blur applied to the response map. `0` disables
275    /// blurring; `1` yields a 3×3 box.
276    pub response_blur_radius: u32,
277    /// Peak-fit mode for the 3-point subpixel refinement.
278    pub peak_fit: PeakFitMode,
279    /// Non-maximum-suppression half-radius, in working-resolution pixels.
280    pub nms_radius: u32,
281    /// Minimum count of positive-response neighbours in the NMS window
282    /// required to accept a peak.
283    pub min_cluster_size: u32,
284    /// Subpixel refiner. Each variant carries its tuning struct.
285    pub refiner: RadonRefiner,
286}
287
288impl Default for RadonConfig {
289    fn default() -> Self {
290        Self {
291            ray_radius: 4,
292            image_upsample: 2,
293            response_blur_radius: 1,
294            peak_fit: PeakFitMode::Gaussian,
295            nms_radius: 4,
296            min_cluster_size: 2,
297            refiner: RadonRefiner::default(),
298        }
299    }
300}
301
302// ---------------------------------------------------------------------------
303// DetectionStrategy
304// ---------------------------------------------------------------------------
305
306/// Top-level detector dispatch. Selects between the ChESS kernel
307/// pipeline and the Radon whole-image detector. The chosen variant
308/// carries all detector-specific tuning; settings that don't apply to
309/// the active detector are simply unreachable, so the type system
310/// enforces correctness instead of silently ignoring fields.
311#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case")]
313#[non_exhaustive]
314pub enum DetectionStrategy {
315    /// ChESS kernel detection with optional coarse-to-fine multiscale.
316    Chess(ChessConfig),
317    /// Whole-image Radon (Duda-Frese) detection.
318    Radon(RadonConfig),
319}
320
321impl Default for DetectionStrategy {
322    fn default() -> Self {
323        DetectionStrategy::Chess(ChessConfig::default())
324    }
325}
326
327// ---------------------------------------------------------------------------
328// DetectorConfig
329// ---------------------------------------------------------------------------
330
331/// High-level detection configuration.
332///
333/// Build one with [`DetectorConfig::chess`],
334/// [`DetectorConfig::chess_multiscale`], [`DetectorConfig::radon`], or
335/// [`DetectorConfig::radon_multiscale`] and tweak only the fields you need.
336/// The detector translates this into the low-level [`ChessParams`] /
337/// [`RadonDetectorParams`] consumed by `chess-corners-core` at the detection
338/// boundary.
339#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
340#[serde(default)]
341#[non_exhaustive]
342pub struct DetectorConfig {
343    /// Detector dispatch: ChESS or Radon, each carrying its own tuning.
344    pub strategy: DetectionStrategy,
345    /// Acceptance threshold. Same enum is honoured by both detectors.
346    pub threshold: Threshold,
347    /// Coarse-to-fine multiscale configuration. `SingleScale` skips
348    /// the pyramid entirely. Honoured by both strategies.
349    pub multiscale: MultiscaleConfig,
350    /// Pre-pipeline integer upscaling. `Disabled` skips the stage.
351    pub upscale: UpscaleConfig,
352    /// Orientation-fit method used when building corner descriptors.
353    pub orientation_method: OrientationMethod,
354    /// Merge radius (base-image pixels) for cross-level / cross-seed
355    /// duplicate suppression. Honoured by both detectors.
356    pub merge_radius: f32,
357}
358
359impl Default for DetectorConfig {
360    fn default() -> Self {
361        Self::chess()
362    }
363}
364
365impl DetectorConfig {
366    /// Single-scale ChESS preset.
367    pub fn chess() -> Self {
368        Self {
369            strategy: DetectionStrategy::Chess(ChessConfig::default()),
370            threshold: Threshold::Absolute(0.0),
371            multiscale: MultiscaleConfig::SingleScale,
372            upscale: UpscaleConfig::Disabled,
373            orientation_method: OrientationMethod::default(),
374            merge_radius: 3.0,
375        }
376    }
377
378    /// Three-level coarse-to-fine ChESS preset.
379    pub fn chess_multiscale() -> Self {
380        Self {
381            multiscale: MultiscaleConfig::pyramid_default(),
382            ..Self::chess()
383        }
384    }
385
386    /// Whole-image Radon detector preset.
387    /// Single-scale; use [`Self::radon_multiscale`] for coarse-to-fine
388    /// Radon detection on larger frames.
389    pub fn radon() -> Self {
390        Self {
391            strategy: DetectionStrategy::Radon(RadonConfig::default()),
392            threshold: Threshold::Relative(0.01),
393            multiscale: MultiscaleConfig::SingleScale,
394            ..Self::chess()
395        }
396    }
397
398    /// Coarse-to-fine Radon preset. Measure against [`Self::radon`] on
399    /// your target frame sizes; this preset trades more configuration
400    /// machinery for less full-resolution detector work on large frames.
401    pub fn radon_multiscale() -> Self {
402        Self {
403            strategy: DetectionStrategy::Radon(RadonConfig::default()),
404            threshold: Threshold::Relative(0.01),
405            multiscale: MultiscaleConfig::pyramid_default(),
406            ..Self::chess()
407        }
408    }
409
410    /// Set the active strategy to ChESS and apply `f` to the nested config.
411    /// If the current strategy is already ChESS, mutate it in place.
412    /// Otherwise, replace the strategy with [`ChessConfig::default`] and apply `f`.
413    ///
414    /// Top-level fields (threshold, multiscale, upscale, orientation_method,
415    /// merge_radius) are untouched. When switching strategies, prefer the
416    /// preset constructors — Radon uses `Relative(0.01)` thresholds while
417    /// ChESS uses `Absolute(0.0)`.
418    pub fn with_chess<F: FnOnce(&mut ChessConfig)>(mut self, f: F) -> Self {
419        let mut chess = match self.strategy {
420            DetectionStrategy::Chess(c) => c,
421            DetectionStrategy::Radon(_) => ChessConfig::default(),
422        };
423        f(&mut chess);
424        self.strategy = DetectionStrategy::Chess(chess);
425        self
426    }
427
428    /// Mirror of [`Self::with_chess`] for the Radon strategy.
429    pub fn with_radon<F: FnOnce(&mut RadonConfig)>(mut self, f: F) -> Self {
430        let mut radon = match self.strategy {
431            DetectionStrategy::Radon(r) => r,
432            DetectionStrategy::Chess(_) => RadonConfig::default(),
433        };
434        f(&mut radon);
435        self.strategy = DetectionStrategy::Radon(radon);
436        self
437    }
438
439    /// Replace the acceptance threshold.
440    pub fn with_threshold(mut self, threshold: Threshold) -> Self {
441        self.threshold = threshold;
442        self
443    }
444    /// Replace the multiscale configuration.
445    pub fn with_multiscale(mut self, multiscale: MultiscaleConfig) -> Self {
446        self.multiscale = multiscale;
447        self
448    }
449    /// Replace the upscale configuration.
450    pub fn with_upscale(mut self, upscale: UpscaleConfig) -> Self {
451        self.upscale = upscale;
452        self
453    }
454    /// Replace the orientation-fit method used when building descriptors.
455    pub fn with_orientation_method(mut self, method: OrientationMethod) -> Self {
456        self.orientation_method = method;
457        self
458    }
459    /// Replace the merge radius for cross-level duplicate suppression.
460    pub fn with_merge_radius(mut self, radius: f32) -> Self {
461        self.merge_radius = radius;
462        self
463    }
464
465    /// Translate this config into the low-level [`ChessParams`] consumed
466    /// by `chess-corners-core`. Only meaningful when
467    /// [`Self::strategy`] is the ChESS variant.
468    ///
469    /// When the active strategy is [`DetectionStrategy::Radon`], the
470    /// ChESS-specific fields fall back to their [`ChessParams::default()`]
471    /// values; callers should route through
472    /// [`Self::to_radon_detector_params`] instead.
473    pub fn to_chess_params(&self) -> ChessParams {
474        let mut params = ChessParams::default();
475        if let DetectionStrategy::Chess(chess) = &self.strategy {
476            params.use_radius10 = matches!(chess.ring, ChessRing::Broad);
477            params.nms_radius = chess.nms_radius;
478            params.min_cluster_size = chess.min_cluster_size;
479            params.descriptor_use_radius10 = match chess.descriptor_ring {
480                DescriptorRing::FollowDetector => None,
481                DescriptorRing::Canonical => Some(false),
482                DescriptorRing::Broad => Some(true),
483            };
484            params.refiner = chess_refiner_to_kind(chess.refiner);
485        }
486        apply_threshold(&mut params, self.threshold);
487        params.orientation_method = self.orientation_method;
488        params
489    }
490
491    /// Translate this config into the low-level [`RadonDetectorParams`]
492    /// consumed by `chess-corners-core`. Only meaningful when
493    /// [`Self::strategy`] is the Radon variant.
494    ///
495    /// When the active strategy is [`DetectionStrategy::Chess`], the
496    /// Radon-specific fields fall back to their
497    /// [`RadonDetectorParams::default()`] values; callers should route
498    /// through [`Self::to_chess_params`] instead.
499    pub fn to_radon_detector_params(&self) -> RadonDetectorParams {
500        let mut params = RadonDetectorParams::default();
501        if let DetectionStrategy::Radon(radon) = &self.strategy {
502            params.ray_radius = radon.ray_radius;
503            params.image_upsample = radon.image_upsample;
504            params.response_blur_radius = radon.response_blur_radius;
505            params.peak_fit = radon.peak_fit;
506            params.nms_radius = radon.nms_radius;
507            params.min_cluster_size = radon.min_cluster_size;
508            params.refiner = radon_refiner_to_kind(radon.refiner);
509        }
510        apply_threshold(&mut params, self.threshold);
511        params
512    }
513
514    /// Translate this config into the [`CoarseToFineParams`] that drive
515    /// the multiscale pipeline. Returns `None` when [`Self::multiscale`]
516    /// is [`MultiscaleConfig::SingleScale`]. Both ChESS and Radon honour
517    /// the same top-level multiscale settings.
518    pub fn to_coarse_to_fine_params(&self) -> Option<CoarseToFineParams> {
519        let MultiscaleConfig::Pyramid {
520            levels,
521            min_size,
522            refinement_radius,
523        } = self.multiscale
524        else {
525            return None;
526        };
527        let mut cfg = CoarseToFineParams::default();
528        let mut pyramid = PyramidParams::default();
529        pyramid.num_levels = levels;
530        pyramid.min_size = min_size;
531        cfg.pyramid = pyramid;
532        cfg.refinement_radius = refinement_radius;
533        cfg.merge_radius = self.merge_radius;
534        Some(cfg)
535    }
536}
537
538// ---------------------------------------------------------------------------
539// Refiner-enum → core RefinerKind translation
540// ---------------------------------------------------------------------------
541
542/// Translate a [`ChessRefiner`] into the lower-level [`RefinerKind`] used
543/// by `chess-corners-core`.
544///
545/// The [`ChessRefiner::Ml`] variant (gated on the `ml-refiner` feature)
546/// does not map to a core [`RefinerKind`] variant — the ML refiner
547/// lives in the facade, not the core crate. The translation falls back
548/// to center-of-mass with default tuning so the coarse pass and any
549/// inference-time fallback both stay well-defined.
550pub(crate) fn chess_refiner_to_kind(refiner: ChessRefiner) -> RefinerKind {
551    match refiner {
552        ChessRefiner::CenterOfMass(cfg) => RefinerKind::CenterOfMass(cfg),
553        ChessRefiner::Forstner(cfg) => RefinerKind::Forstner(cfg),
554        ChessRefiner::SaddlePoint(cfg) => RefinerKind::SaddlePoint(cfg),
555        #[cfg(feature = "ml-refiner")]
556        ChessRefiner::Ml => RefinerKind::CenterOfMass(CenterOfMassConfig::default()),
557    }
558}
559
560/// Translate a [`RadonRefiner`] into the lower-level [`RefinerKind`]
561/// used by `chess-corners-core`.
562pub(crate) fn radon_refiner_to_kind(refiner: RadonRefiner) -> RefinerKind {
563    match refiner {
564        RadonRefiner::RadonPeak(cfg) => RefinerKind::RadonPeak(cfg),
565        RadonRefiner::CenterOfMass(cfg) => RefinerKind::CenterOfMass(cfg),
566    }
567}
568
569// ---------------------------------------------------------------------------
570// Threshold → core param translation
571// ---------------------------------------------------------------------------
572
573/// Detector params that carry a `(threshold_abs, threshold_rel)` pair.
574/// Lets [`apply_threshold`] translate a [`Threshold`] uniformly without
575/// duplicating the match arms per detector.
576trait HasThreshold {
577    fn set_threshold_abs(&mut self, value: Option<f32>);
578    fn set_threshold_rel(&mut self, value: f32);
579}
580
581impl HasThreshold for ChessParams {
582    #[inline]
583    fn set_threshold_abs(&mut self, value: Option<f32>) {
584        self.threshold_abs = value;
585    }
586    #[inline]
587    fn set_threshold_rel(&mut self, value: f32) {
588        self.threshold_rel = value;
589    }
590}
591
592impl HasThreshold for RadonDetectorParams {
593    #[inline]
594    fn set_threshold_abs(&mut self, value: Option<f32>) {
595        self.threshold_abs = value;
596    }
597    #[inline]
598    fn set_threshold_rel(&mut self, value: f32) {
599        self.threshold_rel = value;
600    }
601}
602
603/// Translate a [`Threshold`] into the `(threshold_abs, threshold_rel)`
604/// pair carried by [`ChessParams`] and [`RadonDetectorParams`].
605///
606/// `Absolute(v)` sets `threshold_abs = Some(v)` (overrides relative);
607/// `Relative(f)` sets `threshold_abs = None` and `threshold_rel = f`.
608fn apply_threshold<T: HasThreshold>(params: &mut T, threshold: Threshold) {
609    match threshold {
610        Threshold::Absolute(value) => {
611            params.set_threshold_abs(Some(value));
612        }
613        Threshold::Relative(frac) => {
614            params.set_threshold_abs(None);
615            params.set_threshold_rel(frac);
616        }
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    fn assert_strategy_chess(cfg: &DetectorConfig) -> &ChessConfig {
625        match &cfg.strategy {
626            DetectionStrategy::Chess(c) => c,
627            other => panic!("expected ChESS strategy, got {other:?}"),
628        }
629    }
630
631    fn assert_strategy_radon(cfg: &DetectorConfig) -> &RadonConfig {
632        match &cfg.strategy {
633            DetectionStrategy::Radon(r) => r,
634            other => panic!("expected Radon strategy, got {other:?}"),
635        }
636    }
637
638    #[test]
639    fn default_is_single_scale_chess_with_paper_threshold() {
640        let cfg = DetectorConfig::default();
641        let chess = assert_strategy_chess(&cfg);
642        assert_eq!(chess.ring, ChessRing::Canonical);
643        assert_eq!(chess.descriptor_ring, DescriptorRing::FollowDetector);
644        assert_eq!(chess.nms_radius, 2);
645        assert_eq!(chess.min_cluster_size, 2);
646        assert_eq!(
647            chess.refiner,
648            ChessRefiner::CenterOfMass(CenterOfMassConfig::default())
649        );
650        assert_eq!(cfg.multiscale, MultiscaleConfig::SingleScale);
651        assert_eq!(cfg.upscale, UpscaleConfig::Disabled);
652        assert_eq!(cfg.threshold, Threshold::Absolute(0.0));
653        assert_eq!(cfg.merge_radius, 3.0);
654        assert!(cfg.to_coarse_to_fine_params().is_none());
655
656        let params = cfg.to_chess_params();
657        assert!(!params.use_radius10);
658        assert_eq!(params.descriptor_use_radius10, None);
659        assert_eq!(params.threshold_abs, Some(0.0));
660        assert_eq!(params.nms_radius, 2);
661        assert_eq!(params.min_cluster_size, 2);
662        assert_eq!(
663            params.refiner,
664            RefinerKind::CenterOfMass(CenterOfMassConfig::default())
665        );
666    }
667
668    #[test]
669    fn relative_threshold_clears_absolute() {
670        let cfg = DetectorConfig {
671            threshold: Threshold::Relative(0.15),
672            ..DetectorConfig::chess()
673        };
674        let params = cfg.to_chess_params();
675        assert_eq!(params.threshold_abs, None);
676        assert!((params.threshold_rel - 0.15).abs() < f32::EPSILON);
677    }
678
679    #[test]
680    fn absolute_threshold_overrides_relative() {
681        let cfg = DetectorConfig {
682            threshold: Threshold::Absolute(7.5),
683            ..DetectorConfig::chess()
684        };
685        let params = cfg.to_chess_params();
686        assert_eq!(params.threshold_abs, Some(7.5));
687    }
688
689    #[test]
690    fn chess_multiscale_preset_carries_pyramid_params() {
691        let cfg = DetectorConfig::chess_multiscale();
692        let MultiscaleConfig::Pyramid {
693            levels,
694            min_size,
695            refinement_radius,
696        } = cfg.multiscale
697        else {
698            panic!("chess_multiscale preset must carry Pyramid params");
699        };
700        assert_eq!(levels, 3);
701        assert_eq!(min_size, 128);
702        assert_eq!(refinement_radius, 3);
703
704        let cf = cfg
705            .to_coarse_to_fine_params()
706            .expect("chess_multiscale config must produce CoarseToFineParams");
707        assert_eq!(cf.pyramid.num_levels, 3);
708        assert_eq!(cf.pyramid.min_size, 128);
709        assert_eq!(cf.refinement_radius, 3);
710        assert_eq!(cf.merge_radius, 3.0);
711    }
712
713    #[test]
714    fn radon_preset_uses_radon_config_and_relative_threshold() {
715        let cfg = DetectorConfig::radon();
716        let radon = assert_strategy_radon(&cfg);
717        assert_eq!(radon.ray_radius, 4);
718        assert_eq!(radon.image_upsample, 2);
719        assert_eq!(radon.response_blur_radius, 1);
720        assert_eq!(radon.peak_fit, PeakFitMode::Gaussian);
721        assert_eq!(radon.nms_radius, 4);
722        assert_eq!(radon.min_cluster_size, 2);
723        assert_eq!(
724            radon.refiner,
725            RadonRefiner::RadonPeak(RadonPeakConfig::default())
726        );
727        assert_eq!(cfg.threshold, Threshold::Relative(0.01));
728        assert_eq!(cfg.multiscale, MultiscaleConfig::SingleScale);
729        assert!(cfg.to_coarse_to_fine_params().is_none());
730
731        let radon_params = cfg.to_radon_detector_params();
732        assert_eq!(radon_params.ray_radius, 4);
733        assert_eq!(radon_params.image_upsample, 2);
734        assert_eq!(radon_params.threshold_abs, None);
735        assert!((radon_params.threshold_rel - 0.01).abs() < f32::EPSILON);
736        assert_eq!(
737            radon_params.refiner,
738            RefinerKind::RadonPeak(RadonPeakConfig::default())
739        );
740    }
741
742    #[test]
743    fn radon_multiscale_preset_carries_pyramid_params() {
744        let cfg = DetectorConfig::radon_multiscale();
745        assert_strategy_radon(&cfg);
746        assert_eq!(cfg.threshold, Threshold::Relative(0.01));
747        let MultiscaleConfig::Pyramid {
748            levels,
749            min_size,
750            refinement_radius,
751        } = cfg.multiscale
752        else {
753            panic!("radon_multiscale preset must carry Pyramid params");
754        };
755        assert_eq!(levels, 3);
756        assert_eq!(min_size, 128);
757        assert_eq!(refinement_radius, 3);
758
759        let cf = cfg
760            .to_coarse_to_fine_params()
761            .expect("radon_multiscale config must produce CoarseToFineParams");
762        assert_eq!(cf.pyramid.num_levels, 3);
763        assert_eq!(cf.pyramid.min_size, 128);
764        assert_eq!(cf.refinement_radius, 3);
765        assert_eq!(cf.merge_radius, 3.0);
766    }
767
768    #[test]
769    fn broad_ring_and_forstner_refiner_propagate_to_params() {
770        let cfg = DetectorConfig {
771            strategy: DetectionStrategy::Chess(ChessConfig {
772                ring: ChessRing::Broad,
773                descriptor_ring: DescriptorRing::Canonical,
774                refiner: ChessRefiner::Forstner(ForstnerConfig {
775                    max_offset: 2.0,
776                    ..ForstnerConfig::default()
777                }),
778                ..ChessConfig::default()
779            }),
780            ..DetectorConfig::chess()
781        };
782
783        let params = cfg.to_chess_params();
784        assert!(params.use_radius10);
785        assert_eq!(params.descriptor_use_radius10, Some(false));
786        assert_eq!(
787            params.refiner,
788            RefinerKind::Forstner(ForstnerConfig {
789                max_offset: 2.0,
790                ..ForstnerConfig::default()
791            })
792        );
793    }
794
795    #[test]
796    fn radon_center_of_mass_refiner_round_trips_to_params() {
797        let cfg = DetectorConfig {
798            strategy: DetectionStrategy::Radon(RadonConfig {
799                refiner: RadonRefiner::CenterOfMass(CenterOfMassConfig::default()),
800                ..RadonConfig::default()
801            }),
802            ..DetectorConfig::radon()
803        };
804        let params = cfg.to_radon_detector_params();
805        assert_eq!(
806            params.refiner,
807            RefinerKind::CenterOfMass(CenterOfMassConfig::default())
808        );
809    }
810
811    #[test]
812    fn chess_preset_round_trips_through_serde() {
813        let cfg = DetectorConfig::chess();
814        let json = serde_json::to_string(&cfg).expect("serialize chess config");
815        let decoded: DetectorConfig =
816            serde_json::from_str(&json).expect("deserialize chess config");
817        assert_eq!(decoded, cfg);
818    }
819
820    #[test]
821    fn chess_multiscale_preset_round_trips_through_serde() {
822        let cfg = DetectorConfig::chess_multiscale();
823        let json = serde_json::to_string(&cfg).expect("serialize chess_multiscale config");
824        let decoded: DetectorConfig =
825            serde_json::from_str(&json).expect("deserialize chess_multiscale config");
826        assert_eq!(decoded, cfg);
827    }
828
829    #[test]
830    fn radon_preset_round_trips_through_serde() {
831        let cfg = DetectorConfig::radon();
832        let json = serde_json::to_string(&cfg).expect("serialize radon config");
833        let decoded: DetectorConfig =
834            serde_json::from_str(&json).expect("deserialize radon config");
835        assert_eq!(decoded, cfg);
836    }
837
838    #[test]
839    fn radon_multiscale_preset_round_trips_through_serde() {
840        let cfg = DetectorConfig::radon_multiscale();
841        let json = serde_json::to_string(&cfg).expect("serialize radon_multiscale config");
842        let decoded: DetectorConfig =
843            serde_json::from_str(&json).expect("deserialize radon_multiscale config");
844        assert_eq!(decoded, cfg);
845    }
846
847    #[test]
848    fn threshold_round_trips_with_externally_tagged_payload() {
849        let abs = Threshold::Absolute(3.5);
850        let abs_json = serde_json::to_string(&abs).expect("serialize absolute threshold");
851        assert!(abs_json.contains("absolute"));
852        let abs_decoded: Threshold =
853            serde_json::from_str(&abs_json).expect("deserialize absolute threshold");
854        assert_eq!(abs_decoded, abs);
855
856        let rel = Threshold::Relative(0.42);
857        let rel_json = serde_json::to_string(&rel).expect("serialize relative threshold");
858        assert!(rel_json.contains("relative"));
859        let rel_decoded: Threshold =
860            serde_json::from_str(&rel_json).expect("deserialize relative threshold");
861        assert_eq!(rel_decoded, rel);
862    }
863
864    #[test]
865    fn multiscale_config_round_trips_with_externally_tagged_payload() {
866        let single = MultiscaleConfig::SingleScale;
867        let single_json = serde_json::to_string(&single).expect("serialize single-scale");
868        assert!(single_json.contains("single_scale"));
869        let decoded: MultiscaleConfig =
870            serde_json::from_str(&single_json).expect("deserialize single-scale");
871        assert_eq!(decoded, single);
872
873        let pyramid = MultiscaleConfig::Pyramid {
874            levels: 3,
875            min_size: 128,
876            refinement_radius: 3,
877        };
878        let pyramid_json = serde_json::to_string(&pyramid).expect("serialize pyramid");
879        assert!(pyramid_json.contains("pyramid"));
880        let decoded: MultiscaleConfig =
881            serde_json::from_str(&pyramid_json).expect("deserialize pyramid");
882        assert_eq!(decoded, pyramid);
883    }
884
885    #[test]
886    fn chess_refiner_round_trips_each_variant() {
887        let variants = [
888            ChessRefiner::CenterOfMass(CenterOfMassConfig::default()),
889            ChessRefiner::Forstner(ForstnerConfig::default()),
890            ChessRefiner::SaddlePoint(SaddlePointConfig::default()),
891        ];
892        for v in variants {
893            let json = serde_json::to_string(&v).expect("serialize chess refiner");
894            let decoded: ChessRefiner =
895                serde_json::from_str(&json).expect("deserialize chess refiner");
896            assert_eq!(decoded, v);
897        }
898    }
899
900    #[test]
901    fn radon_refiner_round_trips_each_variant() {
902        let variants = [
903            RadonRefiner::RadonPeak(RadonPeakConfig::default()),
904            RadonRefiner::CenterOfMass(CenterOfMassConfig::default()),
905        ];
906        for v in variants {
907            let json = serde_json::to_string(&v).expect("serialize radon refiner");
908            let decoded: RadonRefiner =
909                serde_json::from_str(&json).expect("deserialize radon refiner");
910            assert_eq!(decoded, v);
911        }
912    }
913
914    #[test]
915    fn unit_enum_variants_serialize_as_bare_strings() {
916        // Codifies the externally-tagged serde encoding for unit variants.
917        // The Python from_dict paths must accept these bare strings produced
918        // by serde so that Rust→JSON→Python round-trips work end-to-end.
919        let json = serde_json::to_string(&MultiscaleConfig::SingleScale).unwrap();
920        assert_eq!(json, "\"single_scale\"");
921
922        let json = serde_json::to_string(&UpscaleConfig::Disabled).unwrap();
923        assert_eq!(json, "\"disabled\"");
924    }
925
926    #[test]
927    fn with_chess_mutates_in_place_when_strategy_is_chess() {
928        let cfg = DetectorConfig::chess().with_chess(|c| c.nms_radius = 7);
929        let chess = assert_strategy_chess(&cfg);
930        assert_eq!(chess.nms_radius, 7);
931        // Other chess fields untouched
932        assert_eq!(chess.min_cluster_size, 2);
933    }
934
935    #[test]
936    fn with_chess_replaces_radon_preserves_threshold() {
937        let cfg = DetectorConfig::radon()
938            .with_threshold(Threshold::Absolute(5.0))
939            .with_chess(|c| c.nms_radius = 3);
940        // Strategy replaced with chess
941        let chess = assert_strategy_chess(&cfg);
942        assert_eq!(chess.nms_radius, 3);
943        // Top-level threshold preserved
944        assert_eq!(cfg.threshold, Threshold::Absolute(5.0));
945    }
946
947    #[test]
948    fn with_radon_mutates_in_place_when_strategy_is_radon() {
949        let cfg = DetectorConfig::radon().with_radon(|r| r.nms_radius = 9);
950        let radon = assert_strategy_radon(&cfg);
951        assert_eq!(radon.nms_radius, 9);
952        // Other radon fields untouched
953        assert_eq!(radon.min_cluster_size, 2);
954    }
955
956    #[test]
957    fn with_radon_replaces_chess_preserves_threshold() {
958        let cfg = DetectorConfig::chess()
959            .with_threshold(Threshold::Relative(0.5))
960            .with_radon(|r| r.nms_radius = 6);
961        let radon = assert_strategy_radon(&cfg);
962        assert_eq!(radon.nms_radius, 6);
963        // Threshold preserved
964        assert_eq!(cfg.threshold, Threshold::Relative(0.5));
965    }
966
967    #[test]
968    fn chained_builder_produces_expected_state() {
969        let cfg = DetectorConfig::chess()
970            .with_threshold(Threshold::Relative(0.15))
971            .with_chess(|c| c.refiner = ChessRefiner::forstner());
972        assert_eq!(cfg.threshold, Threshold::Relative(0.15));
973        let chess = assert_strategy_chess(&cfg);
974        assert_eq!(
975            chess.refiner,
976            ChessRefiner::Forstner(ForstnerConfig::default())
977        );
978    }
979
980    #[test]
981    fn with_multiscale_sets_multiscale() {
982        let cfg = DetectorConfig::chess().with_multiscale(MultiscaleConfig::pyramid_default());
983        assert_eq!(
984            cfg.multiscale,
985            MultiscaleConfig::Pyramid {
986                levels: 3,
987                min_size: 128,
988                refinement_radius: 3
989            }
990        );
991    }
992
993    #[test]
994    fn with_upscale_sets_upscale() {
995        let cfg = DetectorConfig::chess().with_upscale(UpscaleConfig::Fixed(2));
996        assert_eq!(cfg.upscale, UpscaleConfig::Fixed(2));
997    }
998
999    #[test]
1000    fn with_orientation_method_sets_method() {
1001        let method = OrientationMethod::DiskFit;
1002        let cfg = DetectorConfig::chess().with_orientation_method(method);
1003        assert_eq!(cfg.orientation_method, method);
1004    }
1005
1006    #[test]
1007    fn with_merge_radius_sets_radius() {
1008        let cfg = DetectorConfig::chess().with_merge_radius(5.0);
1009        assert!((cfg.merge_radius - 5.0).abs() < f32::EPSILON);
1010    }
1011
1012    #[test]
1013    fn chess_refiner_shortcuts_equal_full_constructors() {
1014        assert_eq!(
1015            ChessRefiner::center_of_mass(),
1016            ChessRefiner::CenterOfMass(CenterOfMassConfig::default())
1017        );
1018        assert_eq!(
1019            ChessRefiner::forstner(),
1020            ChessRefiner::Forstner(ForstnerConfig::default())
1021        );
1022        assert_eq!(
1023            ChessRefiner::saddle_point(),
1024            ChessRefiner::SaddlePoint(SaddlePointConfig::default())
1025        );
1026    }
1027
1028    #[test]
1029    fn radon_refiner_shortcuts_equal_full_constructors() {
1030        assert_eq!(
1031            RadonRefiner::radon_peak(),
1032            RadonRefiner::RadonPeak(RadonPeakConfig::default())
1033        );
1034        assert_eq!(
1035            RadonRefiner::center_of_mass(),
1036            RadonRefiner::CenterOfMass(CenterOfMassConfig::default())
1037        );
1038    }
1039
1040    #[test]
1041    fn multiscale_config_pyramid_default_equals_literal() {
1042        assert_eq!(
1043            MultiscaleConfig::pyramid_default(),
1044            MultiscaleConfig::Pyramid {
1045                levels: 3,
1046                min_size: 128,
1047                refinement_radius: 3
1048            }
1049        );
1050    }
1051
1052    #[cfg(feature = "ml-refiner")]
1053    #[test]
1054    fn chess_refiner_ml_serializes_as_bare_string() {
1055        let json = serde_json::to_string(&ChessRefiner::Ml).unwrap();
1056        assert_eq!(json, "\"ml\"");
1057        let decoded: ChessRefiner = serde_json::from_str(&json).expect("deserialize ml refiner");
1058        assert_eq!(decoded, ChessRefiner::Ml);
1059    }
1060}