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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum Threshold {
31 Absolute(f32),
33 Relative(f32),
36}
37
38impl Default for Threshold {
39 fn default() -> Self {
40 Threshold::Absolute(0.0)
43 }
44}
45
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54#[non_exhaustive]
55pub enum ChessRing {
56 #[default]
58 Canonical,
59 Broad,
62}
63
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum DescriptorRing {
70 #[default]
72 FollowDetector,
73 Canonical,
75 Broad,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90#[non_exhaustive]
91pub enum ChessRefiner {
92 CenterOfMass(CenterOfMassConfig),
95 Forstner(ForstnerConfig),
97 SaddlePoint(SaddlePointConfig),
99 #[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 pub fn center_of_mass() -> Self {
115 Self::CenterOfMass(CenterOfMassConfig::default())
116 }
117 pub fn forstner() -> Self {
119 Self::Forstner(ForstnerConfig::default())
120 }
121 pub fn saddle_point() -> Self {
123 Self::SaddlePoint(SaddlePointConfig::default())
124 }
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum RadonRefiner {
136 RadonPeak(RadonPeakConfig),
138 CenterOfMass(CenterOfMassConfig),
141}
142
143impl Default for RadonRefiner {
144 fn default() -> Self {
145 Self::RadonPeak(RadonPeakConfig::default())
146 }
147}
148
149impl RadonRefiner {
150 pub fn radon_peak() -> Self {
152 Self::RadonPeak(RadonPeakConfig::default())
153 }
154 pub fn center_of_mass() -> Self {
156 Self::CenterOfMass(CenterOfMassConfig::default())
157 }
158}
159
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175#[non_exhaustive]
176pub enum MultiscaleConfig {
177 #[default]
179 SingleScale,
180 Pyramid {
182 levels: u8,
185 min_size: usize,
188 refinement_radius: u32,
191 },
192}
193
194impl MultiscaleConfig {
195 pub const fn pyramid_default() -> Self {
199 Self::Pyramid {
200 levels: 3,
201 min_size: 128,
202 refinement_radius: 3,
203 }
204 }
205 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
226#[serde(default)]
227#[non_exhaustive]
228pub struct ChessConfig {
229 pub ring: ChessRing,
232 pub descriptor_ring: DescriptorRing,
235 pub nms_radius: u32,
237 pub min_cluster_size: u32,
240 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
263#[serde(default)]
264#[non_exhaustive]
265pub struct RadonConfig {
266 pub ray_radius: u32,
270 pub image_upsample: u32,
274 pub response_blur_radius: u32,
277 pub peak_fit: PeakFitMode,
279 pub nms_radius: u32,
281 pub min_cluster_size: u32,
284 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case")]
313#[non_exhaustive]
314pub enum DetectionStrategy {
315 Chess(ChessConfig),
317 Radon(RadonConfig),
319}
320
321impl Default for DetectionStrategy {
322 fn default() -> Self {
323 DetectionStrategy::Chess(ChessConfig::default())
324 }
325}
326
327#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
340#[serde(default)]
341#[non_exhaustive]
342pub struct DetectorConfig {
343 pub strategy: DetectionStrategy,
345 pub threshold: Threshold,
347 pub multiscale: MultiscaleConfig,
350 pub upscale: UpscaleConfig,
352 pub orientation_method: OrientationMethod,
354 pub merge_radius: f32,
357}
358
359impl Default for DetectorConfig {
360 fn default() -> Self {
361 Self::chess()
362 }
363}
364
365impl DetectorConfig {
366 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 pub fn chess_multiscale() -> Self {
380 Self {
381 multiscale: MultiscaleConfig::pyramid_default(),
382 ..Self::chess()
383 }
384 }
385
386 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 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 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 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 pub fn with_threshold(mut self, threshold: Threshold) -> Self {
441 self.threshold = threshold;
442 self
443 }
444 pub fn with_multiscale(mut self, multiscale: MultiscaleConfig) -> Self {
446 self.multiscale = multiscale;
447 self
448 }
449 pub fn with_upscale(mut self, upscale: UpscaleConfig) -> Self {
451 self.upscale = upscale;
452 self
453 }
454 pub fn with_orientation_method(mut self, method: OrientationMethod) -> Self {
456 self.orientation_method = method;
457 self
458 }
459 pub fn with_merge_radius(mut self, radius: f32) -> Self {
461 self.merge_radius = radius;
462 self
463 }
464
465 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 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 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
538pub(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
560pub(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
569trait 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
603fn 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 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 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 let chess = assert_strategy_chess(&cfg);
942 assert_eq!(chess.nms_radius, 3);
943 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 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 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}