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}