projective_grid/square/seed.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
11use crate::homography::homography_from_4pt;
12use nalgebra::Point2;
13
14pub use crate::square::grow::Seed;
15
16/// Output of a seed finder: the 2×2 quad plus a cell size derived
17/// directly from the seed's own edge lengths.
18#[derive(Clone, Copy, Debug)]
19pub struct SeedOutput {
20 pub seed: Seed,
21 pub cell_size: f32,
22}
23
24/// Grid positions of the four seed corners in the "canonical" seed
25/// quad layout used by [`crate::square::grow::bfs_grow`] and the
26/// chessboard detector: `A = (0, 0), B = (1, 0), C = (0, 1),
27/// D = (1, 1)`.
28pub const SEED_QUAD_GRID: [(i32, i32); 4] = [(0, 0), (1, 0), (0, 1), (1, 1)];
29
30/// Detect the 2× spacing mislabel, where a 2×2 quad has accidentally
31/// been picked across a 2-cell step of the real grid (e.g., real
32/// positions `(0,0), (2,0), (0,2), (2,2)` mislabelled as the
33/// canonical seed).
34///
35/// Returns `true` when any of the seed's edge midpoints or its
36/// parallelogram center coincides (within `midpoint_tol_rel ×
37/// cell_size` of pixel distance) with a real corner **other than
38/// the seed quad itself**. Such coincidences indicate the seed
39/// has skipped a true intermediate corner — a classic 2× spacing
40/// bug.
41///
42/// `positions` — every corner's pixel position.
43/// `seed_quad` — the four corner indices in the seed.
44/// `cell_size` — the seed's own estimated cell size.
45/// `midpoint_tol_rel` — tolerance as a fraction of `cell_size`.
46/// `on_edge_midpoint` — candidate indices to test against the four
47/// edge midpoints. Pattern-specific callers
48/// pass the set that should NOT be near the
49/// midpoints (e.g., "Swapped"-label corners
50/// for a chessboard).
51/// `on_parallelogram_center` — candidate indices to test against
52/// the parallelogram center `(0.5,
53/// 0.5)`. Pattern-specific callers
54/// pass the set that should NOT be
55/// near the center (e.g., "Canonical"-
56/// label corners for a chessboard).
57pub fn seed_has_midpoint_violation(
58 positions: &[Point2<f32>],
59 seed_quad: [usize; 4],
60 cell_size: f32,
61 midpoint_tol_rel: f32,
62 on_edge_midpoint: &[usize],
63 on_parallelogram_center: &[usize],
64) -> bool {
65 let tol = midpoint_tol_rel * cell_size;
66 let tol_sq = tol * tol;
67
68 let [a, b, c, d] = seed_quad;
69 let pa = positions[a];
70 let pb = positions[b];
71 let pc = positions[c];
72 let pd = positions[d];
73
74 let midpoints = [
75 Point2::from((pa.coords + pb.coords) * 0.5),
76 Point2::from((pa.coords + pc.coords) * 0.5),
77 Point2::from((pb.coords + pd.coords) * 0.5),
78 Point2::from((pc.coords + pd.coords) * 0.5),
79 ];
80 for mp in midpoints {
81 if any_within(positions, on_edge_midpoint, mp, tol_sq, &seed_quad) {
82 return true;
83 }
84 }
85
86 let center = Point2::from((pa.coords + pd.coords) * 0.5);
87 if any_within(
88 positions,
89 on_parallelogram_center,
90 center,
91 tol_sq,
92 &seed_quad,
93 ) {
94 return true;
95 }
96 false
97}
98
99fn any_within(
100 positions: &[Point2<f32>],
101 candidates: &[usize],
102 target: Point2<f32>,
103 tol_sq: f32,
104 exclude: &[usize],
105) -> bool {
106 for &idx in candidates {
107 if exclude.contains(&idx) {
108 continue;
109 }
110 let p = positions[idx];
111 let dx = p.x - target.x;
112 let dy = p.y - target.y;
113 if dx * dx + dy * dy <= tol_sq {
114 return true;
115 }
116 }
117 false
118}
119
120/// Compute a per-seed cell-size estimate: the mean of the four
121/// seed-edge lengths. This is the self-consistent cell size that the
122/// chessboard detector carries through downstream stages; the
123/// advantage over a global cross-cluster distance mode is that the
124/// seed's own geometry is always consistent with the value it emits.
125///
126/// Returns `None` when the seed has zero-length edges (degenerate).
127pub fn seed_cell_size(positions: &[Point2<f32>], seed: Seed) -> Option<f32> {
128 let p = |i: usize| positions[i];
129 let edges = [
130 (p(seed.a) - p(seed.b)).norm(),
131 (p(seed.a) - p(seed.c)).norm(),
132 (p(seed.b) - p(seed.d)).norm(),
133 (p(seed.c) - p(seed.d)).norm(),
134 ];
135 if edges.iter().any(|&e| e <= 0.0) {
136 return None;
137 }
138 Some(edges.iter().sum::<f32>() * 0.25)
139}
140
141/// Reassemble the 4 seed corner indices into the flat array layout
142/// used by homography helpers (grid corner order: TL, TR, BR, BL).
143pub fn seed_homography(
144 positions: &[Point2<f32>],
145 seed: Seed,
146) -> Option<crate::homography::Homography> {
147 let img_pts = [
148 positions[seed.a],
149 positions[seed.b],
150 positions[seed.d],
151 positions[seed.c],
152 ];
153 let grid_pts = [
154 Point2::new(0.0, 0.0),
155 Point2::new(1.0, 0.0),
156 Point2::new(1.0, 1.0),
157 Point2::new(0.0, 1.0),
158 ];
159 homography_from_4pt(&grid_pts, &img_pts)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn positions_4(a: (f32, f32), b: (f32, f32), c: (f32, f32), d: (f32, f32)) -> Vec<Point2<f32>> {
167 vec![
168 Point2::new(a.0, a.1),
169 Point2::new(b.0, b.1),
170 Point2::new(c.0, c.1),
171 Point2::new(d.0, d.1),
172 ]
173 }
174
175 #[test]
176 fn seed_cell_size_unit_square() {
177 let p = positions_4((0.0, 0.0), (10.0, 0.0), (0.0, 10.0), (10.0, 10.0));
178 let s = seed_cell_size(
179 &p,
180 Seed {
181 a: 0,
182 b: 1,
183 c: 2,
184 d: 3,
185 },
186 )
187 .unwrap();
188 assert!((s - 10.0).abs() < 1e-4);
189 }
190
191 #[test]
192 fn midpoint_violation_detects_2x_mislabel() {
193 // Seed thinks the quad is (0,0),(1,0),(0,1),(1,1) at cell
194 // size 10, but an intermediate corner (e.g. swapped at
195 // (0.5, 0) in seed-space = (5, 0) in pixels) exists in the
196 // cloud.
197 let positions = vec![
198 Point2::new(0.0, 0.0), // 0 = A
199 Point2::new(20.0, 0.0), // 1 = B (2× spacing!)
200 Point2::new(0.0, 20.0), // 2 = C
201 Point2::new(20.0, 20.0), // 3 = D
202 Point2::new(10.0, 0.0), // 4 = intermediate swapped corner
203 ];
204 let violation = seed_has_midpoint_violation(
205 &positions,
206 [0, 1, 2, 3],
207 20.0,
208 0.3,
209 &[4], // "swapped" candidates
210 &[], // no canonical to check center
211 );
212 assert!(violation);
213 }
214
215 #[test]
216 fn midpoint_violation_absent_on_clean_seed() {
217 let positions = positions_4((0.0, 0.0), (10.0, 0.0), (0.0, 10.0), (10.0, 10.0));
218 let violation = seed_has_midpoint_violation(&positions, [0, 1, 2, 3], 10.0, 0.3, &[], &[]);
219 assert!(!violation);
220 }
221}