Skip to main content

projective_grid/topological/
square_detector.rs

1//! Axis-driven topological grid finder (Shu/Brunton/Fiala 2009) for the
2//! `(LatticeKind::Square, Evidence::Oriented2)` slot.
3//!
4//! Pipeline overview:
5//!
6//! 1. Pre-filter features whose both axes are uninformative under
7//!    [`TopologicalParams::max_axis_sigma_rad`].
8//! 2. Delaunay-triangulate the surviving feature positions.
9//! 3. Classify every Delaunay half-edge as `Grid`, `Diagonal`, or
10//!    `Spurious` via the per-corner axes (no image-color sampling).
11//! 4. Merge triangle pairs sharing a `Diagonal` edge into quads (one
12//!    quad per lattice cell).
13//! 5. Drop quads with two illegal corners (quad-mesh degree > 4),
14//!    extreme parallelograms, or out-of-band edge lengths against the
15//!    per-component median.
16//! 6. Flood-fill integer `(u, v)` labels through the surviving quad
17//!    mesh and rebase each connected component to `(0, 0)`.
18//! 7. Reunite the labelled components in label space with the shared
19//!    [`crate::shared::merge::merge_components_local`]
20//!    pass (local geometry only, radial-distortion safe), so the topological
21//!    path no longer leaves an un-merged quad-mesh component per disconnected
22//!    patch.
23//! 8. Reuse the shared advanced [`validate`](crate::shared::validate)
24//!    post-stage to drop labelled corners flagged by line-collinearity and
25//!    local-H checks.
26//! 9. Fit a projective transform on the surviving labels and report
27//!    per-corner residuals.
28//!
29//! Multi-component output is represented directly: the orchestrator returns
30//! one [`GridSolution`] per qualifying component, ordered by labelled count
31//! descending.
32
33use 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
53/// Minimum number of usable features for Delaunay triangulation.
54pub(super) const MIN_USABLE_FOR_DELAUNAY: usize = 3;
55
56/// Exact stage snapshots collected only by the diagnostics entry point.
57#[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/// Tuning knobs for the axis-driven topological pipeline.
79///
80/// Defaults are conservative values pinned by the crate's regression tests.
81/// Adding new fields is non-breaking via `#[non_exhaustive]`;
82/// literal-construction from outside the crate goes through [`Self::default`]
83/// + struct-update syntax or [`Self::new`].
84#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
85#[non_exhaustive]
86pub struct TopologicalParams {
87    /// Maximum angular distance, in radians, between an edge's
88    /// direction and a corner's axis for the edge to classify as a
89    /// grid edge at that corner. Default: 15° = 0.262 rad.
90    pub axis_align_tol_rad: f32,
91    /// Maximum 1σ axis uncertainty (radians) for a feature axis to be
92    /// considered informative. Features whose both axes have
93    /// `sigma_rad ≥ max_axis_sigma_rad` are excluded from Delaunay;
94    /// classification skips individual axes above the threshold.
95    /// Default: `0.6 ≈ 34°`. `sigma_rad = None` is treated as informative.
96    pub max_axis_sigma_rad: f32,
97    /// Reject quads whose opposing edges differ in length by more than
98    /// this factor (paper's parallelogram test). Default: `1.5`.
99    pub opposing_edge_ratio_max: f32,
100    /// Lower bound on a quad's perimeter edge length, expressed as a
101    /// fraction of the per-component median quad edge length. Quads
102    /// with any edge shorter than `edge_length_min_rel * component_median`
103    /// are rejected as "below local cell scale". Default: `0.4`.
104    /// Set to `0.0` to disable the lower bound entirely.
105    pub edge_length_min_rel: f32,
106    /// Upper bound on a quad's perimeter edge length, expressed as a
107    /// fraction of the per-component median quad edge length. Quads
108    /// with any edge longer than `edge_length_max_rel * component_median`
109    /// are rejected as "above local cell scale" (typically a quad formed
110    /// across a missing corner). Default: `2.5`. Set to `+inf` to
111    /// disable the upper bound entirely.
112    pub edge_length_max_rel: f32,
113    /// Discard labelled components with fewer than this many corners.
114    /// Default: `4` (one quad of four corners).
115    pub min_corners_for_component: usize,
116    /// Discard connected quad-mesh components below this size. Default:
117    /// `1` (keep all). Set higher to reject isolated noise quads.
118    pub min_quads_per_component: usize,
119    /// Optional global grid-direction centers, in radians, interpreted
120    /// modulo π. When `Some([θ₀, θ₁])`, a feature is admitted to
121    /// Delaunay only if at least one of its informative axes is within
122    /// [`Self::cluster_axis_tol_rad`] of one of the centers. When
123    /// `None`, the gate is skipped.
124    pub axis_cluster_centers: Option<[f32; 2]>,
125    /// Per-axis admission tolerance against
126    /// [`Self::axis_cluster_centers`], in radians. Only consulted when
127    /// `axis_cluster_centers.is_some()`. Default: `16° = 0.279`.
128    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    /// Construct topological params from the two most commonly tuned
149    /// knobs; the remaining fields take their defaults.
150    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    /// Builder-style override for [`Self::axis_align_tol_rad`].
159    pub fn with_axis_align_tol_rad(mut self, value: f32) -> Self {
160        self.axis_align_tol_rad = value;
161        self
162    }
163
164    /// Builder-style override for [`Self::max_axis_sigma_rad`].
165    pub fn with_max_axis_sigma_rad(mut self, value: f32) -> Self {
166        self.max_axis_sigma_rad = value;
167        self
168    }
169
170    /// Builder-style override for [`Self::opposing_edge_ratio_max`].
171    pub fn with_opposing_edge_ratio_max(mut self, value: f32) -> Self {
172        self.opposing_edge_ratio_max = value;
173        self
174    }
175
176    /// Builder-style override for [`Self::edge_length_min_rel`].
177    pub fn with_edge_length_min_rel(mut self, value: f32) -> Self {
178        self.edge_length_min_rel = value;
179        self
180    }
181
182    /// Builder-style override for [`Self::edge_length_max_rel`].
183    pub fn with_edge_length_max_rel(mut self, value: f32) -> Self {
184        self.edge_length_max_rel = value;
185        self
186    }
187
188    /// Set both edge-length bounds in one call. Equivalent to
189    /// `.with_edge_length_min_rel(min_rel).with_edge_length_max_rel(max_rel)`.
190    ///
191    /// Pass `min_rel = 0.0` to disable the lower bound; pass
192    /// `max_rel = f32::INFINITY` to disable the upper bound.
193    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    /// Builder-style override for [`Self::min_corners_for_component`].
200    pub fn with_min_corners_for_component(mut self, value: usize) -> Self {
201        self.min_corners_for_component = value;
202        self
203    }
204
205    /// Builder-style override for [`Self::min_quads_per_component`].
206    pub fn with_min_quads_per_component(mut self, value: usize) -> Self {
207        self.min_quads_per_component = value;
208        self
209    }
210
211    /// Builder-style override for [`Self::axis_cluster_centers`]. The
212    /// two centers are stored as supplied; the caller is responsible
213    /// for wrapping them into `[0, π)` if their source might emit
214    /// signed angles. The internal alignment check works modulo π so
215    /// either convention is accepted.
216    pub fn with_axis_cluster_centers(mut self, centers: [f32; 2]) -> Self {
217        self.axis_cluster_centers = Some(centers);
218        self
219    }
220
221    /// Builder-style override for [`Self::cluster_axis_tol_rad`].
222    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
228/// Multi-component axis-driven topological grid detector for
229/// `(Square, Oriented2)`.
230///
231/// Returns one [`GridSolution`] per qualifying connected quad-mesh
232/// component, ordered by component size descending. Features that no
233/// component admitted (uninformative axes, gated by the cluster prior,
234/// not picked up by Delaunay, etc.) appear in the **first** solution's
235/// `rejected` vector tagged [`RejectionReason::Unlabelled`]; features
236/// dropped by the per-component validation stage appear in that
237/// component's own `rejected` vector tagged
238/// [`RejectionReason::ValidationDropped`].
239///
240/// Too little usable evidence returns [`GridError::InsufficientEvidence`];
241/// topology or fit degeneracy returns the underlying typed geometry error.
242pub(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    // Geometry-only recovery schedule for facade-synthesized axes. The native
265    // Oriented2 path stays off under `RecoverySchedule::Auto`; explicit `On`
266    // still applies to either provenance. The chessboard adapter selects `Off`
267    // because it owns its pattern-specific recovery downstream.
268    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    // Process each merged component independently; preserve the labelled
297    // source-indices of every component that yielded a valid solution
298    // so the orchestrator can build the global "unlabelled" set afterwards.
299    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    // Sort components by labelled count descending; ties broken by the
315    // smallest source_index seen so the order is deterministic.
316    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
327/// Assemble square topology through the component-merge checkpoint.
328///
329/// Coordinates intentionally remain in the walk's axis-slot frame. Public
330/// detections canonicalize them later, after validation and before fitting;
331/// pattern-specific detector builders may need the original slots because
332/// their admission and recovery policies attach semantics to those axes.
333pub(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    // Apply the optional axis-cluster gate. When no centers are supplied
352    // the predicate is the identity and `usable` matches the ungated
353    // behaviour exactly.
354    #[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    // Triangulate over the packed usable set; remap triangles back into
370    // the global feature index space so the downstream stages share
371    // indices with `features` / `axes`.
372    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    // Reunite the labelled components in label space with the shared
447    // `merge_components_local` step. The topological walk leaves one quad-mesh
448    // component per disconnected patch; hosting the merge here lets the
449    // chessboard adapter consume a single already-merged output (see
450    // `calib-targets-chessboard::topological`).
451    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/// Reunite the walk's labelled components in label space via the shared
474/// local-geometry merge, then return one `Coord`-keyed map per surviving
475/// merged component.
476///
477/// The merge input is ordered exactly as the per-component solutions were
478/// historically presented to consumers: by labelled count descending, ties
479/// broken by the smallest feature index. The previous architecture ran the
480/// per-component validate + fit first and sorted the resulting solutions by
481/// `(kept_source_indices.len() desc, min_source_index asc)`; with this
482/// facade-hosted merge the validate/fit run *after* the merge, so the
483/// pre-merge ordering is reconstructed directly from the walk components
484/// (validate is membership-preserving for the merge-input ordering keys —
485/// labelled count and minimum feature index — so the two orderings agree).
486///
487/// `merge_components_local` re-sorts its working set by size on every
488/// iteration and rebases its output, so this ordering only fixes the
489/// tie-break among equal-size components; pinning it keeps the merge
490/// deterministic and byte-compatible with the prior chessboard-side merge.
491#[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    // Order the walk components by the historical solution-presentation key.
505    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    // Convert each `Coord`-keyed walk map into the `(i32, i32)`-keyed shape
514    // the shared merge consumes. Hold the owned maps alive so the
515    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
536/// Smallest feature index referenced by a walk component, used as the
537/// tie-break in [`merge_walk_components`]'s ordering.
538fn 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/// Build the global "unlabelled" set and assemble the per-component
548/// solutions, attributing every globally-unseen feature to the largest
549/// component so callers that read solely `solutions[0].rejected` see the
550/// same shape as the single-solution path.
551#[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    // Reuse the shared advanced validate post-stage (the same module the
647    // chessboard topological adapter and the recovery schedule consume).
648    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, &params.tuning().validation)
662    };
663    #[cfg(not(feature = "tracing"))]
664    let validation =
665        pg_validate::validate(&validate_entries, cell_size, &params.tuning().validation);
666
667    // Working label set after the validation drop, keyed by Coord.
668    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    // Canonicalize before fitting so the mandatory public transform and its
678    // residuals are expressed in exactly the same coordinate frame as the
679    // returned labels. Detector builders that need the original axis slots use
680    // `expert::square::assemble_oriented2_components`, which stops before this
681    // facade-only normalization.
682    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/// Run the shared `fit_component` helper, drop over-threshold entries
785/// once, and refit on the remaining set. Mutates `kept` to the surviving
786/// label set. Returns `None` when fewer than four entries survive.
787#[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    // Preserve the first pass's over-threshold attribution.
818    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
840/// Per-feature alignment check against the optional axis-cluster
841/// centers in [`TopologicalParams`]. Returns `true` when the gate is
842/// disabled (`axis_cluster_centers.is_none()`) or when at least one
843/// informative axis is within `cluster_axis_tol_rad` of one of the
844/// centers under undirected (mod π) distance.
845fn 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
868/// Triangulate only the usable features and remap triangle vertex
869/// indices back into the global feature index space.
870pub(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    // After triangulation, indices reference the packed slice. We remap
884    // `triangles` to global indices; `halfedges` stay valid because
885    // half-edges are offsets into `triangles`, not vertex indices.
886    for v in triangulation.triangles.iter_mut() {
887        *v = packed_to_global[*v];
888    }
889    triangulation
890}
891
892/// Mean labelled-pair edge length over cardinal lattice neighbours.
893/// Used as the `cell_size` input to the shared validate post-stage.
894///
895/// Falls back to `1.0` when no cardinal pair exists, in which case the
896/// validate caller's relative tolerances reduce to absolute thresholds.
897/// In practice the topological pipeline only reaches this helper with
898/// at least one labelled quad, so the fallback is defensive.
899fn 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            &params,
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            &params,
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        // 5×5 axis-aligned grid (axes at 0°, 90°) + 4 noise features
1000        // whose axes both sit near 45°. With the cluster gate centered
1001        // at [0, π/2] and a 16° tolerance, the noise features must be
1002        // dropped pre-Delaunay; with the gate disabled they survive.
1003        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            &params_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            &params_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, &params_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, &params_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}