Skip to main content

projective_grid/square/seed/
mod.rs

1//! Seed-finder data types + pure-geometry helpers.
2//!
3//! The chessboard detector's seed search in
4//! `calib_targets_chessboard::seed` is still pattern-specific (it
5//! relies on chessboard parity, the Canonical/Swapped cluster split,
6//! and the axis-slot-swap invariant). The pieces that are pure
7//! geometry — the four-corner seed quad, its edge / cell-size bundle,
8//! and the 2×-spacing "midpoint violation" rejection — live here so
9//! non-calibration consumers can reuse them.
10//!
11//! The pattern-agnostic seed *finder* (KD-tree search, axis classification,
12//! parallelogram completion) lives in the [`finder`] submodule so the
13//! data types and the search logic have separate homes.
14
15pub mod finder;
16
17use crate::homography::homography_from_4pt;
18use nalgebra::Point2;
19
20/// Seed quad: corner indices at grid cells `(0, 0), (1, 0), (0, 1),
21/// `(1, 1)` respectively.
22///
23/// Created by the seed finder and consumed by
24/// [`crate::square::grow::bfs_grow`].
25#[derive(Clone, Copy, Debug)]
26pub struct Seed {
27    pub a: usize,
28    pub b: usize,
29    pub c: usize,
30    pub d: usize,
31}
32
33/// Output of a seed finder: the 2×2 quad plus a cell size derived
34/// directly from the seed's own edge lengths.
35#[derive(Clone, Copy, Debug)]
36pub struct SeedOutput {
37    pub seed: Seed,
38    pub cell_size: f32,
39}
40
41/// Grid positions of the four seed corners in the "canonical" seed
42/// quad layout used by [`crate::square::grow::bfs_grow`] and the
43/// chessboard detector: `A = (0, 0), B = (1, 0), C = (0, 1),
44/// D = (1, 1)`.
45pub const SEED_QUAD_GRID: [(i32, i32); 4] = [(0, 0), (1, 0), (0, 1), (1, 1)];
46
47/// Detect the 2× spacing mislabel, where a 2×2 quad has accidentally
48/// been picked across a 2-cell step of the real grid (e.g., real
49/// positions `(0,0), (2,0), (0,2), (2,2)` mislabelled as the
50/// canonical seed).
51///
52/// Returns `true` when any of the seed's edge midpoints or its
53/// parallelogram center coincides (within `midpoint_tol_rel ×
54/// cell_size` of pixel distance) with a real corner **other than
55/// the seed quad itself**. Such coincidences indicate the seed
56/// has skipped a true intermediate corner — a classic 2× spacing
57/// bug.
58///
59/// `positions` — every corner's pixel position.
60/// `seed_quad` — the four corner indices in the seed.
61/// `cell_size` — the seed's own estimated cell size.
62/// `midpoint_tol_rel` — tolerance as a fraction of `cell_size`.
63/// `on_edge_midpoint` — pattern-specific candidate indices to test
64///                       against the four edge midpoints. Callers
65///                       pass the set whose presence at a midpoint
66///                       is a stronger violation signal (e.g.,
67///                       "Swapped"-label corners for a chessboard,
68///                       which would lie at midpoints if the seed
69///                       skipped a Swapped row).
70/// `on_parallelogram_center` — pattern-specific candidate indices to
71///                              test against the parallelogram center
72///                              `(0.5, 0.5)`. Same convention as
73///                              `on_edge_midpoint`.
74/// `all_positions` — full-population fallback indices to test against
75///                    midpoints AND center, regardless of cluster
76///                    label. Catches 2× / sqrt(2)× / general N× cases
77///                    where the intermediate corner failed Stage-3
78///                    clustering and isn't in the pattern-specific
79///                    lists. Pass `&[]` to disable.
80pub fn seed_has_midpoint_violation(
81    positions: &[Point2<f32>],
82    seed_quad: [usize; 4],
83    cell_size: f32,
84    midpoint_tol_rel: f32,
85    on_edge_midpoint: &[usize],
86    on_parallelogram_center: &[usize],
87    all_positions: &[usize],
88) -> bool {
89    let tol = midpoint_tol_rel * cell_size;
90    let tol_sq = tol * tol;
91
92    let [a, b, c, d] = seed_quad;
93    let pa = positions[a];
94    let pb = positions[b];
95    let pc = positions[c];
96    let pd = positions[d];
97
98    let midpoints = [
99        Point2::from((pa.coords + pb.coords) * 0.5),
100        Point2::from((pa.coords + pc.coords) * 0.5),
101        Point2::from((pb.coords + pd.coords) * 0.5),
102        Point2::from((pc.coords + pd.coords) * 0.5),
103    ];
104
105    // The `all_positions` fallback uses a tighter tolerance (half the
106    // pattern-aware tolerance) because it admits arbitrary corners,
107    // including marker-internal ones that may legitimately fall near
108    // a grid-cell midpoint without indicating a 2×-spacing seed bug.
109    // The pattern-aware lists are already cluster-filtered, so they
110    // can use the wider tolerance.
111    let fallback_tol = tol * 0.5;
112    let fallback_tol_sq = fallback_tol * fallback_tol;
113
114    for mp in midpoints {
115        if any_within(positions, on_edge_midpoint, mp, tol_sq, &seed_quad) {
116            return true;
117        }
118        if any_within(positions, all_positions, mp, fallback_tol_sq, &seed_quad) {
119            return true;
120        }
121    }
122
123    let center = Point2::from((pa.coords + pd.coords) * 0.5);
124    if any_within(
125        positions,
126        on_parallelogram_center,
127        center,
128        tol_sq,
129        &seed_quad,
130    ) {
131        return true;
132    }
133    if any_within(
134        positions,
135        all_positions,
136        center,
137        fallback_tol_sq,
138        &seed_quad,
139    ) {
140        return true;
141    }
142    false
143}
144
145fn any_within(
146    positions: &[Point2<f32>],
147    candidates: &[usize],
148    target: Point2<f32>,
149    tol_sq: f32,
150    exclude: &[usize],
151) -> bool {
152    for &idx in candidates {
153        if exclude.contains(&idx) {
154            continue;
155        }
156        let p = positions[idx];
157        let dx = p.x - target.x;
158        let dy = p.y - target.y;
159        if dx * dx + dy * dy <= tol_sq {
160            return true;
161        }
162    }
163    false
164}
165
166/// Compute a per-seed cell-size estimate: the mean of the four
167/// seed-edge lengths. This is the self-consistent cell size that the
168/// chessboard detector carries through downstream stages; the
169/// advantage over a global cross-cluster distance mode is that the
170/// seed's own geometry is always consistent with the value it emits.
171///
172/// Returns `None` when the seed has zero-length edges (degenerate).
173pub fn seed_cell_size(positions: &[Point2<f32>], seed: Seed) -> Option<f32> {
174    let p = |i: usize| positions[i];
175    let edges = [
176        (p(seed.a) - p(seed.b)).norm(),
177        (p(seed.a) - p(seed.c)).norm(),
178        (p(seed.b) - p(seed.d)).norm(),
179        (p(seed.c) - p(seed.d)).norm(),
180    ];
181    if edges.iter().any(|&e| e <= 0.0) {
182        return None;
183    }
184    Some(edges.iter().sum::<f32>() * 0.25)
185}
186
187/// Reassemble the 4 seed corner indices into the flat array layout
188/// used by homography helpers (grid corner order: TL, TR, BR, BL).
189pub fn seed_homography(
190    positions: &[Point2<f32>],
191    seed: Seed,
192) -> Option<crate::homography::Homography> {
193    let img_pts = [
194        positions[seed.a],
195        positions[seed.b],
196        positions[seed.d],
197        positions[seed.c],
198    ];
199    let grid_pts = [
200        Point2::new(0.0, 0.0),
201        Point2::new(1.0, 0.0),
202        Point2::new(1.0, 1.0),
203        Point2::new(0.0, 1.0),
204    ];
205    homography_from_4pt(&grid_pts, &img_pts)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn positions_4(a: (f32, f32), b: (f32, f32), c: (f32, f32), d: (f32, f32)) -> Vec<Point2<f32>> {
213        vec![
214            Point2::new(a.0, a.1),
215            Point2::new(b.0, b.1),
216            Point2::new(c.0, c.1),
217            Point2::new(d.0, d.1),
218        ]
219    }
220
221    #[test]
222    fn seed_cell_size_unit_square() {
223        let p = positions_4((0.0, 0.0), (10.0, 0.0), (0.0, 10.0), (10.0, 10.0));
224        let s = seed_cell_size(
225            &p,
226            Seed {
227                a: 0,
228                b: 1,
229                c: 2,
230                d: 3,
231            },
232        )
233        .unwrap();
234        assert!((s - 10.0).abs() < 1e-4);
235    }
236
237    #[test]
238    fn midpoint_violation_detects_2x_mislabel() {
239        // Seed thinks the quad is (0,0),(1,0),(0,1),(1,1) at cell
240        // size 10, but an intermediate corner (e.g. swapped at
241        // (0.5, 0) in seed-space = (5, 0) in pixels) exists in the
242        // cloud.
243        let positions = vec![
244            Point2::new(0.0, 0.0),   // 0 = A
245            Point2::new(20.0, 0.0),  // 1 = B (2× spacing!)
246            Point2::new(0.0, 20.0),  // 2 = C
247            Point2::new(20.0, 20.0), // 3 = D
248            Point2::new(10.0, 0.0),  // 4 = intermediate swapped corner
249        ];
250        let violation = seed_has_midpoint_violation(
251            &positions,
252            [0, 1, 2, 3],
253            20.0,
254            0.3,
255            &[4], // "swapped" candidates
256            &[],  // no canonical to check center
257            &[],  // no all-position fallback (already caught by swapped)
258        );
259        assert!(violation);
260    }
261
262    #[test]
263    fn midpoint_violation_absent_on_clean_seed() {
264        let positions = positions_4((0.0, 0.0), (10.0, 0.0), (0.0, 10.0), (10.0, 10.0));
265        let violation =
266            seed_has_midpoint_violation(&positions, [0, 1, 2, 3], 10.0, 0.3, &[], &[], &[]);
267        assert!(!violation);
268    }
269
270    #[test]
271    fn midpoint_violation_detects_2x_via_unclustered_fallback() {
272        // Same shape as `midpoint_violation_detects_2x_mislabel` but
273        // the intermediate corner failed Stage-3 clustering, so it
274        // is NOT in the chessboard-specific `on_edge_midpoint` set.
275        // The new `all_positions` fallback must still flag it.
276        let positions = vec![
277            Point2::new(0.0, 0.0),
278            Point2::new(20.0, 0.0),
279            Point2::new(0.0, 20.0),
280            Point2::new(20.0, 20.0),
281            Point2::new(10.0, 0.0), // intermediate, not clustered
282        ];
283        let all = vec![0, 1, 2, 3, 4];
284        let violation = seed_has_midpoint_violation(
285            &positions,
286            [0, 1, 2, 3],
287            20.0,
288            0.3,
289            &[], // no clustered candidates
290            &[],
291            &all,
292        );
293        assert!(violation);
294    }
295}