Skip to main content

checkerboard_calibrate/chessboard/
order.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Ordering the board graph into a corner lattice — second half of stage 3.
5//!
6//! After [`super::link_quads`] builds the quad adjacency graph, the quads must
7//! be laid out on the board's integer corner lattice so the inner corners can
8//! be read off in a consistent row-major order. This mirrors the role of
9//! OpenCV's `orderQuadCorners` / `orderFoundConnectedQuads`.
10//!
11//! Steps:
12//!   1. [`order_all_corners`] puts each quad's four corners (and their neighbor
13//!      links) into a consistent rotational order.
14//!   2. [`assign_grid`] propagates integer lattice coordinates across a
15//!      connected component by BFS over the neighbor links.
16//!   3. [`ordered_inner_corners`] reads out the shared (inner) corners sorted
17//!      row-major.
18//!
19//! Validated here with synthetic boards; end-to-end parity with OpenCV is
20//! checked later against the detection golden.
21
22use std::collections::HashMap;
23
24use super::link::LinkedQuad;
25
26/// Lattice offsets of a unit cell's four corners, in the same rotational order
27/// produced by sorting corners by angle about their centroid (see
28/// [`order_quad_corners`]). For the centroid-relative angle convention this is
29/// the cyclic sequence top-left, top-right, bottom-right, bottom-left.
30const CELL: [(i32, i32); 4] = [(0, 0), (1, 0), (1, 1), (0, 1)];
31
32/// Reorder a quad's corners into a consistent rotational order (by angle about
33/// the centroid), permuting the `neighbors` links the same way so each
34/// `neighbors[i]` still refers to `corners[i]`.
35pub fn order_quad_corners(quad: &mut LinkedQuad) {
36    let cx = quad.corners.iter().map(|c| c.0).sum::<f32>() / 4.0;
37    let cy = quad.corners.iter().map(|c| c.1).sum::<f32>() / 4.0;
38
39    let mut idx = [0usize, 1, 2, 3];
40    idx.sort_by(|&a, &b| {
41        let aa = (quad.corners[a].1 - cy).atan2(quad.corners[a].0 - cx);
42        let ab = (quad.corners[b].1 - cy).atan2(quad.corners[b].0 - cx);
43        aa.partial_cmp(&ab).unwrap()
44    });
45
46    let corners = quad.corners;
47    let neighbors = quad.neighbors;
48    for (new_i, &old_i) in idx.iter().enumerate() {
49        quad.corners[new_i] = corners[old_i];
50        quad.neighbors[new_i] = neighbors[old_i];
51    }
52}
53
54/// Order the corners of every quad in place.
55pub fn order_all_corners(quads: &mut [LinkedQuad]) {
56    for q in quads.iter_mut() {
57        order_quad_corners(q);
58    }
59}
60
61/// Integer lattice coordinate of each of a quad's four corners.
62pub type QuadGrid = [(i32, i32); 4];
63
64/// Propagate integer board-lattice coordinates across one connected component.
65///
66/// The seed quad is placed at the unit cell `[(0,0),(1,0),(1,1),(0,1)]`; each
67/// neighbor is positioned so its shared corner matches the already-assigned
68/// lattice coordinate, exploiting the fact that every quad shares the same
69/// rotational corner order (so a quad's corners are always a translated unit
70/// cell in lattice space). Returns the per-quad lattice coordinates keyed by
71/// quad index.
72///
73/// Corners must already be ordered ([`order_all_corners`]).
74pub fn assign_grid(quads: &[LinkedQuad], component: &[usize]) -> HashMap<usize, QuadGrid> {
75    let mut coords: HashMap<usize, QuadGrid> = HashMap::new();
76    if component.is_empty() {
77        return coords;
78    }
79
80    let seed = component[0];
81    coords.insert(seed, CELL);
82    let mut stack = vec![seed];
83
84    while let Some(a) = stack.pop() {
85        let a_grid = coords[&a];
86        for (ci, &shared) in a_grid.iter().enumerate() {
87            let Some(b) = quads[a].neighbors[ci] else {
88                continue;
89            };
90            if coords.contains_key(&b) {
91                continue;
92            }
93            // The corner of `b` shared with `a`.
94            let cj = quads[b]
95                .neighbors
96                .iter()
97                .position(|&n| n == Some(a))
98                .expect("neighbor link is reciprocal");
99
100            // Origin so that b.corners[cj] lands on the shared lattice point.
101            let ox = shared.0 - CELL[cj].0;
102            let oy = shared.1 - CELL[cj].1;
103            let b_grid: QuadGrid = std::array::from_fn(|k| (ox + CELL[k].0, oy + CELL[k].1));
104
105            coords.insert(b, b_grid);
106            stack.push(b);
107        }
108    }
109    coords
110}
111
112/// Every lattice point referenced by at least one quad corner, mapped to its
113/// averaged position and the number of referencing quad corners.
114pub fn corner_lattice(
115    quads: &[LinkedQuad],
116    coords: &HashMap<usize, QuadGrid>,
117) -> HashMap<(i32, i32), ((f32, f32), usize)> {
118    // lattice point -> (summed position, count)
119    let mut acc: HashMap<(i32, i32), ((f64, f64), usize)> = HashMap::new();
120    for (&qi, grid) in coords {
121        for (k, &cell) in grid.iter().enumerate() {
122            let entry = acc.entry(cell).or_insert(((0.0, 0.0), 0));
123            entry.0.0 += quads[qi].corners[k].0 as f64;
124            entry.0.1 += quads[qi].corners[k].1 as f64;
125            entry.1 += 1;
126        }
127    }
128
129    acc.into_iter()
130        .map(|(lattice, ((sx, sy), count))| {
131            let n = count as f64;
132            (lattice, (((sx / n) as f32, (sy / n) as f32), count))
133        })
134        .collect()
135}
136
137/// Inner board corners with their lattice coordinates (unordered).
138///
139/// Inner corners are the lattice points referenced by two or more quads (the
140/// interior points where black squares meet); outer-boundary corners, touched
141/// by a single quad, are excluded. Linked corners are already snapped to a
142/// shared position, so all references to a lattice point coincide.
143pub fn inner_corner_lattice(
144    quads: &[LinkedQuad],
145    coords: &HashMap<usize, QuadGrid>,
146) -> Vec<((i32, i32), (f32, f32))> {
147    corner_lattice(quads, coords)
148        .into_iter()
149        .filter(|(_, (_, count))| *count >= 2)
150        .map(|(lattice, (pos, _))| (lattice, pos))
151        .collect()
152}
153
154/// Read out the inner board corners from an assigned grid, sorted row-major
155/// (by lattice row then column).
156pub fn ordered_inner_corners(
157    quads: &[LinkedQuad],
158    coords: &HashMap<usize, QuadGrid>,
159) -> Vec<(f32, f32)> {
160    let mut inner = inner_corner_lattice(quads, coords);
161    // Row-major: lattice y (row) then x (column).
162    inner.sort_by_key(|a| (a.0.1, a.0.0));
163    inner.into_iter().map(|(_, pt)| pt).collect()
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::chessboard::link::{connected_components, link_quads};
170    use crate::chessboard::quad::Quad;
171
172    fn linked(corners: [(f32, f32); 4]) -> LinkedQuad {
173        LinkedQuad {
174            corners,
175            neighbors: [None; 4],
176            edge_len: 0.0,
177        }
178    }
179
180    fn dist2(a: (f32, f32), b: (f32, f32)) -> f32 {
181        (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)
182    }
183
184    #[test]
185    fn orders_into_rotational_sequence() {
186        let mut q = linked([(10.0, 10.0), (0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]);
187        order_quad_corners(&mut q);
188        for i in 0..4 {
189            approx::assert_abs_diff_eq!(
190                dist2(q.corners[i], q.corners[(i + 1) % 4]),
191                100.0,
192                epsilon = 1e-3
193            );
194        }
195        approx::assert_abs_diff_eq!(dist2(q.corners[0], q.corners[2]), 200.0, epsilon = 1e-3);
196    }
197
198    #[test]
199    fn permutes_neighbors_with_corners() {
200        let mut q = linked([(10.0, 10.0), (0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]);
201        q.neighbors[0] = Some(42);
202        order_quad_corners(&mut q);
203        let pos = q.corners.iter().position(|&c| c == (10.0, 10.0)).unwrap();
204        assert_eq!(q.neighbors[pos], Some(42));
205        assert_eq!(q.neighbors.iter().filter(|n| n.is_some()).count(), 1);
206    }
207
208    /// Build the black squares of a checkerboard with `cells` x `cells` cells,
209    /// each `side` pixels, returning the quads. Black cells are those with
210    /// `(cx + cy)` even.
211    fn black_square_board(cells: i32, side: i32) -> Vec<Quad> {
212        let mut quads = Vec::new();
213        for cy in 0..cells {
214            for cx in 0..cells {
215                if (cx + cy) % 2 != 0 {
216                    continue;
217                }
218                let x = cx * side;
219                let y = cy * side;
220                quads.push(Quad {
221                    corners: [(x, y), (x + side, y), (x + side, y + side), (x, y + side)],
222                });
223            }
224        }
225        quads
226    }
227
228    #[test]
229    fn recovers_inner_corner_lattice() {
230        // 4x4 cells -> interior lattice points x,y in 1..=3 -> 3x3 = 9 inner corners.
231        let side = 20;
232        let quads = black_square_board(4, side);
233        let mut linked = link_quads(&quads);
234        order_all_corners(&mut linked);
235
236        let comps = connected_components(&linked);
237        assert_eq!(comps.len(), 1, "black squares should form one component");
238
239        let grid = assign_grid(&linked, &comps[0]);
240        let corners = ordered_inner_corners(&linked, &grid);
241
242        // Expected: interior lattice points (x,y) in 1..=3, row-major, scaled.
243        let mut expected = Vec::new();
244        for y in 1..=3 {
245            for x in 1..=3 {
246                expected.push(((x * side) as f32, (y * side) as f32));
247            }
248        }
249        assert_eq!(corners.len(), 9, "got {corners:?}");
250        for (got, want) in corners.iter().zip(expected.iter()) {
251            approx::assert_abs_diff_eq!(got.0, want.0, epsilon = 1e-3);
252            approx::assert_abs_diff_eq!(got.1, want.1, epsilon = 1e-3);
253        }
254    }
255}