Skip to main content

projective_grid/
global_step.rs

1//! Automatic global cell-size estimation for a 2D corner cloud.
2//!
3//! Given the positions of detected corners, finds the dominant pairwise
4//! "nearest-neighbor step" — the most common spatial distance between adjacent
5//! points. Pattern-agnostic: any grid-like layout (chessboard, ChArUco,
6//! PuzzleBoard, hex) produces a peaked distribution of nearest distances, and
7//! this module recovers its mode.
8//!
9//! Used by the graph-build layer to size absolute thresholds (KD-tree radius,
10//! validator step bounds) automatically per-frame, so callers no longer need
11//! to supply `min_spacing_pix` / `max_spacing_pix` / `step_fallback_pix` that
12//! have to match the image scale.
13//!
14//! # Algorithm
15//!
16//! 1. Build a KD-tree over the input positions.
17//! 2. Per corner, take the closest non-self distance. Collect into a vector.
18//! 3. Fit a 1-D mean-shift mode on the collected distances, seeded from the
19//!    25th, 50th, and 75th percentile. Track the density (count of samples
20//!    within bandwidth) at each mode; return the densest mode.
21//!
22//! # Dual-scale datasets (ChArUco, etc.)
23//!
24//! When marker-internal corners coexist with board corners, the nearest-
25//! distance distribution becomes bimodal: a sub-mode at the marker spacing
26//! (~0.2× board step) and a dominant mode at the board step. Because we use
27//! *nearest-neighbor* distance (not all pairs), each marker-internal corner
28//! contributes only its distance to its closest neighbor — often another
29//! marker-internal corner, not a board corner — so the two modes remain
30//! distinguishable and the board mode retains the higher total support on
31//! typical real-world captures.
32
33use crate::Float;
34use kiddo::{KdTree, SquaredEuclidean};
35use nalgebra::{Point2, RealField};
36
37/// Estimated global grid step.
38#[derive(Clone, Copy, Debug)]
39pub struct GlobalStepEstimate<F: Float = f32> {
40    /// Dominant nearest-neighbor distance, in pixels.
41    pub cell_size: F,
42    /// Density at the returned mode: number of input points whose nearest-
43    /// neighbor distance lies within `±bandwidth` of `cell_size`. Useful for
44    /// downstream sanity checks.
45    pub support: u32,
46    /// Total points whose nearest-neighbor distance was collected (= input
47    /// length, minus isolated points that have no reachable neighbors in the
48    /// KD-tree search).
49    pub sample_count: u32,
50    /// `support / sample_count`, saturated to `[0, 1]`. A confident
51    /// unimodal cloud sits near 1.0; noisy or multi-scale clouds sit lower.
52    pub confidence: F,
53}
54
55/// Tuning knobs for [`estimate_global_cell_size`].
56#[derive(Clone, Copy, Debug)]
57pub struct GlobalStepParams<F: Float = f32> {
58    /// Bandwidth for mean-shift, expressed as a fraction of the seed value.
59    /// Defaults to `0.15` (±15 % of the candidate step).
60    pub bandwidth_rel: F,
61    /// Maximum mean-shift iterations per seed before accepting the current
62    /// center. Defaults to `20`.
63    pub max_iters: u32,
64    /// Convergence threshold: mean-shift stops when the center update falls
65    /// below `bandwidth × convergence_rel`. Defaults to `1e-3`.
66    pub convergence_rel: F,
67}
68
69impl<F: Float> Default for GlobalStepParams<F> {
70    fn default() -> Self {
71        Self {
72            bandwidth_rel: F::from_subset(&0.15),
73            max_iters: 20,
74            convergence_rel: F::from_subset(&1e-3),
75        }
76    }
77}
78
79/// Estimate a single dominant grid-cell size from a cloud of 2D corners.
80///
81/// Returns `None` when the cloud is too small (≤ 1 point) or degenerate
82/// (all nearest-neighbor distances are zero).
83pub fn estimate_global_cell_size<F: Float + kiddo::float::kdtree::Axis>(
84    positions: &[Point2<F>],
85    params: &GlobalStepParams<F>,
86) -> Option<GlobalStepEstimate<F>> {
87    if positions.len() < 2 {
88        return None;
89    }
90
91    let coords: Vec<[F; 2]> = positions.iter().map(|p| [p.x, p.y]).collect();
92    let tree: KdTree<F, 2> = (&coords).into();
93
94    let mut nn_distances: Vec<F> = Vec::with_capacity(positions.len());
95    for (i, p) in positions.iter().enumerate() {
96        let hits = tree.nearest_n::<SquaredEuclidean>(&[p.x, p.y], 2);
97        for hit in hits {
98            let j = hit.item as usize;
99            if j == i {
100                continue;
101            }
102            let d2 = hit.distance;
103            if d2 > F::zero() {
104                nn_distances.push(d2.sqrt());
105            }
106            break;
107        }
108    }
109
110    if nn_distances.is_empty() {
111        return None;
112    }
113    nn_distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
114
115    let sample_count = nn_distances.len() as u32;
116    let seeds = [
117        percentile_sorted(&nn_distances, F::from_subset(&0.25)),
118        percentile_sorted(&nn_distances, F::from_subset(&0.5)),
119        percentile_sorted(&nn_distances, F::from_subset(&0.75)),
120    ];
121
122    // Score each mode candidate by `support × cell_size`. This breaks ties in
123    // favour of larger cell sizes when a minority sub-mode from marker-internal
124    // corners has comparable support — we always want the lattice step, not
125    // the within-cell step.
126    let mut best: Option<(F, u32, F)> = None; // (mode, support, score)
127    for seed in seeds {
128        if let Some((mode, support)) = mean_shift_mode(&nn_distances, seed, params) {
129            if support == 0 {
130                continue;
131            }
132            let score = F::from_subset(&(support as f64)) * mode;
133            if best.map(|b: (F, u32, F)| score > b.2).unwrap_or(true) {
134                best = Some((mode, support, score));
135            }
136        }
137    }
138    let (cell_size, support, _) = best?;
139    let confidence = RealField::max(
140        RealField::min(
141            F::from_subset(&(support as f64)) / F::from_subset(&(sample_count as f64)),
142            F::one(),
143        ),
144        F::zero(),
145    );
146
147    Some(GlobalStepEstimate {
148        cell_size,
149        support,
150        sample_count,
151        confidence,
152    })
153}
154
155fn percentile_sorted<F: Float>(sorted: &[F], q: F) -> F {
156    let len = sorted.len();
157    if len == 0 {
158        return F::zero();
159    }
160    let idx_f = q * F::from_subset(&((len - 1) as f64));
161    let idx = idx_f.floor();
162    let i = idx.to_subset().unwrap_or(0.0) as usize;
163    let i = i.min(len - 1);
164    sorted[i]
165}
166
167fn mean_shift_mode<F: Float>(
168    sorted: &[F],
169    seed: F,
170    params: &GlobalStepParams<F>,
171) -> Option<(F, u32)> {
172    if seed <= F::zero() {
173        return None;
174    }
175    let bandwidth = seed * params.bandwidth_rel;
176    if bandwidth <= F::zero() {
177        return Some((seed, 0));
178    }
179    let convergence = bandwidth * params.convergence_rel;
180
181    let mut center = seed;
182    for _ in 0..params.max_iters {
183        let mut sum = F::zero();
184        let mut weight = F::zero();
185        let mut count_in_band = 0u32;
186        for &v in sorted {
187            let diff = v - center;
188            if diff.abs() > bandwidth {
189                continue;
190            }
191            let t = diff / bandwidth;
192            let w = F::one() - t * t;
193            let w = if w < F::zero() { F::zero() } else { w };
194            if w > F::zero() {
195                sum += v * w;
196                weight += w;
197                count_in_band += 1;
198            }
199        }
200        if weight <= F::zero() {
201            return Some((center, 0));
202        }
203        let next = sum / weight;
204        if (next - center).abs() <= convergence {
205            return Some((next, count_in_band));
206        }
207        center = next;
208    }
209    // Did not converge: fall back to the last center and its in-band count.
210    let mut in_band = 0u32;
211    for &v in sorted {
212        if (v - center).abs() <= bandwidth {
213            in_band += 1;
214        }
215    }
216    Some((center, in_band))
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn rectangular_grid(rows: u32, cols: u32, spacing: f32) -> Vec<Point2<f32>> {
224        let mut out = Vec::new();
225        for j in 0..rows {
226            for i in 0..cols {
227                out.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
228            }
229        }
230        out
231    }
232
233    #[test]
234    fn recovers_regular_grid_spacing() {
235        let params = GlobalStepParams::<f32>::default();
236        for &spacing in &[10.0_f32, 24.0, 50.0] {
237            let pts = rectangular_grid(5, 5, spacing);
238            let est = estimate_global_cell_size(&pts, &params).expect("estimate");
239            assert!(
240                (est.cell_size - spacing).abs() / spacing < 0.02,
241                "spacing {spacing}: estimate {} off >2 %",
242                est.cell_size
243            );
244            assert!(est.confidence > 0.9, "confidence {}", est.confidence);
245        }
246    }
247
248    #[test]
249    fn sparse_noise_does_not_drag_mode() {
250        // 5×5 board at spacing=24 plus a small sprinkling of noise corners
251        // offset by random non-grid distances. The dominant mode should stay
252        // on the board spacing.
253        let mut pts = rectangular_grid(5, 5, 24.0);
254        // 4 noise points at positions that do not land on the 24 px lattice.
255        for (dx, dy) in [(6.0, 9.0), (43.0, 9.0), (9.0, 43.0), (81.0, 81.0)] {
256            pts.push(Point2::new(dx, dy));
257        }
258        let est =
259            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
260        assert!(
261            (est.cell_size - 24.0).abs() < 2.0,
262            "expected board step ~24 but got {}",
263            est.cell_size
264        );
265        assert!(est.support >= 10); // most of the 25 board corners contribute.
266    }
267
268    #[test]
269    fn bimodal_density_weights_by_cell_size() {
270        // Two disjoint grids: a 4×4 "small" grid at spacing=4 and a 4×4
271        // "big" grid at spacing=40. Equal point counts. The `support ×
272        // cell_size` score biases us toward the larger spacing.
273        let mut pts = Vec::new();
274        for j in 0..4 {
275            for i in 0..4 {
276                pts.push(Point2::new(i as f32 * 4.0, j as f32 * 4.0));
277            }
278        }
279        for j in 0..4 {
280            for i in 0..4 {
281                pts.push(Point2::new(
282                    1000.0 + i as f32 * 40.0,
283                    1000.0 + j as f32 * 40.0,
284                ));
285            }
286        }
287        let est =
288            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
289        assert!(
290            (est.cell_size - 40.0).abs() < 4.0,
291            "expected larger-grid cell ~40 but got {}",
292            est.cell_size
293        );
294    }
295
296    #[test]
297    fn too_small_input_returns_none() {
298        let pts: Vec<Point2<f32>> = vec![];
299        assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
300        let pts = vec![Point2::new(0.0, 0.0)];
301        assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
302    }
303
304    #[test]
305    fn degenerate_duplicate_points_are_skipped() {
306        let pts = vec![
307            Point2::new(0.0, 0.0),
308            Point2::new(0.0, 0.0),
309            Point2::new(10.0, 0.0),
310            Point2::new(0.0, 10.0),
311            Point2::new(10.0, 10.0),
312        ];
313        let est =
314            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
315        assert!((est.cell_size - 10.0).abs() < 1.0);
316    }
317
318    #[test]
319    fn mild_jitter_still_recovers_mode() {
320        // 5×5 grid at 24 px spacing with 5 % positional jitter.
321        let pts: Vec<Point2<f32>> = rectangular_grid(5, 5, 24.0)
322            .into_iter()
323            .enumerate()
324            .map(|(i, p)| {
325                let jitter_x = ((i * 17 % 7) as f32 - 3.0) * 0.4;
326                let jitter_y = ((i * 23 % 9) as f32 - 4.0) * 0.4;
327                Point2::new(p.x + jitter_x, p.y + jitter_y)
328            })
329            .collect();
330        let est =
331            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
332        assert!(
333            (est.cell_size - 24.0).abs() < 2.0,
334            "expected ~24 got {}",
335            est.cell_size
336        );
337    }
338}