Skip to main content

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, &params.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, &params.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 &params.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}