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)]
242#[serde(default)]
243#[non_exhaustive]
244pub struct ChessConfig {
245 pub ring: ChessRing,
248 pub descriptor_ring: DescriptorRing,
251 pub nms_radius: u32,
258 pub min_cluster_size: u32,
264 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
302#[serde(default)]
303#[non_exhaustive]
304pub struct RadonConfig {
305 pub ray_radius: u32,
311 pub image_upsample: u32,
316 pub response_blur_radius: u32,
322 pub peak_fit: PeakFitMode,
327 pub nms_radius: u32,
332 pub min_cluster_size: u32,
336 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365#[non_exhaustive]
366pub enum DetectionStrategy {
367 Chess(ChessConfig),
369 Radon(RadonConfig),
371}
372
373impl Default for DetectionStrategy {
374 fn default() -> Self {
375 DetectionStrategy::Chess(ChessConfig::default())
376 }
377}
378
379#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
414#[serde(default)]
415#[non_exhaustive]
416pub struct DetectorConfig {
417 pub strategy: DetectionStrategy,
419 pub threshold: Threshold,
421 pub multiscale: MultiscaleConfig,
424 pub upscale: UpscaleConfig,
426 pub orientation_method: OrientationMethod,
428 pub merge_radius: f32,
437}
438
439impl Default for DetectorConfig {
440 fn default() -> Self {
441 Self::chess()
442 }
443}
444
445impl DetectorConfig {
446 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 pub fn chess_multiscale() -> Self {
460 Self {
461 multiscale: MultiscaleConfig::pyramid_default(),
462 ..Self::chess()
463 }
464 }
465
466 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 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 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 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 pub fn with_threshold(mut self, threshold: Threshold) -> Self {
521 self.threshold = threshold;
522 self
523 }
524 pub fn with_multiscale(mut self, multiscale: MultiscaleConfig) -> Self {
526 self.multiscale = multiscale;
527 self
528 }
529 pub fn with_upscale(mut self, upscale: UpscaleConfig) -> Self {
531 self.upscale = upscale;
532 self
533 }
534 pub fn with_orientation_method(mut self, method: OrientationMethod) -> Self {
536 self.orientation_method = method;
537 self
538 }
539 pub fn with_merge_radius(mut self, radius: f32) -> Self {
541 self.merge_radius = radius;
542 self
543 }
544
545 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 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 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
618pub(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
640pub(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
649trait 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
683fn 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 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 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 let chess = assert_strategy_chess(&cfg);
1022 assert_eq!(chess.nms_radius, 3);
1023 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 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 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}