Skip to main content

chess_corners_core/detect/
merge.rs

1//! Corner merging: spatial-grid and naive-fallback implementations.
2use super::Corner;
3
4#[cfg(feature = "tracing")]
5use tracing::instrument;
6
7/// Merge corners within a given radius, keeping the strongest response.
8///
9/// Uses a uniform spatial grid with cell size equal to `radius`, so any
10/// two corners within `radius` of each other land in the same or
11/// neighbouring cells. For `N` input corners the expected cost is
12/// `O(N)` with small constants (vs the naive `O(N²)` pairwise scan),
13/// which matters on Radon-detected frames where `N` can run into the
14/// thousands.
15///
16/// The output is order-equivalent to the naive scan: when an incoming
17/// corner is within `radius` of multiple existing corners, the
18/// **first-seen** existing corner wins the merge — same as the
19/// previous implementation. When the incoming corner is stronger, it
20/// replaces that existing corner's position in-place.
21#[cfg_attr(feature = "tracing", instrument(level = "info", skip(corners)))]
22pub fn merge_corners_simple(corners: &mut Vec<Corner>, radius: f32) -> Vec<Corner> {
23    let n = corners.len();
24    if n == 0 {
25        return Vec::new();
26    }
27    if !radius.is_finite() || radius <= 0.0 {
28        return std::mem::take(corners);
29    }
30
31    let r2 = radius * radius;
32
33    // Bounding box of the input corner cloud.
34    let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY);
35    let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY);
36    for c in corners.iter() {
37        if c.x < min_x {
38            min_x = c.x;
39        }
40        if c.y < min_y {
41            min_y = c.y;
42        }
43        if c.x > max_x {
44            max_x = c.x;
45        }
46        if c.y > max_y {
47            max_y = c.y;
48        }
49    }
50
51    // Guard: any non-finite coordinate or zero-extent cloud falls back
52    // to the naive scan rather than building a degenerate grid.
53    if !(min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()) {
54        return merge_corners_naive(corners, r2);
55    }
56
57    let cell = radius;
58    let inv_cell = 1.0 / cell;
59    let grid_w = ((max_x - min_x) * inv_cell).floor() as usize + 1;
60    let grid_h = ((max_y - min_y) * inv_cell).floor() as usize + 1;
61
62    // Each grid cell stores indices into `out`. `u32` is plenty for
63    // realistic candidate counts and halves the per-cell footprint
64    // vs `usize` on 64-bit targets.
65    let mut grid: Vec<Vec<u32>> = vec![Vec::new(); grid_w * grid_h];
66    let mut out: Vec<Corner> = Vec::with_capacity(n);
67
68    let cell_of = |c: &Corner| -> (usize, usize) {
69        let gx = (((c.x - min_x) * inv_cell) as usize).min(grid_w - 1);
70        let gy = (((c.y - min_y) * inv_cell) as usize).min(grid_h - 1);
71        (gx, gy)
72    };
73
74    for c in corners.drain(..) {
75        let (gx, gy) = cell_of(&c);
76
77        // Scan the 3×3 cell neighbourhood for the first existing
78        // corner within `radius`. Iteration order matches the naive
79        // scan (cells visited in (y, x) order; within a cell, indices
80        // are stored in insertion order, which is the order
81        // corners were pushed into `out`).
82        let y0 = gy.saturating_sub(1);
83        let y1 = (gy + 1).min(grid_h - 1);
84        let x0 = gx.saturating_sub(1);
85        let x1 = (gx + 1).min(grid_w - 1);
86
87        let mut hit: Option<usize> = None;
88        let mut best_idx = u32::MAX;
89        for ny in y0..=y1 {
90            for nx in x0..=x1 {
91                for &idx in &grid[ny * grid_w + nx] {
92                    if idx >= best_idx {
93                        continue;
94                    }
95                    let i = idx as usize;
96                    let dx = c.x - out[i].x;
97                    let dy = c.y - out[i].y;
98                    if dx * dx + dy * dy <= r2 {
99                        best_idx = idx;
100                        hit = Some(i);
101                    }
102                }
103            }
104        }
105
106        if let Some(i) = hit {
107            if c.strength > out[i].strength {
108                let (old_gx, old_gy) = cell_of(&out[i]);
109                out[i] = c;
110                let (new_gx, new_gy) = cell_of(&out[i]);
111                if old_gx != new_gx || old_gy != new_gy {
112                    let id = i as u32;
113                    let old_cell = old_gy * grid_w + old_gx;
114                    let new_cell = new_gy * grid_w + new_gx;
115                    grid[old_cell].retain(|&j| j != id);
116                    grid[new_cell].push(id);
117                }
118            }
119        } else {
120            let new_idx = out.len() as u32;
121            let (gx, gy) = cell_of(&c);
122            out.push(c);
123            grid[gy * grid_w + gx].push(new_idx);
124        }
125    }
126
127    out
128}
129
130/// Naive O(N²) merge, used only as a fallback when the spatial-grid
131/// path can't build a valid bounding box (e.g. NaN / Inf coordinates).
132fn merge_corners_naive(corners: &mut Vec<Corner>, r2: f32) -> Vec<Corner> {
133    let mut out: Vec<Corner> = Vec::new();
134    'outer: for c in corners.drain(..) {
135        for o in &mut out {
136            let dx = c.x - o.x;
137            let dy = c.y - o.y;
138            if dx * dx + dy * dy <= r2 {
139                if c.strength > o.strength {
140                    *o = c;
141                }
142                continue 'outer;
143            }
144        }
145        out.push(c);
146    }
147    out
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn merge_corners_prefers_stronger_entries() {
156        let mut corners = vec![
157            Corner {
158                x: 10.0,
159                y: 10.0,
160                strength: 1.0,
161            },
162            Corner {
163                x: 11.0,
164                y: 11.0,
165                strength: 5.0,
166            },
167            Corner {
168                x: 20.0,
169                y: 20.0,
170                strength: 3.0,
171            },
172        ];
173        let merged = merge_corners_simple(&mut corners, 2.5);
174        assert_eq!(merged.len(), 2);
175        assert!(merged.iter().any(|c| (c.x - 11.0).abs() < 1e-6
176            && (c.y - 11.0).abs() < 1e-6
177            && (c.strength - 5.0).abs() < 1e-6));
178        assert!(merged
179            .iter()
180            .any(|c| (c.x - 20.0).abs() < 1e-6 && (c.y - 20.0).abs() < 1e-6));
181    }
182
183    /// Cheap LCG so we can run a deterministic randomized equivalence
184    /// test without pulling in `rand` as a dev-dep.
185    fn lcg(state: &mut u64) -> u32 {
186        *state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
187        (*state >> 33) as u32
188    }
189
190    fn rand_corners(seed: u64, n: usize, span: f32) -> Vec<Corner> {
191        let mut state = seed.wrapping_add(0x9E3779B97F4A7C15);
192        let mut out = Vec::with_capacity(n);
193        for _ in 0..n {
194            let rx = lcg(&mut state) as f32 / u32::MAX as f32;
195            let ry = lcg(&mut state) as f32 / u32::MAX as f32;
196            let rs = lcg(&mut state) as f32 / u32::MAX as f32;
197            out.push(Corner {
198                x: rx * span,
199                y: ry * span,
200                strength: rs * 100.0,
201            });
202        }
203        out
204    }
205
206    fn corners_eq_unordered(a: &[Corner], b: &[Corner]) -> bool {
207        if a.len() != b.len() {
208            return false;
209        }
210        let mut used = vec![false; b.len()];
211        'outer: for ca in a.iter() {
212            for (i, cb) in b.iter().enumerate() {
213                if used[i] {
214                    continue;
215                }
216                if (ca.x - cb.x).abs() < 1e-5
217                    && (ca.y - cb.y).abs() < 1e-5
218                    && (ca.strength - cb.strength).abs() < 1e-3
219                {
220                    used[i] = true;
221                    continue 'outer;
222                }
223            }
224            return false;
225        }
226        true
227    }
228
229    #[test]
230    fn merge_corners_grid_matches_naive_scan() {
231        // Equivalence check: the spatial-grid `merge_corners_simple`
232        // must produce the same kept-corner set as the naive O(N²)
233        // pairwise scan on randomized inputs. Order is allowed to
234        // differ — both implementations are deterministic but the
235        // grid visits cells in a different order.
236        for seed in [1u64, 7, 42, 123, 1729] {
237            for &n in &[16usize, 64, 256, 1024] {
238                for &radius in &[1.0f32, 2.5, 6.0] {
239                    let corners = rand_corners(seed, n, 50.0);
240                    let mut a = corners.clone();
241                    let mut b = corners.clone();
242                    let r2 = radius * radius;
243                    let merged_grid = merge_corners_simple(&mut a, radius);
244                    let merged_naive = merge_corners_naive(&mut b, r2);
245                    assert!(
246                        corners_eq_unordered(&merged_grid, &merged_naive),
247                        "mismatch at seed={seed}, n={n}, radius={radius}: grid={} naive={}",
248                        merged_grid.len(),
249                        merged_naive.len(),
250                    );
251                }
252            }
253        }
254    }
255
256    #[test]
257    fn merge_corners_handles_empty_and_zero_radius() {
258        let mut empty: Vec<Corner> = Vec::new();
259        let merged = merge_corners_simple(&mut empty, 1.0);
260        assert!(merged.is_empty());
261
262        let mut few = vec![
263            Corner {
264                x: 1.0,
265                y: 1.0,
266                strength: 1.0,
267            },
268            Corner {
269                x: 1.5,
270                y: 1.5,
271                strength: 2.0,
272            },
273        ];
274        // Zero radius is a no-op merge — every input survives.
275        let merged = merge_corners_simple(&mut few, 0.0);
276        assert_eq!(merged.len(), 2);
277    }
278}