1mod axis;
34mod classify;
35mod delaunay;
36mod filter;
37pub(crate) mod hex;
38mod quads;
39mod walk;
40
41use std::collections::HashSet;
42
43use nalgebra::Point2;
44
45use crate::detect::DetectionParams;
46use crate::error::{GridError, Result};
47use crate::feature::OrientedFeature;
48use crate::lattice::{Coord, GridDimensions, LatticeKind};
49use crate::result::{
50 GridEntry, GridSolution, LabelledGrid, LatticeFit, RejectedFeature, RejectionReason,
51};
52use crate::shared::merge::{merge_components_local, ComponentInput, LocalMergeParams};
53use crate::shared::validate as pg_validate;
54
55use self::axis::{build_axis_caches, AxisCache};
56use super::shared::{fit_component, FitComponentResult};
57
58pub(super) const MIN_USABLE_FOR_DELAUNAY: usize = 3;
60
61#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
68#[non_exhaustive]
69pub struct TopologicalParams {
70 pub axis_align_tol_rad: f32,
74 pub max_axis_sigma_rad: f32,
80 pub opposing_edge_ratio_max: f32,
83 pub edge_length_min_rel: f32,
89 pub edge_length_max_rel: f32,
96 pub min_corners_for_component: usize,
99 pub min_quads_per_component: usize,
102 pub axis_cluster_centers: Option<[f32; 2]>,
108 pub cluster_axis_tol_rad: f32,
112}
113
114impl Default for TopologicalParams {
115 fn default() -> Self {
116 Self {
117 axis_align_tol_rad: 15.0_f32.to_radians(),
118 max_axis_sigma_rad: 0.6,
119 opposing_edge_ratio_max: 1.5,
120 edge_length_min_rel: 0.4,
121 edge_length_max_rel: 2.5,
122 min_corners_for_component: 4,
123 min_quads_per_component: 1,
124 axis_cluster_centers: None,
125 cluster_axis_tol_rad: 16.0_f32.to_radians(),
126 }
127 }
128}
129
130impl TopologicalParams {
131 pub fn new(axis_align_tol_rad: f32, max_axis_sigma_rad: f32) -> Self {
134 Self {
135 axis_align_tol_rad,
136 max_axis_sigma_rad,
137 ..Self::default()
138 }
139 }
140
141 pub fn with_axis_align_tol_rad(mut self, value: f32) -> Self {
143 self.axis_align_tol_rad = value;
144 self
145 }
146
147 pub fn with_max_axis_sigma_rad(mut self, value: f32) -> Self {
149 self.max_axis_sigma_rad = value;
150 self
151 }
152
153 pub fn with_opposing_edge_ratio_max(mut self, value: f32) -> Self {
155 self.opposing_edge_ratio_max = value;
156 self
157 }
158
159 pub fn with_edge_length_min_rel(mut self, value: f32) -> Self {
161 self.edge_length_min_rel = value;
162 self
163 }
164
165 pub fn with_edge_length_max_rel(mut self, value: f32) -> Self {
167 self.edge_length_max_rel = value;
168 self
169 }
170
171 pub fn with_edge_length_band(mut self, min_rel: f32, max_rel: f32) -> Self {
177 self.edge_length_min_rel = min_rel;
178 self.edge_length_max_rel = max_rel;
179 self
180 }
181
182 pub fn with_min_corners_for_component(mut self, value: usize) -> Self {
184 self.min_corners_for_component = value;
185 self
186 }
187
188 pub fn with_min_quads_per_component(mut self, value: usize) -> Self {
190 self.min_quads_per_component = value;
191 self
192 }
193
194 pub fn with_axis_cluster_centers(mut self, centers: [f32; 2]) -> Self {
200 self.axis_cluster_centers = Some(centers);
201 self
202 }
203
204 pub fn with_cluster_axis_tol_rad(mut self, tol_rad: f32) -> Self {
206 self.cluster_axis_tol_rad = tol_rad;
207 self
208 }
209}
210
211pub(crate) fn detect_square_oriented2_topological_all(
228 features: &[OrientedFeature<2>],
229 dimensions: Option<GridDimensions>,
230 params: &DetectionParams,
231 synthesized_axes: bool,
232) -> Result<Vec<GridSolution>> {
233 if features.len() < MIN_USABLE_FOR_DELAUNAY {
234 return Err(GridError::InsufficientEvidence);
235 }
236
237 let topo = ¶ms.topological;
238 let axes = build_axis_caches(features, topo.max_axis_sigma_rad);
239 #[cfg(feature = "tracing")]
243 let usable: Vec<bool> = {
244 let _span = tracing::debug_span!("usable_mask", num_features = features.len()).entered();
245 build_usable_mask(features, &axes, topo)
246 };
247 #[cfg(not(feature = "tracing"))]
248 let usable: Vec<bool> = build_usable_mask(features, &axes, topo);
249 let n_usable = usable.iter().filter(|&&b| b).count();
250 if n_usable < MIN_USABLE_FOR_DELAUNAY {
251 return Err(GridError::InsufficientEvidence);
252 }
253
254 let positions: Vec<Point2<f32>> = features.iter().map(|f| f.point.position).collect();
258 let triangulation = triangulate_usable(&positions, &usable);
259 if triangulation.num_tri() == 0 {
260 return Err(GridError::DegenerateGeometry);
261 }
262
263 let edge_kinds =
264 classify::classify_all_edges(&positions, &axes, &triangulation, topo.axis_align_tol_rad);
265 let raw_quads = quads::merge_triangle_pairs(&triangulation, &edge_kinds, &positions);
266 let kept_quads = filter::filter_quads(
267 raw_quads,
268 &positions,
269 topo.opposing_edge_ratio_max,
270 topo.edge_length_min_rel,
271 topo.edge_length_max_rel,
272 );
273 let components = walk::label_components(
274 &kept_quads,
275 topo.min_quads_per_component,
276 topo.min_corners_for_component,
277 );
278
279 if components.is_empty() {
280 return Err(GridError::DegenerateGeometry);
281 }
282
283 let merged = merge_walk_components(&components, &positions);
289 if merged.is_empty() {
290 return Err(GridError::DegenerateGeometry);
291 }
292
293 let merged = if let Some(rec_params) = params.recovery.resolve(synthesized_axes) {
300 let ij_in: Vec<std::collections::HashMap<(i32, i32), usize>> = merged
301 .iter()
302 .map(|m| m.iter().map(|(c, &idx)| ((c.u, c.v), idx)).collect())
303 .collect();
304 let local_pitch = crate::shared::recovery_schedule::local_pitch_of(&positions);
305 let recovered = crate::shared::recovery_schedule::recover_components(
306 ij_in,
307 crate::shared::recovery_schedule::RecoveryInputs {
308 features,
309 positions: &positions,
310 local_pitch: &local_pitch,
311 params: &rec_params,
312 validate_params: ¶ms.validate,
313 },
314 );
315 recovered
316 .into_iter()
317 .map(|m| {
318 m.into_iter()
319 .map(|((u, v), idx)| (Coord::new(u, v), idx))
320 .collect()
321 })
322 .collect()
323 } else {
324 merged
325 };
326
327 let mut component_outputs: Vec<ComponentOutput> = Vec::new();
332 for labelled in &merged {
333 if labelled.len() < 4 {
334 continue;
335 }
336 match build_component_solution(labelled, features, &positions, params) {
337 Some(out) => component_outputs.push(out),
338 None => continue,
339 }
340 }
341
342 if component_outputs.is_empty() {
343 return Err(GridError::DegenerateGeometry);
344 }
345
346 component_outputs.sort_by(|a, b| {
349 b.kept_source_indices
350 .len()
351 .cmp(&a.kept_source_indices.len())
352 .then_with(|| a.min_source_index.cmp(&b.min_source_index))
353 });
354
355 let solutions = assemble_solutions(component_outputs, features, dimensions);
356 Ok(solutions)
357}
358
359fn merge_walk_components(
378 components: &[walk::TopologicalComponent],
379 positions: &[Point2<f32>],
380) -> Vec<std::collections::HashMap<Coord, usize>> {
381 let mut ordered: Vec<&walk::TopologicalComponent> = components.iter().collect();
383 ordered.sort_by(|a, b| {
384 b.labelled
385 .len()
386 .cmp(&a.labelled.len())
387 .then_with(|| min_feature_index(a).cmp(&min_feature_index(b)))
388 });
389
390 let owned: Vec<std::collections::HashMap<(i32, i32), usize>> = ordered
394 .iter()
395 .map(|c| {
396 c.labelled
397 .iter()
398 .map(|(coord, &idx)| ((coord.u, coord.v), idx))
399 .collect()
400 })
401 .collect();
402 let views: Vec<ComponentInput<'_>> = owned
403 .iter()
404 .map(|labelled| ComponentInput {
405 labelled,
406 positions,
407 })
408 .collect();
409
410 let merged = merge_components_local(&views, &LocalMergeParams::default());
411 let merged = if merged.components.is_empty() {
412 owned
416 } else {
417 merged.components
418 };
419
420 merged
421 .into_iter()
422 .map(|m| {
423 m.into_iter()
424 .map(|((u, v), idx)| (Coord::new(u, v), idx))
425 .collect()
426 })
427 .collect()
428}
429
430fn min_feature_index(component: &walk::TopologicalComponent) -> usize {
433 component
434 .labelled
435 .values()
436 .copied()
437 .min()
438 .unwrap_or(usize::MAX)
439}
440
441fn assemble_solutions(
446 component_outputs: Vec<ComponentOutput>,
447 features: &[OrientedFeature<2>],
448 dimensions: Option<GridDimensions>,
449) -> Vec<GridSolution> {
450 let mut globally_kept: HashSet<usize> = HashSet::new();
451 let mut globally_validation_dropped: HashSet<usize> = HashSet::new();
452 for out in &component_outputs {
453 for &src in &out.kept_source_indices {
454 globally_kept.insert(src);
455 }
456 for &src in &out.validation_drop_source_indices {
457 globally_validation_dropped.insert(src);
458 }
459 }
460 let mut global_unlabelled: Vec<RejectedFeature> = Vec::new();
461 for feature in features {
462 let src = feature.point.source_index;
463 if globally_kept.contains(&src) {
464 continue;
465 }
466 if globally_validation_dropped.contains(&src) {
467 global_unlabelled.push(RejectedFeature::new(
468 src,
469 None,
470 None,
471 RejectionReason::ValidationDropped,
472 ));
473 continue;
474 }
475 global_unlabelled.push(RejectedFeature::new(
476 src,
477 None,
478 None,
479 RejectionReason::Unlabelled,
480 ));
481 }
482
483 let mut solutions: Vec<GridSolution> = Vec::with_capacity(component_outputs.len());
484 for (idx, out) in component_outputs.into_iter().enumerate() {
485 let ComponentOutput {
486 entries,
487 fit,
488 mut rejected,
489 ..
490 } = out;
491 if idx == 0 {
492 rejected.extend(global_unlabelled.iter().copied());
493 }
494 let grid = LabelledGrid::new(LatticeKind::Square, entries, dimensions);
495 solutions.push(GridSolution::new(grid, Some(fit), rejected));
496 }
497 solutions
498}
499
500pub(super) struct ComponentOutput {
501 pub(super) entries: Vec<GridEntry>,
502 pub(super) fit: LatticeFit,
503 pub(super) rejected: Vec<RejectedFeature>,
504 pub(super) kept_source_indices: HashSet<usize>,
505 pub(super) validation_drop_source_indices: HashSet<usize>,
506 pub(super) min_source_index: usize,
507}
508
509fn build_usable_mask(
510 features: &[OrientedFeature<2>],
511 axes: &[AxisCache],
512 topo: &TopologicalParams,
513) -> Vec<bool> {
514 features
515 .iter()
516 .zip(axes.iter())
517 .map(|(f, cache)| cache.any_informative() && axes_pass_cluster_gate(&f.axes, cache, topo))
518 .collect()
519}
520
521fn build_component_solution(
522 labelled: &std::collections::HashMap<Coord, usize>,
523 features: &[OrientedFeature<2>],
524 positions: &[Point2<f32>],
525 params: &DetectionParams,
526) -> Option<ComponentOutput> {
527 let validate_entries: Vec<pg_validate::LabelledEntry> = labelled
530 .iter()
531 .map(|(coord, &idx)| pg_validate::LabelledEntry {
532 idx,
533 pixel: features[idx].point.position,
534 grid: (coord.u, coord.v),
535 })
536 .collect();
537 let cell_size = estimate_cell_size(labelled, positions);
538 let validation = pg_validate::validate(&validate_entries, cell_size, ¶ms.validate);
539
540 let mut kept: Vec<(Coord, usize)> = labelled
542 .iter()
543 .filter(|(_, &idx)| !validation.blacklist.contains(&idx))
544 .map(|(&coord, &idx)| (coord, idx))
545 .collect();
546 if kept.len() < 4 {
547 return None;
548 }
549
550 let lattice = LatticeKind::Square;
551 let fit_result = run_fit_with_residual_drop(&mut kept, features, positions, lattice, params)?;
552 let FitComponentResult {
553 entries: entries_out,
554 fit,
555 over_threshold,
556 } = fit_result;
557
558 let kept_source_indices: HashSet<usize> = kept
559 .iter()
560 .map(|&(_, idx)| features[idx].point.source_index)
561 .collect();
562 let validation_drop_source_indices: HashSet<usize> = validation
563 .blacklist
564 .iter()
565 .map(|&idx| features[idx].point.source_index)
566 .collect();
567
568 let mut rejected: Vec<RejectedFeature> = Vec::new();
569 for &src in &validation_drop_source_indices {
570 rejected.push(RejectedFeature::new(
571 src,
572 None,
573 None,
574 RejectionReason::ValidationDropped,
575 ));
576 }
577 for r in over_threshold {
578 rejected.push(r);
579 }
580
581 let min_source_index = kept_source_indices
582 .iter()
583 .copied()
584 .min()
585 .unwrap_or(usize::MAX);
586
587 Some(ComponentOutput {
588 entries: entries_out,
589 fit,
590 rejected,
591 kept_source_indices,
592 validation_drop_source_indices,
593 min_source_index,
594 })
595}
596
597fn run_fit_with_residual_drop(
601 kept: &mut Vec<(Coord, usize)>,
602 features: &[OrientedFeature<2>],
603 positions: &[Point2<f32>],
604 lattice: LatticeKind,
605 params: &DetectionParams,
606) -> Option<FitComponentResult> {
607 let first = fit_component(kept, features, positions, lattice, params).ok()?;
608 if first.over_threshold.is_empty() {
609 return Some(first);
610 }
611 let drop: HashSet<usize> = first
612 .over_threshold
613 .iter()
614 .map(|r| r.source_index)
615 .collect();
616 kept.retain(|&(_, idx)| !drop.contains(&features[idx].point.source_index));
617 if kept.len() < 4 {
618 return None;
619 }
620 let refit = fit_component(kept, features, positions, lattice, params).ok()?;
621 Some(FitComponentResult {
623 entries: refit.entries,
624 fit: refit.fit,
625 over_threshold: first.over_threshold,
626 })
627}
628
629fn axes_pass_cluster_gate(
635 axes: &[crate::feature::LocalAxis; 2],
636 cache: &AxisCache,
637 params: &TopologicalParams,
638) -> bool {
639 let Some(centers) = params.axis_cluster_centers else {
640 return true;
641 };
642 let tol = params.cluster_axis_tol_rad;
643 for (axis, &informative) in axes.iter().zip(cache.informative.iter()) {
644 if !informative {
645 continue;
646 }
647 let angle = axis.angle_rad;
648 let d0 = angular_dist_pi(angle, centers[0]);
649 let d1 = angular_dist_pi(angle, centers[1]);
650 if d0 < tol || d1 < tol {
651 return true;
652 }
653 }
654 false
655}
656
657#[inline]
661fn angular_dist_pi(a: f32, b: f32) -> f32 {
662 let pi = std::f32::consts::PI;
663 let diff_raw = (a - b) % pi;
667 let positive = (diff_raw + pi) % pi;
668 let complement = pi - positive;
669 if positive < complement {
670 positive
671 } else {
672 complement
673 }
674}
675
676pub(in crate::topological) fn triangulate_usable(
679 positions: &[Point2<f32>],
680 usable: &[bool],
681) -> delaunay::Triangulation {
682 let mut packed_to_global: Vec<usize> = Vec::with_capacity(positions.len());
683 let mut packed_positions: Vec<Point2<f32>> = Vec::with_capacity(positions.len());
684 for (i, (&u, &p)) in usable.iter().zip(positions.iter()).enumerate() {
685 if u {
686 packed_to_global.push(i);
687 packed_positions.push(p);
688 }
689 }
690 let mut triangulation = delaunay::triangulate(&packed_positions);
691 for v in triangulation.triangles.iter_mut() {
695 *v = packed_to_global[*v];
696 }
697 triangulation
698}
699
700fn estimate_cell_size(
708 labelled: &std::collections::HashMap<Coord, usize>,
709 positions: &[Point2<f32>],
710) -> f32 {
711 use crate::lattice::SQUARE_CARDINAL_OFFSETS;
712
713 let mut sum = 0.0_f32;
714 let mut count: usize = 0;
715 for (&coord, &idx) in labelled {
716 let here = positions[idx];
717 for offset in &SQUARE_CARDINAL_OFFSETS {
718 let neigh = Coord::new(coord.u + offset.u, coord.v + offset.v);
719 if let Some(&n_idx) = labelled.get(&neigh) {
720 let nb = positions[n_idx];
721 let dx = nb.x - here.x;
722 let dy = nb.y - here.y;
723 sum += (dx * dx + dy * dy).sqrt();
724 count += 1;
725 }
726 }
727 }
728 if count == 0 {
729 return 1.0;
730 }
731 sum / count as f32
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use crate::feature::{LocalAxis, PointFeature};
738
739 fn axis_aligned_features(rows: i32, cols: i32, s: f32) -> Vec<OrientedFeature<2>> {
740 let origin = 50.0_f32;
741 let mut out = Vec::with_capacity((rows * cols) as usize);
742 let mut idx = 0_usize;
743 for j in 0..rows {
744 for i in 0..cols {
745 let x = (i as f32) * s + origin;
746 let y = (j as f32) * s + origin;
747 let point = PointFeature::new(idx, Point2::new(x, y));
748 let axes = [
749 LocalAxis::new(0.0_f32, Some(0.05)),
750 LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.05)),
751 ];
752 out.push(OrientedFeature::new(point, axes));
753 idx += 1;
754 }
755 }
756 out
757 }
758
759 #[test]
760 fn default_params_match_regression_values() {
761 let p = TopologicalParams::default();
762 assert!((p.axis_align_tol_rad - 15.0_f32.to_radians()).abs() < 1e-5);
763 assert!((p.max_axis_sigma_rad - 0.6).abs() < 1e-5);
764 assert!((p.opposing_edge_ratio_max - 1.5).abs() < 1e-5);
765 assert!((p.edge_length_min_rel - 0.4).abs() < 1e-5);
766 assert!((p.edge_length_max_rel - 2.5).abs() < 1e-5);
767 assert_eq!(p.min_corners_for_component, 4);
768 assert_eq!(p.min_quads_per_component, 1);
769 assert!(p.axis_cluster_centers.is_none());
770 assert!((p.cluster_axis_tol_rad - 16.0_f32.to_radians()).abs() < 1e-5);
771 }
772
773 #[test]
774 fn clean_5x5_grid_is_fully_labelled() {
775 let features = axis_aligned_features(5, 5, 20.0);
776 let params = DetectionParams::default();
777 let mut solutions =
778 detect_square_oriented2_topological_all(&features, None, ¶ms, false).unwrap();
779 assert_eq!(solutions.len(), 1);
780 let solution = solutions.remove(0);
781 assert_eq!(solution.grid.entries.len(), 25);
782 let fit = solution.fit.unwrap();
783 assert!(fit.residuals.max_px < 0.01, "{}", fit.residuals.max_px);
784 }
785
786 #[test]
787 fn fewer_than_three_features_errors() {
788 let features = axis_aligned_features(1, 2, 20.0);
789 let params = DetectionParams::default();
790 let err =
791 detect_square_oriented2_topological_all(&features, None, ¶ms, false).unwrap_err();
792 assert_eq!(err, GridError::InsufficientEvidence);
793 }
794
795 #[test]
796 fn cluster_gate_drops_off_axis_features() {
797 let mut features = axis_aligned_features(5, 5, 20.0);
802 let extra: [(f32, f32); 4] = [(40.0, 40.0), (180.0, 40.0), (40.0, 180.0), (180.0, 180.0)];
803 let next = features.len();
804 for (i, &(x, y)) in extra.iter().enumerate() {
805 let point = PointFeature::new(next + i, Point2::new(x, y));
806 let off_axis = std::f32::consts::FRAC_PI_4;
807 let axes = [
808 LocalAxis::new(off_axis, Some(0.05)),
809 LocalAxis::new(off_axis + std::f32::consts::FRAC_PI_2, Some(0.05)),
810 ];
811 features.push(OrientedFeature::new(point, axes));
812 }
813
814 let params_on = DetectionParams::default().with_topological(
815 TopologicalParams::default()
816 .with_axis_cluster_centers([0.0, std::f32::consts::FRAC_PI_2]),
817 );
818 let mut sol_on =
819 detect_square_oriented2_topological_all(&features, None, ¶ms_on, false).unwrap();
820 assert_eq!(sol_on.len(), 1);
821 let primary = sol_on.remove(0);
822 assert_eq!(primary.grid.entries.len(), 25, "gate must keep the 5×5");
823
824 let params_off = DetectionParams::default();
825 let mut sol_off =
826 detect_square_oriented2_topological_all(&features, None, ¶ms_off, false).unwrap();
827 assert_eq!(sol_off.len(), 1);
828 let primary_off = sol_off.remove(0);
829 assert_eq!(primary_off.grid.entries.len(), 25);
830 let noise_ids: std::collections::HashSet<usize> = (next..next + 4).collect();
831 for r in &primary.rejected {
832 if noise_ids.contains(&r.source_index) {
833 assert_eq!(r.reason, RejectionReason::Unlabelled);
834 }
835 }
836 }
837
838 #[test]
839 fn axes_pass_cluster_gate_with_no_centers_is_identity() {
840 let cache = AxisCache {
841 angle_rad: [std::f32::consts::FRAC_PI_4, std::f32::consts::FRAC_PI_4],
842 informative: [true, true],
843 };
844 let axes = [
845 LocalAxis::new(std::f32::consts::FRAC_PI_4, Some(0.05_f32)),
846 LocalAxis::new(std::f32::consts::FRAC_PI_4, Some(0.05_f32)),
847 ];
848 let params_off = TopologicalParams::default();
849 assert!(axes_pass_cluster_gate(&axes, &cache, ¶ms_off));
850 let params_on = TopologicalParams::default()
851 .with_axis_cluster_centers([0.0_f32, std::f32::consts::FRAC_PI_2]);
852 assert!(!axes_pass_cluster_gate(&axes, &cache, ¶ms_on));
853 }
854
855 #[test]
856 fn angular_dist_pi_is_undirected() {
857 let pi = std::f32::consts::PI;
858 let d_zero = angular_dist_pi(0.0, pi);
859 assert!(d_zero < 1e-5, "{d_zero}");
860 let d_perp = angular_dist_pi(0.0, std::f32::consts::FRAC_PI_2);
861 assert!((d_perp - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
862 let d_signed = angular_dist_pi(-0.1, std::f32::consts::PI + 0.1);
863 assert!((d_signed - 0.2).abs() < 1e-4, "{d_signed}");
864 let d_seam = angular_dist_pi(std::f32::consts::PI - 0.05, 0.05);
865 assert!((d_seam - 0.1).abs() < 1e-5, "{d_seam}");
866 }
867}
868
869pub mod trace;
871
872mod hex_detect;
873pub(crate) use hex_detect::detect_hex_oriented3_topological_all;