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