Skip to main content

projective_grid/topological/
mod.rs

1//! Topological grid construction (Shu/Brunton/Fiala 2009, axis-driven variant).
2//!
3//! Builds a labelled `(i, j)` grid from a cloud of 2D corners by:
4//!
5//! 1. Delaunay-triangulating the points.
6//! 2. Classifying each Delaunay edge as a *grid edge*, *diagonal*, or
7//!    *spurious* using the per-corner ChESS axes — no image color sampling
8//!    is required.
9//! 3. Merging triangle pairs whose shared edge is a diagonal into quads
10//!    (one quad per chessboard cell).
11//! 4. Pruning corners with quad-degree > 4 (illegal), then quads with two
12//!    illegal corners (paper §4).
13//! 5. Pruning quads whose opposing edges differ in length by more than
14//!    `edge_ratio_max` (paper §4 geometric test).
15//! 6. Flood-filling integer `(i, j)` labels through the quad mesh
16//!    (paper §5 topological walking).
17//! 7. Rebasing labels per component so the bounding box starts at `(0, 0)`.
18//!
19//! The pipeline produces one [`TopologicalComponent`] per connected
20//! component of the surviving quad mesh. Component merging is handled by
21//! [`crate::component_merge`] so the same logic is reusable from the
22//! chessboard-v2 seed-and-grow pipeline.
23//!
24//! Why an axis-driven test rather than the paper's color test:
25//!
26//! - The crate stays standalone (no image dependency, see workspace
27//!   architecture rules).
28//! - At low view angles the global cell-size mode estimate becomes
29//!   ambiguous, but ChESS axes (which encode local image-gradient
30//!   direction at each corner) remain reliable.
31//! - The test naturally rejects background corners whose axes do not
32//!   align with the dominant grid directions.
33//!
34//! Pre-conditions on inputs:
35//!
36//! - `positions[k]` and `axes[k]` describe the same corner for every `k`.
37//! - `axes[k][0]` and `axes[k][1]` follow the workspace convention:
38//!   angles in radians, the two axes orthogonal up to ChESS noise, and
39//!   `sigma = π` indicates "no information" (such corners are skipped).
40
41use std::collections::HashMap;
42
43use nalgebra::Point2;
44use serde::{Deserialize, Serialize};
45
46mod classify;
47mod delaunay;
48mod quads;
49mod topo_filter;
50mod walk;
51
52#[cfg(test)]
53mod tests;
54
55pub use classify::EdgeKind;
56
57/// One local grid-axis direction at a corner with its 1σ angular uncertainty.
58///
59/// Mirror of `calib_targets_core::AxisEstimate` so that `projective-grid`
60/// remains free of image / detector dependencies. The chessboard crate
61/// converts `Corner.axes` into this type before calling.
62#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
63pub struct AxisHint {
64    /// Axis angle in radians.
65    pub angle: f32,
66    /// 1σ angular uncertainty in radians. `sigma >= max_sigma` is treated
67    /// as "no information" and the corner is skipped.
68    pub sigma: f32,
69}
70
71impl Default for AxisHint {
72    fn default() -> Self {
73        // No-information default — matches `AxisEstimate::default()`.
74        Self {
75            angle: 0.0,
76            sigma: std::f32::consts::PI,
77        }
78    }
79}
80
81impl AxisHint {
82    /// Construct an `AxisHint` from a bare angle, with no uncertainty
83    /// information (`sigma = 0.0`).  Useful for callers that only have
84    /// an angle (e.g. [`SeedQuadValidator::axes`] impls that do not track
85    /// per-corner uncertainty).
86    ///
87    /// [`SeedQuadValidator::axes`]: crate::square::seed_finder::SeedQuadValidator::axes
88    pub fn from_angle(angle: f32) -> Self {
89        Self { angle, sigma: 0.0 }
90    }
91}
92
93/// Tuning knobs for [`build_grid_topological`].
94#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct TopologicalParams {
97    /// Maximum angular distance, in radians, between an edge's direction
98    /// and a corner's axis for the edge to be classified as a *grid edge*
99    /// at that corner. Default: `15° = 0.262`.
100    pub axis_align_tol_rad: f32,
101    /// Maximum angular distance, in radians, between an edge's direction
102    /// and `axis ± π/4` for the edge to be classified as a *diagonal* at
103    /// that corner. Default: `15° = 0.262`.
104    pub diagonal_angle_tol_rad: f32,
105    /// Maximum 1σ axis uncertainty (radians) for a corner to participate
106    /// in classification. Corners whose both axes have `sigma >=
107    /// max_axis_sigma_rad` are excluded. Default: `0.6` (≈ 34°).
108    pub max_axis_sigma_rad: f32,
109    /// Reject quads whose opposing edges differ in length by more than
110    /// this factor (matches the paper's parallelogram test). Default: `10.0`.
111    pub edge_ratio_max: f32,
112    /// Discard connected quad-mesh components below this size. Default: `1`
113    /// (keep all). Set higher to reject isolated noise quads.
114    pub min_quads_per_component: usize,
115}
116
117impl Default for TopologicalParams {
118    fn default() -> Self {
119        Self {
120            axis_align_tol_rad: 0.262, // 15°
121            diagonal_angle_tol_rad: 0.262,
122            max_axis_sigma_rad: 0.6,
123            edge_ratio_max: 10.0,
124            min_quads_per_component: 1,
125        }
126    }
127}
128
129/// Per-component output of the topological pipeline.
130#[derive(Clone, Debug, Default)]
131pub struct TopologicalComponent {
132    /// `(i, j) → corner_idx` mapping. Indices reference the original
133    /// `positions` slice. The bounding box of the labelled set always
134    /// starts at `(0, 0)` (workspace invariant).
135    pub labelled: HashMap<(i32, i32), usize>,
136}
137
138/// Diagnostic counters from one [`build_grid_topological`] run.
139#[derive(Clone, Copy, Debug, Default)]
140#[non_exhaustive]
141pub struct TopologicalStats {
142    /// Corners passed in.
143    pub corners_in: usize,
144    /// Corners that survived the axis-validity pre-filter.
145    pub corners_used: usize,
146    /// Triangles produced by Delaunay triangulation.
147    pub triangles: usize,
148    /// Half-edges classified as `Grid` (counted twice, once per direction).
149    pub grid_edges: usize,
150    /// Half-edges classified as `Diagonal`.
151    pub diagonal_edges: usize,
152    /// Half-edges classified as `Spurious`.
153    pub spurious_edges: usize,
154    /// Triangles with exactly one Diagonal edge and two Grid edges
155    /// (i.e. eligible to merge into a quad if their buddy agrees).
156    pub triangles_mergeable: usize,
157    /// Triangles with all three edges classified as Grid (suggests
158    /// the triangle spans more than one cell — the paper's failure
159    /// mode at very low view angles).
160    pub triangles_all_grid: usize,
161    /// Triangles with multiple Diagonal edges (ambiguous).
162    pub triangles_multi_diag: usize,
163    /// Triangles with at least one Spurious edge.
164    pub triangles_has_spurious: usize,
165    /// Triangle pairs merged into quads.
166    pub quads_merged: usize,
167    /// Quads surviving topological + geometric filtering.
168    pub quads_kept: usize,
169    /// Connected quad-mesh components after walking.
170    pub components: usize,
171}
172
173/// Top-level result.
174#[derive(Clone, Debug, Default)]
175pub struct TopologicalGrid {
176    pub components: Vec<TopologicalComponent>,
177    pub diagnostics: TopologicalStats,
178}
179
180/// Errors from [`build_grid_topological`].
181#[derive(Clone, Copy, Debug, thiserror::Error)]
182pub enum TopologicalError {
183    /// The position and axes slices have mismatched length.
184    #[error("positions and axes must be the same length (got {positions} and {axes})")]
185    LengthMismatch { positions: usize, axes: usize },
186    /// Fewer than three usable corners survived the pre-filter, which is
187    /// the minimum for Delaunay triangulation.
188    #[error("not enough usable corners ({usable}) for Delaunay triangulation")]
189    NotEnoughCorners { usable: usize },
190}
191
192/// Build labelled grid components from corners + per-corner axes.
193///
194/// Returns one [`TopologicalComponent`] per connected component of the
195/// surviving quad mesh. Use [`crate::component_merge`] to attempt to
196/// merge components into a single grid.
197#[cfg_attr(
198    feature = "tracing",
199    tracing::instrument(
200        level = "info",
201        skip_all,
202        fields(num_corners = positions.len()),
203    )
204)]
205pub fn build_grid_topological(
206    positions: &[Point2<f32>],
207    axes: &[[AxisHint; 2]],
208    params: &TopologicalParams,
209) -> Result<TopologicalGrid, TopologicalError> {
210    if positions.len() != axes.len() {
211        return Err(TopologicalError::LengthMismatch {
212            positions: positions.len(),
213            axes: axes.len(),
214        });
215    }
216    let mut stats = TopologicalStats {
217        corners_in: positions.len(),
218        ..Default::default()
219    };
220
221    // Pre-filter corners: at least one axis must have a usable sigma.
222    let usable_mask: Vec<bool> = axes
223        .iter()
224        .map(|a| a[0].sigma < params.max_axis_sigma_rad || a[1].sigma < params.max_axis_sigma_rad)
225        .collect();
226    stats.corners_used = usable_mask.iter().filter(|&&b| b).count();
227    if stats.corners_used < 3 {
228        return Err(TopologicalError::NotEnoughCorners {
229            usable: stats.corners_used,
230        });
231    }
232
233    // Delaunay over ALL positions (cheaper than rebuilding indices).
234    // Spurious corners simply produce spurious edges and are dropped later.
235    let triangulation = delaunay::triangulate(positions);
236    stats.triangles = triangulation.triangles.len() / 3;
237
238    // Classify every half-edge.
239    let edge_kinds =
240        classify::classify_all_edges(positions, axes, &usable_mask, &triangulation, params);
241    for &k in &edge_kinds {
242        match k {
243            EdgeKind::Grid => stats.grid_edges += 1,
244            EdgeKind::Diagonal => stats.diagonal_edges += 1,
245            EdgeKind::Spurious => stats.spurious_edges += 1,
246        }
247    }
248
249    // Per-triangle classification breakdown — tells us at a glance
250    // whether the merge step is starving on noise (all-spurious),
251    // saturated by perspective foreshortening (all-grid spans cells),
252    // or jammed by ambiguity (≥ 2 diagonals).
253    for t in 0..stats.triangles {
254        let mut g = 0;
255        let mut d = 0;
256        let mut sp = 0;
257        for k in 0..3 {
258            match edge_kinds[3 * t + k] {
259                EdgeKind::Grid => g += 1,
260                EdgeKind::Diagonal => d += 1,
261                EdgeKind::Spurious => sp += 1,
262            }
263        }
264        if sp > 0 {
265            stats.triangles_has_spurious += 1;
266        } else if d == 1 && g == 2 {
267            stats.triangles_mergeable += 1;
268        } else if d == 0 && g == 3 {
269            stats.triangles_all_grid += 1;
270        } else if d >= 2 {
271            stats.triangles_multi_diag += 1;
272        }
273    }
274
275    // Merge triangle pairs sharing a diagonal whose other edges are grid.
276    let raw_quads = quads::merge_triangle_pairs(&triangulation, &edge_kinds, positions);
277    stats.quads_merged = raw_quads.len();
278
279    // Topological + geometric filtering.
280    let kept_quads = topo_filter::filter_quads(&raw_quads, positions, params);
281    stats.quads_kept = kept_quads.len();
282
283    // Flood-fill labels per connected component.
284    let components = walk::label_components(&kept_quads, params.min_quads_per_component);
285    stats.components = components.len();
286
287    Ok(TopologicalGrid {
288        components,
289        diagnostics: stats,
290    })
291}