1use std::collections::{HashMap, HashSet};
34
35use nalgebra::Point2;
36
37use crate::cluster::angular_dist_pi;
38use crate::detect::DetectionParams;
39use crate::error::{GridError, Result};
40use crate::feature::OrientedFeature;
41use crate::lattice::{Coord, GridDimensions, LatticeKind};
42use crate::result::{
43 GridEntry, GridSolution, LabelledGrid, LatticeFit, RejectedFeature, RejectionReason,
44};
45use crate::shared::merge::{merge_components_local, LocalMergeParams};
46use crate::shared::recovery_schedule::SquareAxisProvenance;
47use crate::shared::validate as pg_validate;
48
49use super::axis::{build_axis_caches, AxisCache};
50use super::{classify, delaunay, filter, quads, walk};
51use crate::shared::{fit_component, FitComponentResult};
52
53pub(super) const MIN_USABLE_FOR_DELAUNAY: usize = 3;
55
56#[derive(Debug, Default)]
58pub(super) struct SquarePipelineTrace {
59 pub(super) usable: Vec<bool>,
60 pub(super) triangles: Vec<[usize; 3]>,
61 pub(super) edges: Vec<(usize, usize, classify::EdgeClass)>,
62 pub(super) raw_quads: Vec<[usize; 4]>,
63 pub(super) topology_quads: Vec<[usize; 4]>,
64 pub(super) geometry_quads: Vec<[usize; 4]>,
65 pub(super) scale_quads: Vec<[usize; 4]>,
66 pub(super) walk_components: Vec<Vec<(Coord, usize)>>,
67 pub(super) merged_components: Vec<Vec<(Coord, usize)>>,
68}
69
70type LabelledComponent = HashMap<Coord, usize>;
71type LabelledComponents = Vec<LabelledComponent>;
72
73struct SquareTopology {
74 positions: Vec<Point2<f32>>,
75 components: LabelledComponents,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
85#[non_exhaustive]
86pub struct TopologicalParams {
87 pub axis_align_tol_rad: f32,
91 pub max_axis_sigma_rad: f32,
97 pub opposing_edge_ratio_max: f32,
100 pub edge_length_min_rel: f32,
106 pub edge_length_max_rel: f32,
113 pub min_corners_for_component: usize,
116 pub min_quads_per_component: usize,
119 pub axis_cluster_centers: Option<[f32; 2]>,
125 pub cluster_axis_tol_rad: f32,
129}
130
131impl Default for TopologicalParams {
132 fn default() -> Self {
133 Self {
134 axis_align_tol_rad: 15.0_f32.to_radians(),
135 max_axis_sigma_rad: 0.6,
136 opposing_edge_ratio_max: 1.5,
137 edge_length_min_rel: 0.4,
138 edge_length_max_rel: 2.5,
139 min_corners_for_component: 4,
140 min_quads_per_component: 1,
141 axis_cluster_centers: None,
142 cluster_axis_tol_rad: 16.0_f32.to_radians(),
143 }
144 }
145}
146
147impl TopologicalParams {
148 pub fn new(axis_align_tol_rad: f32, max_axis_sigma_rad: f32) -> Self {
151 Self {
152 axis_align_tol_rad,
153 max_axis_sigma_rad,
154 ..Self::default()
155 }
156 }
157
158 pub fn with_axis_align_tol_rad(mut self, value: f32) -> Self {
160 self.axis_align_tol_rad = value;
161 self
162 }
163
164 pub fn with_max_axis_sigma_rad(mut self, value: f32) -> Self {
166 self.max_axis_sigma_rad = value;
167 self
168 }
169
170 pub fn with_opposing_edge_ratio_max(mut self, value: f32) -> Self {
172 self.opposing_edge_ratio_max = value;
173 self
174 }
175
176 pub fn with_edge_length_min_rel(mut self, value: f32) -> Self {
178 self.edge_length_min_rel = value;
179 self
180 }
181
182 pub fn with_edge_length_max_rel(mut self, value: f32) -> Self {
184 self.edge_length_max_rel = value;
185 self
186 }
187
188 pub fn with_edge_length_band(mut self, min_rel: f32, max_rel: f32) -> Self {
194 self.edge_length_min_rel = min_rel;
195 self.edge_length_max_rel = max_rel;
196 self
197 }
198
199 pub fn with_min_corners_for_component(mut self, value: usize) -> Self {
201 self.min_corners_for_component = value;
202 self
203 }
204
205 pub fn with_min_quads_per_component(mut self, value: usize) -> Self {
207 self.min_quads_per_component = value;
208 self
209 }
210
211 pub fn with_axis_cluster_centers(mut self, centers: [f32; 2]) -> Self {
217 self.axis_cluster_centers = Some(centers);
218 self
219 }
220
221 pub fn with_cluster_axis_tol_rad(mut self, tol_rad: f32) -> Self {
223 self.cluster_axis_tol_rad = tol_rad;
224 self
225 }
226}
227
228pub(crate) fn detect_square_oriented2_all(
243 features: &[OrientedFeature<2>],
244 dimensions: Option<GridDimensions>,
245 params: &DetectionParams,
246 axis_provenance: SquareAxisProvenance,
247) -> Result<Vec<GridSolution>> {
248 detect_square_oriented2_all_observed(features, dimensions, params, axis_provenance, None)
249}
250
251pub(super) fn detect_square_oriented2_all_observed(
252 features: &[OrientedFeature<2>],
253 dimensions: Option<GridDimensions>,
254 params: &DetectionParams,
255 axis_provenance: SquareAxisProvenance,
256 trace: Option<&mut SquarePipelineTrace>,
257) -> Result<Vec<GridSolution>> {
258 let tuning = params.tuning();
259 let SquareTopology {
260 positions,
261 components: merged,
262 } = assemble_square_oriented2_components_observed(features, &tuning.topological, trace)?;
263
264 let merged = if let Some(rec_params) = tuning.recovery.resolve(axis_provenance) {
269 let ij_in: Vec<std::collections::HashMap<(i32, i32), usize>> = merged
270 .iter()
271 .map(|m| m.iter().map(|(c, &idx)| ((c.u, c.v), idx)).collect())
272 .collect();
273 let local_pitch = crate::shared::recovery_schedule::local_pitch_of(&positions);
274 let recovered = crate::shared::recovery_schedule::recover_components(
275 ij_in,
276 crate::shared::recovery_schedule::RecoveryInputs {
277 features,
278 positions: &positions,
279 local_pitch: &local_pitch,
280 params: &rec_params,
281 validate_params: &tuning.validation,
282 },
283 );
284 recovered
285 .into_iter()
286 .map(|m| {
287 m.into_iter()
288 .map(|((u, v), idx)| (Coord::new(u, v), idx))
289 .collect()
290 })
291 .collect()
292 } else {
293 merged
294 };
295
296 let mut component_outputs: Vec<ComponentOutput> = Vec::new();
300 for labelled in &merged {
301 if labelled.len() < 4 {
302 continue;
303 }
304 match build_component_solution(labelled, features, &positions, dimensions, params)? {
305 Some(out) => component_outputs.push(out),
306 None => continue,
307 }
308 }
309
310 if component_outputs.is_empty() {
311 return Err(GridError::DegenerateGeometry);
312 }
313
314 component_outputs.sort_by(|a, b| {
317 b.kept_source_indices
318 .len()
319 .cmp(&a.kept_source_indices.len())
320 .then_with(|| a.min_source_index.cmp(&b.min_source_index))
321 });
322
323 let solutions = assemble_solutions(component_outputs, features);
324 Ok(solutions)
325}
326
327pub(crate) fn assemble_square_oriented2_components(
334 features: &[OrientedFeature<2>],
335 topo: &TopologicalParams,
336) -> Result<Vec<std::collections::HashMap<Coord, usize>>> {
337 assemble_square_oriented2_components_observed(features, topo, None)
338 .map(|topology| topology.components)
339}
340
341fn assemble_square_oriented2_components_observed(
342 features: &[OrientedFeature<2>],
343 topo: &TopologicalParams,
344 mut trace: Option<&mut SquarePipelineTrace>,
345) -> Result<SquareTopology> {
346 if features.len() < MIN_USABLE_FOR_DELAUNAY {
347 return Err(GridError::InsufficientEvidence);
348 }
349
350 let axes = build_axis_caches(features, topo.max_axis_sigma_rad);
351 #[cfg(feature = "tracing")]
355 let usable: Vec<bool> = {
356 let _span = tracing::debug_span!("usable_mask", num_features = features.len()).entered();
357 build_usable_mask(features, &axes, topo)
358 };
359 #[cfg(not(feature = "tracing"))]
360 let usable: Vec<bool> = build_usable_mask(features, &axes, topo);
361 if let Some(trace) = trace.as_mut() {
362 trace.usable.clone_from(&usable);
363 }
364 let n_usable = usable.iter().filter(|&&b| b).count();
365 if n_usable < MIN_USABLE_FOR_DELAUNAY {
366 return Err(GridError::InsufficientEvidence);
367 }
368
369 let positions: Vec<Point2<f32>> = features.iter().map(|f| f.point.position).collect();
373 let triangulation = triangulate_usable(&positions, &usable);
374 if triangulation.num_tri() == 0 {
375 return Err(GridError::DegenerateGeometry);
376 }
377 if let Some(trace) = trace.as_mut() {
378 trace.triangles = triangulation
379 .triangles
380 .chunks_exact(3)
381 .map(|triangle| [triangle[0], triangle[1], triangle[2]])
382 .collect();
383 }
384
385 let edge_kinds =
386 classify::classify_all_edges(&positions, &axes, &triangulation, topo.axis_align_tol_rad);
387 if let Some(trace) = trace.as_mut() {
388 trace.edges = edge_kinds
389 .iter()
390 .enumerate()
391 .map(|(edge, &kind)| {
392 (
393 triangulation.triangles[edge],
394 triangulation.triangles[delaunay::Triangulation::next_edge(edge)],
395 kind,
396 )
397 })
398 .collect();
399 }
400 let raw_quads = quads::merge_triangle_pairs(&triangulation, &edge_kinds, &positions);
401 if let Some(trace) = trace.as_mut() {
402 trace.raw_quads = raw_quads.iter().map(|quad| quad.vertices).collect();
403 }
404 let kept_quads = if let Some(trace) = trace.as_mut() {
405 let mut observer = |stage: filter::FilterStage, quads: &[quads::Quad]| {
406 let snapshot = quads.iter().map(|quad| quad.vertices).collect();
407 match stage {
408 filter::FilterStage::Topology => trace.topology_quads = snapshot,
409 filter::FilterStage::Geometry => trace.geometry_quads = snapshot,
410 filter::FilterStage::CellScale => trace.scale_quads = snapshot,
411 }
412 };
413 filter::filter_quads_observed(
414 raw_quads,
415 &positions,
416 topo.opposing_edge_ratio_max,
417 topo.edge_length_min_rel,
418 topo.edge_length_max_rel,
419 Some(&mut observer),
420 )
421 } else {
422 filter::filter_quads(
423 raw_quads,
424 &positions,
425 topo.opposing_edge_ratio_max,
426 topo.edge_length_min_rel,
427 topo.edge_length_max_rel,
428 )
429 };
430 let components = walk::label_components(
431 &kept_quads,
432 topo.min_quads_per_component,
433 topo.min_corners_for_component,
434 );
435 if let Some(trace) = trace.as_mut() {
436 trace.walk_components = components
437 .iter()
438 .map(|component| sorted_labels(&component.labelled))
439 .collect();
440 }
441
442 if components.is_empty() {
443 return Err(GridError::DegenerateGeometry);
444 }
445
446 let merged = merge_walk_components(&components, &positions);
452 if let Some(trace) = trace.as_mut() {
453 trace.merged_components = merged.iter().map(sorted_labels).collect();
454 }
455 if merged.is_empty() {
456 return Err(GridError::DegenerateGeometry);
457 }
458 Ok(SquareTopology {
459 positions,
460 components: merged,
461 })
462}
463
464fn sorted_labels(labelled: &std::collections::HashMap<Coord, usize>) -> Vec<(Coord, usize)> {
465 let mut labels: Vec<(Coord, usize)> = labelled
466 .iter()
467 .map(|(&coord, &feature_index)| (coord, feature_index))
468 .collect();
469 labels.sort_by_key(|&(coord, feature_index)| (coord.v, coord.u, feature_index));
470 labels
471}
472
473#[cfg_attr(
492 feature = "tracing",
493 tracing::instrument(
494 name = "topological_component_merge",
495 level = "debug",
496 skip_all,
497 fields(num_components = components.len()),
498 )
499)]
500fn merge_walk_components(
501 components: &[walk::TopologicalComponent],
502 positions: &[Point2<f32>],
503) -> Vec<std::collections::HashMap<Coord, usize>> {
504 let mut ordered: Vec<&walk::TopologicalComponent> = components.iter().collect();
506 ordered.sort_by(|a, b| {
507 b.labelled
508 .len()
509 .cmp(&a.labelled.len())
510 .then_with(|| min_feature_index(a).cmp(&min_feature_index(b)))
511 });
512
513 let owned: Vec<std::collections::HashMap<(i32, i32), usize>> = ordered
516 .iter()
517 .map(|c| {
518 c.labelled
519 .iter()
520 .map(|(coord, &idx)| ((coord.u, coord.v), idx))
521 .collect()
522 })
523 .collect();
524 let merged = merge_components_local(&owned, positions, &LocalMergeParams::default()).components;
525
526 merged
527 .into_iter()
528 .map(|m| {
529 m.into_iter()
530 .map(|((u, v), idx)| (Coord::new(u, v), idx))
531 .collect()
532 })
533 .collect()
534}
535
536fn min_feature_index(component: &walk::TopologicalComponent) -> usize {
539 component
540 .labelled
541 .values()
542 .copied()
543 .min()
544 .unwrap_or(usize::MAX)
545}
546
547#[cfg_attr(
552 feature = "tracing",
553 tracing::instrument(
554 name = "topological_assembly",
555 level = "debug",
556 skip_all,
557 fields(num_components = component_outputs.len()),
558 )
559)]
560fn assemble_solutions(
561 component_outputs: Vec<ComponentOutput>,
562 features: &[OrientedFeature<2>],
563) -> Vec<GridSolution> {
564 let mut globally_kept: HashSet<usize> = HashSet::new();
565 let mut globally_validation_dropped: HashSet<usize> = HashSet::new();
566 for out in &component_outputs {
567 for &src in &out.kept_source_indices {
568 globally_kept.insert(src);
569 }
570 for &src in &out.validation_drop_source_indices {
571 globally_validation_dropped.insert(src);
572 }
573 }
574 let mut global_unlabelled: Vec<RejectedFeature> = Vec::new();
575 for feature in features {
576 let src = feature.point.source_index;
577 if globally_kept.contains(&src) {
578 continue;
579 }
580 if globally_validation_dropped.contains(&src) {
581 global_unlabelled.push(RejectedFeature::new(
582 src,
583 None,
584 None,
585 RejectionReason::ValidationDropped,
586 ));
587 continue;
588 }
589 global_unlabelled.push(RejectedFeature::new(
590 src,
591 None,
592 None,
593 RejectionReason::Unlabelled,
594 ));
595 }
596 global_unlabelled.sort_by_key(|rejected| rejected.source_index);
597
598 let mut solutions: Vec<GridSolution> = Vec::with_capacity(component_outputs.len());
599 for (idx, out) in component_outputs.into_iter().enumerate() {
600 let ComponentOutput {
601 entries,
602 fit,
603 dimensions,
604 mut rejected,
605 ..
606 } = out;
607 if idx == 0 {
608 rejected.extend(global_unlabelled.iter().copied());
609 }
610 sort_rejections(&mut rejected);
611 let grid = LabelledGrid::new(LatticeKind::Square, entries, dimensions);
612 solutions.push(GridSolution::new(grid, fit, rejected));
613 }
614 solutions
615}
616
617pub(super) struct ComponentOutput {
618 pub(super) entries: Vec<GridEntry>,
619 pub(super) fit: LatticeFit,
620 pub(super) dimensions: Option<GridDimensions>,
621 pub(super) rejected: Vec<RejectedFeature>,
622 pub(super) kept_source_indices: HashSet<usize>,
623 pub(super) validation_drop_source_indices: HashSet<usize>,
624 pub(super) min_source_index: usize,
625}
626
627fn build_usable_mask(
628 features: &[OrientedFeature<2>],
629 axes: &[AxisCache],
630 topo: &TopologicalParams,
631) -> Vec<bool> {
632 features
633 .iter()
634 .zip(axes.iter())
635 .map(|(f, cache)| cache.any_informative() && axes_pass_cluster_gate(&f.axes, cache, topo))
636 .collect()
637}
638
639fn build_component_solution(
640 labelled: &std::collections::HashMap<Coord, usize>,
641 features: &[OrientedFeature<2>],
642 positions: &[Point2<f32>],
643 dimensions: Option<GridDimensions>,
644 params: &DetectionParams,
645) -> Result<Option<ComponentOutput>> {
646 let mut validate_entries: Vec<pg_validate::LabelledEntry> = labelled
649 .iter()
650 .map(|(coord, &idx)| pg_validate::LabelledEntry {
651 idx,
652 pixel: features[idx].point.position,
653 grid: (coord.u, coord.v),
654 })
655 .collect();
656 validate_entries.sort_by_key(|entry| (entry.grid.1, entry.grid.0, entry.idx));
657 let cell_size = estimate_cell_size(labelled, positions);
658 #[cfg(feature = "tracing")]
659 let validation = {
660 let _span = tracing::debug_span!("topological_validation").entered();
661 pg_validate::validate(&validate_entries, cell_size, ¶ms.tuning().validation)
662 };
663 #[cfg(not(feature = "tracing"))]
664 let validation =
665 pg_validate::validate(&validate_entries, cell_size, ¶ms.tuning().validation);
666
667 let mut kept: Vec<(Coord, usize)> = labelled
669 .iter()
670 .filter(|(_, &idx)| !validation.blacklist.contains(&idx))
671 .map(|(&coord, &idx)| (coord, idx))
672 .collect();
673 if kept.len() < 4 {
674 return Ok(None);
675 }
676
677 let provisional_entries: Vec<GridEntry> = kept
683 .iter()
684 .map(|&(coord, feature_index)| {
685 let feature = &features[feature_index];
686 GridEntry::new(
687 coord,
688 feature.point.source_index,
689 feature.point.position,
690 None,
691 )
692 })
693 .collect();
694 let mut grid = LabelledGrid::new(LatticeKind::Square, provisional_entries, dimensions);
695 grid.normalize();
696 if let (Some(dimensions), Some((min, max))) = (grid.dimensions(), grid.bbox()) {
697 debug_assert_eq!(min, Coord::new(0, 0));
698 let Some(span_u) = usize::try_from(max.u)
699 .ok()
700 .and_then(|value| value.checked_add(1))
701 else {
702 return Ok(None);
703 };
704 let Some(span_v) = usize::try_from(max.v)
705 .ok()
706 .and_then(|value| value.checked_add(1))
707 else {
708 return Ok(None);
709 };
710 if span_u > dimensions.width || span_v > dimensions.height {
711 return Ok(None);
712 }
713 }
714 let feature_index_by_source: std::collections::HashMap<usize, usize> = features
715 .iter()
716 .enumerate()
717 .map(|(index, feature)| (feature.point.source_index, index))
718 .collect();
719 let canonical_kept = grid
720 .entries()
721 .iter()
722 .map(|entry| {
723 feature_index_by_source
724 .get(&entry.source_index)
725 .copied()
726 .map(|feature_index| (entry.coord, feature_index))
727 })
728 .collect::<Option<Vec<_>>>();
729 let Some(canonical_kept) = canonical_kept else {
730 return Ok(None);
731 };
732 kept = canonical_kept;
733 let Some(fit_result) =
734 run_fit_with_residual_drop(&mut kept, features, positions, LatticeKind::Square, params)?
735 else {
736 return Ok(None);
737 };
738 let entries_out = fit_result.entries;
739 let fit = fit_result.fit;
740 let over_threshold = fit_result.over_threshold;
741 let dimensions = grid.dimensions();
742
743 let kept_source_indices: HashSet<usize> = kept
744 .iter()
745 .map(|&(_, idx)| features[idx].point.source_index)
746 .collect();
747 let validation_drop_source_indices: HashSet<usize> = validation
748 .blacklist
749 .iter()
750 .map(|&idx| features[idx].point.source_index)
751 .collect();
752
753 let mut rejected: Vec<RejectedFeature> = Vec::new();
754 for &src in &validation_drop_source_indices {
755 rejected.push(RejectedFeature::new(
756 src,
757 None,
758 None,
759 RejectionReason::ValidationDropped,
760 ));
761 }
762 for r in over_threshold {
763 rejected.push(r);
764 }
765 sort_rejections(&mut rejected);
766
767 let min_source_index = kept_source_indices
768 .iter()
769 .copied()
770 .min()
771 .unwrap_or(usize::MAX);
772
773 Ok(Some(ComponentOutput {
774 entries: entries_out,
775 fit,
776 dimensions,
777 rejected,
778 kept_source_indices,
779 validation_drop_source_indices,
780 min_source_index,
781 }))
782}
783
784#[cfg_attr(
788 feature = "tracing",
789 tracing::instrument(
790 name = "topological_projective_fit",
791 level = "debug",
792 skip_all,
793 fields(num_entries = kept.len()),
794 )
795)]
796fn run_fit_with_residual_drop(
797 kept: &mut Vec<(Coord, usize)>,
798 features: &[OrientedFeature<2>],
799 positions: &[Point2<f32>],
800 lattice: LatticeKind,
801 params: &DetectionParams,
802) -> Result<Option<FitComponentResult>> {
803 let first = fit_component(kept, features, positions, lattice, params)?;
804 if first.over_threshold.is_empty() {
805 return Ok(Some(first));
806 }
807 let drop: HashSet<usize> = first
808 .over_threshold
809 .iter()
810 .map(|r| r.source_index)
811 .collect();
812 kept.retain(|&(_, idx)| !drop.contains(&features[idx].point.source_index));
813 if kept.len() < 4 {
814 return Ok(None);
815 }
816 let refit = fit_component(kept, features, positions, lattice, params)?;
817 Ok(Some(FitComponentResult {
819 entries: refit.entries,
820 fit: refit.fit,
821 over_threshold: first.over_threshold,
822 }))
823}
824
825fn sort_rejections(rejected: &mut [RejectedFeature]) {
826 rejected.sort_by_key(|item| {
827 let reason = match item.reason {
828 RejectionReason::ResidualTooHigh => 0_u8,
829 RejectionReason::ValidationDropped => 1,
830 RejectionReason::Unlabelled => 2,
831 };
832 (
833 item.source_index,
834 reason,
835 item.coord.map(|coord| (coord.v, coord.u)),
836 )
837 });
838}
839
840fn axes_pass_cluster_gate(
846 axes: &[crate::feature::LocalAxis; 2],
847 cache: &AxisCache,
848 params: &TopologicalParams,
849) -> bool {
850 let Some(centers) = params.axis_cluster_centers else {
851 return true;
852 };
853 let tol = params.cluster_axis_tol_rad;
854 for (axis, &informative) in axes.iter().zip(cache.informative.iter()) {
855 if !informative {
856 continue;
857 }
858 let angle = axis.angle_rad;
859 let d0 = angular_dist_pi(angle, centers[0]);
860 let d1 = angular_dist_pi(angle, centers[1]);
861 if d0 < tol || d1 < tol {
862 return true;
863 }
864 }
865 false
866}
867
868pub(in crate::topological) fn triangulate_usable(
871 positions: &[Point2<f32>],
872 usable: &[bool],
873) -> delaunay::Triangulation {
874 let mut packed_to_global: Vec<usize> = Vec::with_capacity(positions.len());
875 let mut packed_positions: Vec<Point2<f32>> = Vec::with_capacity(positions.len());
876 for (i, (&u, &p)) in usable.iter().zip(positions.iter()).enumerate() {
877 if u {
878 packed_to_global.push(i);
879 packed_positions.push(p);
880 }
881 }
882 let mut triangulation = delaunay::triangulate(&packed_positions);
883 for v in triangulation.triangles.iter_mut() {
887 *v = packed_to_global[*v];
888 }
889 triangulation
890}
891
892fn estimate_cell_size(
900 labelled: &std::collections::HashMap<Coord, usize>,
901 positions: &[Point2<f32>],
902) -> f32 {
903 use crate::lattice::SQUARE_CARDINAL_OFFSETS;
904
905 let mut sum = 0.0_f32;
906 let mut count: usize = 0;
907 for (&coord, &idx) in labelled {
908 let here = positions[idx];
909 for offset in &SQUARE_CARDINAL_OFFSETS {
910 let neigh = Coord::new(coord.u + offset.u, coord.v + offset.v);
911 if let Some(&n_idx) = labelled.get(&neigh) {
912 let nb = positions[n_idx];
913 let dx = nb.x - here.x;
914 let dy = nb.y - here.y;
915 sum += (dx * dx + dy * dy).sqrt();
916 count += 1;
917 }
918 }
919 }
920 if count == 0 {
921 return 1.0;
922 }
923 sum / count as f32
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use crate::feature::{LocalAxis, PointFeature};
930
931 fn axis_aligned_features(rows: i32, cols: i32, s: f32) -> Vec<OrientedFeature<2>> {
932 let origin = 50.0_f32;
933 let mut out = Vec::with_capacity((rows * cols) as usize);
934 let mut idx = 0_usize;
935 for j in 0..rows {
936 for i in 0..cols {
937 let x = (i as f32) * s + origin;
938 let y = (j as f32) * s + origin;
939 let point = PointFeature::new(idx, Point2::new(x, y));
940 let axes = [
941 LocalAxis::new(0.0_f32, Some(0.05)),
942 LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.05)),
943 ];
944 out.push(OrientedFeature::new(point, axes));
945 idx += 1;
946 }
947 }
948 out
949 }
950
951 #[test]
952 fn default_params_match_regression_values() {
953 let p = TopologicalParams::default();
954 assert!((p.axis_align_tol_rad - 15.0_f32.to_radians()).abs() < 1e-5);
955 assert!((p.max_axis_sigma_rad - 0.6).abs() < 1e-5);
956 assert!((p.opposing_edge_ratio_max - 1.5).abs() < 1e-5);
957 assert!((p.edge_length_min_rel - 0.4).abs() < 1e-5);
958 assert!((p.edge_length_max_rel - 2.5).abs() < 1e-5);
959 assert_eq!(p.min_corners_for_component, 4);
960 assert_eq!(p.min_quads_per_component, 1);
961 assert!(p.axis_cluster_centers.is_none());
962 assert!((p.cluster_axis_tol_rad - 16.0_f32.to_radians()).abs() < 1e-5);
963 }
964
965 #[test]
966 fn clean_5x5_grid_is_fully_labelled() {
967 let features = axis_aligned_features(5, 5, 20.0);
968 let params = DetectionParams::default();
969 let mut solutions = detect_square_oriented2_all(
970 &features,
971 None,
972 ¶ms,
973 SquareAxisProvenance::FullyMeasured,
974 )
975 .unwrap();
976 assert_eq!(solutions.len(), 1);
977 let solution = solutions.remove(0);
978 assert_eq!(solution.detection.grid().entries().len(), 25);
979 let fit = solution.detection.fit();
980 assert!(fit.residuals.max_px < 0.01, "{}", fit.residuals.max_px);
981 }
982
983 #[test]
984 fn fewer_than_three_features_errors() {
985 let features = axis_aligned_features(1, 2, 20.0);
986 let params = DetectionParams::default();
987 let err = detect_square_oriented2_all(
988 &features,
989 None,
990 ¶ms,
991 SquareAxisProvenance::FullyMeasured,
992 )
993 .unwrap_err();
994 assert_eq!(err, GridError::InsufficientEvidence);
995 }
996
997 #[test]
998 fn cluster_gate_drops_off_axis_features() {
999 let mut features = axis_aligned_features(5, 5, 20.0);
1004 let extra: [(f32, f32); 4] = [(40.0, 40.0), (180.0, 40.0), (40.0, 180.0), (180.0, 180.0)];
1005 let next = features.len();
1006 for (i, &(x, y)) in extra.iter().enumerate() {
1007 let point = PointFeature::new(next + i, Point2::new(x, y));
1008 let off_axis = std::f32::consts::FRAC_PI_4;
1009 let axes = [
1010 LocalAxis::new(off_axis, Some(0.05)),
1011 LocalAxis::new(off_axis + std::f32::consts::FRAC_PI_2, Some(0.05)),
1012 ];
1013 features.push(OrientedFeature::new(point, axes));
1014 }
1015
1016 let tuning = crate::detect::DetectionTuning::default().with_topological(
1017 TopologicalParams::default()
1018 .with_axis_cluster_centers([0.0, std::f32::consts::FRAC_PI_2]),
1019 );
1020 let params_on = DetectionParams::default().with_advanced(tuning);
1021 let mut sol_on = detect_square_oriented2_all(
1022 &features,
1023 None,
1024 ¶ms_on,
1025 SquareAxisProvenance::FullyMeasured,
1026 )
1027 .unwrap();
1028 assert_eq!(sol_on.len(), 1);
1029 let primary = sol_on.remove(0);
1030 assert_eq!(
1031 primary.detection.grid().entries().len(),
1032 25,
1033 "gate must keep the 5×5"
1034 );
1035
1036 let params_off = DetectionParams::default();
1037 let mut sol_off = detect_square_oriented2_all(
1038 &features,
1039 None,
1040 ¶ms_off,
1041 SquareAxisProvenance::FullyMeasured,
1042 )
1043 .unwrap();
1044 assert_eq!(sol_off.len(), 1);
1045 let primary_off = sol_off.remove(0);
1046 assert_eq!(primary_off.detection.grid().entries().len(), 25);
1047 let noise_ids: std::collections::HashSet<usize> = (next..next + 4).collect();
1048 for r in &primary.rejected {
1049 if noise_ids.contains(&r.source_index) {
1050 assert_eq!(r.reason, RejectionReason::Unlabelled);
1051 }
1052 }
1053 }
1054
1055 #[test]
1056 fn axes_pass_cluster_gate_with_no_centers_is_identity() {
1057 let cache = AxisCache {
1058 angle_rad: [std::f32::consts::FRAC_PI_4, std::f32::consts::FRAC_PI_4],
1059 informative: [true, true],
1060 };
1061 let axes = [
1062 LocalAxis::new(std::f32::consts::FRAC_PI_4, Some(0.05_f32)),
1063 LocalAxis::new(std::f32::consts::FRAC_PI_4, Some(0.05_f32)),
1064 ];
1065 let params_off = TopologicalParams::default();
1066 assert!(axes_pass_cluster_gate(&axes, &cache, ¶ms_off));
1067 let params_on = TopologicalParams::default()
1068 .with_axis_cluster_centers([0.0_f32, std::f32::consts::FRAC_PI_2]);
1069 assert!(!axes_pass_cluster_gate(&axes, &cache, ¶ms_on));
1070 }
1071
1072 #[test]
1073 fn angular_dist_pi_is_undirected() {
1074 let pi = std::f32::consts::PI;
1075 let d_zero = angular_dist_pi(0.0, pi);
1076 assert!(d_zero < 1e-5, "{d_zero}");
1077 let d_perp = angular_dist_pi(0.0, std::f32::consts::FRAC_PI_2);
1078 assert!((d_perp - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
1079 let d_signed = angular_dist_pi(-0.1, std::f32::consts::PI + 0.1);
1080 assert!((d_signed - 0.2).abs() < 1e-4, "{d_signed}");
1081 let d_seam = angular_dist_pi(std::f32::consts::PI - 0.05, 0.05);
1082 assert!((d_seam - 0.1).abs() < 1e-5, "{d_seam}");
1083 }
1084}