projective_grid/square/detect.rs
1//! End-to-end square-grid recovery pipeline.
2//!
3//! Composes the four geometric stages — seed → grow → extend → fill,
4//! plus optional post-grow validation — under a single user-facing
5//! function, [`detect_square_grid`]. Each stage is an existing
6//! pattern-agnostic primitive; this module wires them together and
7//! returns one [`SquareGridDetection`] carrying the labelled map plus
8//! per-stage diagnostics.
9//!
10//! ## Pattern hooks
11//!
12//! The caller supplies two validators:
13//!
14//! - [`SeedQuadValidator`] decides which 2×2 cells are admissible
15//! seeds (per-pattern parity, axis-slot rules, etc.).
16//! - [`GrowValidator`] decides which corners are admissible
17//! attachments during BFS grow, boundary extension, and hole fill.
18//!
19//! Everything else (KD-tree neighbour search, edge-ratio gate,
20//! parallelogram closure, homography fit, line residual check) is
21//! pattern-agnostic and runs identically across chessboard, ChArUco,
22//! puzzleboard, and any other square-lattice target.
23
24use std::collections::HashMap;
25use std::collections::HashSet;
26
27use nalgebra::{Point2, Vector2};
28
29use crate::square::extension::{
30 extend_via_global_homography, extend_via_local_homography, ExtensionParams, ExtensionStats,
31 LocalExtensionParams,
32};
33use crate::square::fill::{fill_grid_holes, FillParams, FillStats};
34use crate::square::grow::{
35 bfs_grow, Admit, FillEdgeCtx, GrowParams, GrowResult, GrowValidator, LabelledNeighbour,
36};
37use crate::square::seed::finder::{find_quad, SeedQuadParams, SeedQuadValidator};
38use crate::square::seed::{Seed, SeedOutput};
39use crate::square::validate::{validate, LabelledEntry, ValidationParams, ValidationResult};
40use crate::topological::AxisEstimate;
41
42/// Boundary-extension strategy for [`detect_square_grid`].
43///
44/// Selects which (if any) homography-extension pass runs after BFS
45/// grow. The two functional variants wrap the pipeline's two
46/// homography-extension strategies — see
47/// [`extend_via_global_homography`] and [`extend_via_local_homography`]
48/// for the precision / recall trade-off; [`Self::Disabled`] skips the
49/// stage entirely.
50///
51/// `#[non_exhaustive]`: future strategies may be added.
52#[non_exhaustive]
53#[derive(Clone, Copy, Debug)]
54pub enum ExtensionStrategy {
55 /// Skip boundary extension. The labelled set returned by BFS-grow
56 /// stands as-is.
57 Disabled,
58 /// Fit one global homography over the whole labelled set and
59 /// extrapolate from it. See [`extend_via_global_homography`].
60 Global(ExtensionParams),
61 /// Fit a per-candidate local homography from the nearest labelled
62 /// corners. See [`extend_via_local_homography`].
63 Local(LocalExtensionParams),
64}
65
66impl Default for ExtensionStrategy {
67 fn default() -> Self {
68 // Matches the historical default — a single global-H pass with
69 // the upstream module's own defaults.
70 ExtensionStrategy::Global(ExtensionParams::default())
71 }
72}
73
74/// Combined tuning knobs for [`detect_square_grid`].
75///
76/// The post-grow fill and validate sub-stages are each gated on their
77/// `Option<>` field (passing `None` skips them); boundary extension is
78/// selected by the [`ExtensionStrategy`] enum. Default constructor runs
79/// every stage with the upstream module's own defaults.
80#[non_exhaustive]
81#[derive(Clone, Debug)]
82pub struct SquareGridParams {
83 /// Seed-finder gates. A 2×2 seed must be found before any
84 /// other stage runs.
85 pub seed: SeedQuadParams,
86 /// BFS-grow tuning. See [`GrowParams`].
87 pub grow: GrowParams,
88 /// Boundary-extension strategy. See [`ExtensionStrategy`];
89 /// [`ExtensionStrategy::Disabled`] skips the stage.
90 pub extension: ExtensionStrategy,
91 /// Interior-hole + line-extrapolation fill pass. `None` skips
92 /// the stage.
93 pub fill: Option<FillParams>,
94 /// Post-grow line + local-H residual checks. When `Some`, every
95 /// corner flagged by [`validate`] is dropped from the final
96 /// labelled set. `None` skips the stage and returns the raw
97 /// post-fill labels.
98 pub validate: Option<ValidationParams>,
99}
100
101impl Default for SquareGridParams {
102 fn default() -> Self {
103 Self {
104 seed: SeedQuadParams::default(),
105 grow: GrowParams::default(),
106 extension: ExtensionStrategy::default(),
107 fill: Some(FillParams::default()),
108 validate: Some(ValidationParams::default()),
109 }
110 }
111}
112
113/// Final outcome of [`detect_square_grid`].
114///
115/// `labelled` is the canonical product — every entry maps a `(i, j)`
116/// grid cell to a corner index in the caller's `positions` slice.
117/// The bounding box of the labelled set is rebased so the minimum
118/// `(i, j)` is `(0, 0)`.
119#[non_exhaustive]
120#[derive(Debug)]
121pub struct SquareGridDetection {
122 /// `(i, j) → corner_idx` map for every recovered corner.
123 pub labelled: HashMap<(i32, i32), usize>,
124 /// Inverse map: `corner_idx → (i, j)`.
125 pub by_corner: HashMap<usize, (i32, i32)>,
126 /// Pixel-space unit vector along the grid's `i` direction,
127 /// inferred from the seed quad. Useful for downstream overlays.
128 pub axis_i: Vector2<f32>,
129 /// Pixel-space unit vector along the grid's `j` direction.
130 pub axis_j: Vector2<f32>,
131 /// Cell size in pixels, taken from the seed quad's mean edge
132 /// length. Approximate under non-uniform perspective; downstream
133 /// metric work should refit a homography from the labelled set.
134 pub cell_size: f32,
135 /// Per-stage diagnostic counters.
136 pub stats: SquareGridStats,
137}
138
139/// Per-stage counters returned alongside [`SquareGridDetection`].
140#[non_exhaustive]
141#[derive(Debug, Default)]
142pub struct SquareGridStats {
143 /// Corner indices of the chosen seed quad, in `[A, B, C, D]`
144 /// order. `None` indicates seed finding failed (in which case
145 /// [`detect_square_grid`] returns `None` and this struct is not
146 /// produced).
147 pub seed: Option<[usize; 4]>,
148 /// Number of corners attached during the BFS grow stage,
149 /// excluding the four seed corners.
150 pub grown: usize,
151 /// Boundary-extension diagnostics. `None` when extension was
152 /// disabled via `SquareGridParams::extension = None`.
153 pub extension: Option<ExtensionStats>,
154 /// Hole-fill diagnostics. `None` when fill was disabled or the
155 /// labelled set was empty before fill ran.
156 pub fill: Option<FillStats>,
157 /// Validation outcome. `None` when validation was disabled.
158 pub validation: Option<ValidationResult>,
159 /// Number of corners dropped from `labelled` because validation
160 /// flagged them. Always `0` when validation was disabled.
161 pub dropped_by_validation: usize,
162}
163
164/// User-facing entry point for the square-lattice grid pipeline.
165///
166/// Runs five stages end-to-end:
167///
168/// 1. **Seed** — [`find_quad`] picks a 2×2 cell whose four corners
169/// pass every `seed_validator` gate plus the pattern-agnostic
170/// geometric checks (axis alignment, edge-ratio match,
171/// parallelogram closure, no midpoint violation). The seed's
172/// mean edge length defines `cell_size` for the downstream
173/// stages.
174/// 2. **Grow** — [`bfs_grow`] walks the lattice from the seed,
175/// attaching the nearest admissible corner at each unlabelled
176/// cardinal neighbour. Pattern rules flow through
177/// `grow_validator`.
178/// 3. **Extend** — fits a homography to the labelled set and
179/// extrapolates the labelled boundary outward. Selected by
180/// `params.extension`: [`ExtensionStrategy::Global`] runs
181/// [`extend_via_global_homography`],
182/// [`ExtensionStrategy::Local`] runs
183/// [`extend_via_local_homography`], and
184/// [`ExtensionStrategy::Disabled`] skips the stage.
185/// 4. **Fill** — [`fill_grid_holes`] sweeps the bounding box for
186/// cells with ≥ 2 cardinal labelled neighbours and attaches
187/// candidates that survive a per-cell predictor. Gated on
188/// `params.fill` being `Some`.
189/// 5. **Validate** — [`validate`] applies a line-fit + local-H
190/// residual check. Corners in the resulting blacklist are
191/// dropped from the labelled map. Gated on `params.validate`
192/// being `Some`.
193///
194/// Returns `None` only when no seed is found; once a seed exists,
195/// every later stage degrades gracefully (a too-small labelled set
196/// causes extension and fill to no-op rather than fail).
197///
198/// # Example
199///
200/// See `crates/projective-grid/tests/square_pipeline_smoke.rs` for a
201/// synthetic perspective-warped fixture end-to-end.
202#[cfg_attr(
203 feature = "tracing",
204 tracing::instrument(
205 level = "info",
206 skip_all,
207 fields(num_corners = positions.len()),
208 )
209)]
210pub fn detect_square_grid<S, G>(
211 positions: &[Point2<f32>],
212 seed_validator: &S,
213 grow_validator: &G,
214 params: &SquareGridParams,
215) -> Option<SquareGridDetection>
216where
217 S: SeedQuadValidator,
218 G: GrowValidator,
219{
220 let mut stats = SquareGridStats::default();
221
222 let SeedOutput { seed, cell_size } = find_quad(seed_validator, ¶ms.seed)?;
223 stats.seed = Some([seed.a, seed.b, seed.c, seed.d]);
224
225 let mut grow_res: GrowResult =
226 bfs_grow(positions, seed, cell_size, ¶ms.grow, grow_validator);
227 // 4 corners come from the seed; the rest are BFS attachments.
228 stats.grown = grow_res.labelled.len().saturating_sub(4);
229
230 match ¶ms.extension {
231 ExtensionStrategy::Disabled => {}
232 ExtensionStrategy::Global(extension_params) => {
233 let ext = extend_via_global_homography(
234 positions,
235 &mut grow_res,
236 cell_size,
237 extension_params,
238 grow_validator,
239 );
240 stats.extension = Some(ext);
241 }
242 ExtensionStrategy::Local(extension_params) => {
243 let ext = extend_via_local_homography(
244 positions,
245 &mut grow_res,
246 cell_size,
247 extension_params,
248 grow_validator,
249 );
250 stats.extension = Some(ext);
251 }
252 }
253
254 if let Some(fill_params) = params.fill.as_ref() {
255 let fill = fill_grid_holes(
256 positions,
257 &mut grow_res,
258 cell_size,
259 fill_params,
260 grow_validator,
261 );
262 stats.fill = Some(fill);
263 }
264
265 if let Some(validate_params) = params.validate.as_ref() {
266 let entries: Vec<LabelledEntry> = grow_res
267 .labelled
268 .iter()
269 .map(|(&(i, j), &idx)| LabelledEntry {
270 idx,
271 pixel: positions[idx],
272 grid: (i, j),
273 })
274 .collect();
275 let result = validate(&entries, cell_size, validate_params);
276 for &idx in &result.blacklist {
277 if let Some(&cell) = grow_res.by_corner.get(&idx) {
278 grow_res.labelled.remove(&cell);
279 }
280 grow_res.by_corner.remove(&idx);
281 }
282 stats.dropped_by_validation = result.blacklist.len();
283 stats.validation = Some(result);
284 }
285
286 Some(SquareGridDetection {
287 labelled: grow_res.labelled,
288 by_corner: grow_res.by_corner,
289 axis_i: grow_res.axis_i,
290 axis_j: grow_res.axis_j,
291 cell_size,
292 stats,
293 })
294}
295
296/// Tuning knobs for [`detect_square_grid_all`].
297#[non_exhaustive]
298#[derive(Clone, Copy, Debug)]
299pub struct MultiComponentParams {
300 /// Maximum number of components to peel off before stopping.
301 /// Default: `4`.
302 pub max_components: usize,
303 /// Stop once a component has fewer than this many labelled
304 /// corners (typically because the remaining unconsumed corners
305 /// form noise rather than a real component). Default: `4` —
306 /// the seed quad's four corners are the floor.
307 pub min_corners_per_component: usize,
308}
309
310impl Default for MultiComponentParams {
311 fn default() -> Self {
312 Self {
313 max_components: 4,
314 min_corners_per_component: 4,
315 }
316 }
317}
318
319/// Multi-component variant of [`detect_square_grid`].
320///
321/// Peels off one component at a time: after each successful call
322/// to [`detect_square_grid`], the indices of every labelled corner
323/// are marked consumed, and the next iteration's validators see a
324/// reduced eligibility set. Returns every component in detection
325/// order.
326///
327/// Pass the result through [`crate::merge_components_local`] if
328/// you want to reunite spatially-adjacent components into one
329/// labelled grid (typical for partially-occluded boards).
330///
331/// # Stopping conditions
332///
333/// - No more seeds can be found in the unconsumed pool.
334/// - `multi.max_components` reached.
335/// - The most recent component had fewer than
336/// `multi.min_corners_per_component` corners.
337pub fn detect_square_grid_all<S, G>(
338 positions: &[Point2<f32>],
339 seed_validator: &S,
340 grow_validator: &G,
341 params: &SquareGridParams,
342 multi: &MultiComponentParams,
343) -> Vec<SquareGridDetection>
344where
345 S: SeedQuadValidator,
346 G: GrowValidator,
347{
348 let mut consumed: HashSet<usize> = HashSet::new();
349 let mut detections: Vec<SquareGridDetection> = Vec::new();
350
351 while detections.len() < multi.max_components {
352 let wrapped_seed = ExcludeConsumedSeed {
353 inner: seed_validator,
354 consumed: &consumed,
355 };
356 let wrapped_grow = ExcludeConsumedGrow {
357 inner: grow_validator,
358 consumed: &consumed,
359 };
360 let Some(det) = detect_square_grid(positions, &wrapped_seed, &wrapped_grow, params) else {
361 break;
362 };
363 if det.labelled.len() < multi.min_corners_per_component {
364 break;
365 }
366 for (_, &corner_idx) in det.labelled.iter() {
367 consumed.insert(corner_idx);
368 }
369 detections.push(det);
370 }
371
372 detections
373}
374
375// ---------------------------------------------------------------------------
376// Validator wrappers used by `detect_square_grid_all` to exclude already-
377// consumed corner indices from a fresh detection pass.
378// ---------------------------------------------------------------------------
379
380struct ExcludeConsumedSeed<'a, S> {
381 inner: &'a S,
382 consumed: &'a HashSet<usize>,
383}
384
385impl<'a, S: SeedQuadValidator> SeedQuadValidator for ExcludeConsumedSeed<'a, S> {
386 fn position(&self, idx: usize) -> Point2<f32> {
387 self.inner.position(idx)
388 }
389 fn axes(&self, idx: usize) -> [AxisEstimate; 2] {
390 self.inner.axes(idx)
391 }
392 fn a_candidates(&self) -> Vec<usize> {
393 self.inner
394 .a_candidates()
395 .into_iter()
396 .filter(|i| !self.consumed.contains(i))
397 .collect()
398 }
399 fn bc_candidates(&self) -> Vec<usize> {
400 self.inner
401 .bc_candidates()
402 .into_iter()
403 .filter(|i| !self.consumed.contains(i))
404 .collect()
405 }
406 fn edge_ok(&self, from: usize, to: usize, axis_tol_rad: f32) -> bool {
407 self.inner.edge_ok(from, to, axis_tol_rad)
408 }
409 fn has_midpoint_violation(&self, seed: Seed, cell_size: f32) -> bool {
410 self.inner.has_midpoint_violation(seed, cell_size)
411 }
412}
413
414struct ExcludeConsumedGrow<'a, G> {
415 inner: &'a G,
416 consumed: &'a HashSet<usize>,
417}
418
419impl<'a, G: GrowValidator> GrowValidator for ExcludeConsumedGrow<'a, G> {
420 fn is_eligible(&self, idx: usize) -> bool {
421 !self.consumed.contains(&idx) && self.inner.is_eligible(idx)
422 }
423 fn required_label_at(&self, i: i32, j: i32) -> Option<u8> {
424 self.inner.required_label_at(i, j)
425 }
426 fn label_of(&self, idx: usize) -> Option<u8> {
427 self.inner.label_of(idx)
428 }
429 fn accept_candidate(
430 &self,
431 idx: usize,
432 at: (i32, i32),
433 prediction: Point2<f32>,
434 neighbours: &[LabelledNeighbour],
435 ) -> Admit {
436 if self.consumed.contains(&idx) {
437 return Admit::Reject;
438 }
439 self.inner.accept_candidate(idx, at, prediction, neighbours)
440 }
441 fn edge_ok(
442 &self,
443 candidate_idx: usize,
444 neighbour_idx: usize,
445 at_candidate: (i32, i32),
446 at_neighbour: (i32, i32),
447 ) -> bool {
448 self.inner
449 .edge_ok(candidate_idx, neighbour_idx, at_candidate, at_neighbour)
450 }
451 fn eligible_for_fill(&self, idx: usize) -> bool {
452 !self.consumed.contains(&idx) && self.inner.eligible_for_fill(idx)
453 }
454 fn fill_edge_ok(&self, ctx: FillEdgeCtx<'_>) -> bool {
455 self.inner.fill_edge_ok(ctx)
456 }
457}