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#[non_exhaustive]
39#[derive(Clone, Copy, Debug)]
40pub struct GlobalStepEstimate<F: Float = f32> {
41    /// Dominant nearest-neighbor distance, in pixels.
42    pub cell_size: F,
43    /// Density at the returned mode: number of input points whose nearest-
44    /// neighbor distance lies within `±bandwidth` of `cell_size`. Useful for
45    /// downstream sanity checks.
46    pub support: usize,
47    /// Total points whose nearest-neighbor distance was collected (= input
48    /// length, minus isolated points that have no reachable neighbors in the
49    /// KD-tree search).
50    pub sample_count: usize,
51    /// `support / sample_count`, saturated to `[0, 1]`. A confident
52    /// unimodal cloud sits near 1.0; noisy or multi-scale clouds sit lower.
53    pub confidence: F,
54    /// `true` when at least two of the three percentile-seeded mean-shift
55    /// runs converged to *different* modes (separated by more than a
56    /// bandwidth). Signals the underlying nearest-neighbour distance
57    /// distribution is multi-modal — typical for ChArUco frames where
58    /// marker-internal corners coexist with board corners. Downstream
59    /// callers may want to fall back to a self-consistent seed estimate
60    /// when this is set.
61    pub multimodal: bool,
62}
63
64/// Tuning knobs for [`estimate_global_cell_size`].
65#[derive(Clone, Copy, Debug)]
66pub struct GlobalStepParams<F: Float = f32> {
67    /// Bandwidth for mean-shift, expressed as a fraction of the seed value.
68    /// Defaults to `0.15` (±15 % of the candidate step).
69    pub bandwidth_rel: F,
70    /// Maximum mean-shift iterations per seed before accepting the current
71    /// center. Defaults to `20`.
72    pub max_iters: u32,
73    /// Convergence threshold: mean-shift stops when the center update falls
74    /// below `bandwidth × convergence_rel`. Defaults to `1e-3`.
75    pub convergence_rel: F,
76}
77
78impl<F: Float> Default for GlobalStepParams<F> {
79    fn default() -> Self {
80        Self {
81            bandwidth_rel: F::from_subset(&0.15),
82            max_iters: 20,
83            convergence_rel: F::from_subset(&1e-3),
84        }
85    }
86}
87
88/// Estimate a single dominant grid-cell size from a cloud of 2D corners.
89///
90/// Returns `None` when the cloud is too small (≤ 1 point) or degenerate
91/// (all nearest-neighbor distances are zero).
92#[cfg_attr(
93    feature = "tracing",
94    tracing::instrument(
95        level = "debug",
96        skip_all,
97        fields(num_points = positions.len()),
98    )
99)]
100pub fn estimate_global_cell_size<F: Float + kiddo::float::kdtree::Axis>(
101    positions: &[Point2<F>],
102    params: &GlobalStepParams<F>,
103) -> Option<GlobalStepEstimate<F>> {
104    if positions.len() < 2 {
105        return None;
106    }
107
108    let coords: Vec<[F; 2]> = positions.iter().map(|p| [p.x, p.y]).collect();
109    let tree: KdTree<F, 2> = (&coords).into();
110
111    let mut nn_distances: Vec<F> = Vec::with_capacity(positions.len());
112    for (i, p) in positions.iter().enumerate() {
113        let hits = tree.nearest_n::<SquaredEuclidean>(&[p.x, p.y], 2);
114        for hit in hits {
115            let j = hit.item as usize;
116            if j == i {
117                continue;
118            }
119            let d2 = hit.distance;
120            if d2 > F::zero() {
121                nn_distances.push(d2.sqrt());
122            }
123            break;
124        }
125    }
126
127    if nn_distances.is_empty() {
128        return None;
129    }
130    nn_distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
131
132    let sample_count = nn_distances.len();
133    let seeds = [
134        percentile_sorted(&nn_distances, F::from_subset(&0.25)),
135        percentile_sorted(&nn_distances, F::from_subset(&0.5)),
136        percentile_sorted(&nn_distances, F::from_subset(&0.75)),
137    ];
138
139    // Score each mode candidate by `support × cell_size`. This breaks ties in
140    // favour of larger cell sizes when a minority sub-mode from marker-internal
141    // corners has comparable support — we always want the lattice step, not
142    // the within-cell step.
143    let mut best: Option<(F, usize, F)> = None; // (mode, support, score)
144    let mut converged_modes: Vec<F> = Vec::new();
145    for seed in seeds {
146        if let Some((mode, support_u32)) = mean_shift_mode(&nn_distances, seed, params) {
147            if support_u32 == 0 {
148                continue;
149            }
150            let support = support_u32 as usize;
151            converged_modes.push(mode);
152            let score = F::from_subset(&(support as f64)) * mode;
153            if best.map(|b: (F, usize, F)| score > b.2).unwrap_or(true) {
154                best = Some((mode, support, score));
155            }
156        }
157    }
158    let (cell_size, support, _) = best?;
159    let confidence = RealField::max(
160        RealField::min(
161            F::from_subset(&(support as f64)) / F::from_subset(&(sample_count as f64)),
162            F::one(),
163        ),
164        F::zero(),
165    );
166
167    // Multimodality: at least two seeds converged to modes that
168    // differ by more than one bandwidth. Bandwidth here is computed
169    // from the winning `cell_size`.
170    let bandwidth = cell_size * params.bandwidth_rel;
171    let multimodal = converged_modes.iter().any(|&m| {
172        let diff: F = m - cell_size;
173        let abs_diff: F = if diff < F::zero() { -diff } else { diff };
174        abs_diff > bandwidth
175    });
176
177    Some(GlobalStepEstimate {
178        cell_size,
179        support,
180        sample_count,
181        confidence,
182        multimodal,
183    })
184}
185
186fn percentile_sorted<F: Float>(sorted: &[F], q: F) -> F {
187    let len = sorted.len();
188    if len == 0 {
189        return F::zero();
190    }
191    let idx_f = q * F::from_subset(&((len - 1) as f64));
192    let idx = idx_f.floor();
193    let i = idx.to_subset().unwrap_or(0.0) as usize;
194    let i = i.min(len - 1);
195    sorted[i]
196}
197
198fn mean_shift_mode<F: Float>(
199    sorted: &[F],
200    seed: F,
201    params: &GlobalStepParams<F>,
202) -> Option<(F, u32)> {
203    if seed <= F::zero() {
204        return None;
205    }
206    let bandwidth = seed * params.bandwidth_rel;
207    if bandwidth <= F::zero() {
208        return Some((seed, 0));
209    }
210    let convergence = bandwidth * params.convergence_rel;
211
212    let mut center = seed;
213    for _ in 0..params.max_iters {
214        let mut sum = F::zero();
215        let mut weight = F::zero();
216        let mut count_in_band = 0u32;
217        for &v in sorted {
218            let diff = v - center;
219            if diff.abs() > bandwidth {
220                continue;
221            }
222            let t = diff / bandwidth;
223            let w = F::one() - t * t;
224            let w = if w < F::zero() { F::zero() } else { w };
225            if w > F::zero() {
226                sum += v * w;
227                weight += w;
228                count_in_band += 1;
229            }
230        }
231        if weight <= F::zero() {
232            return Some((center, 0));
233        }
234        let next = sum / weight;
235        if (next - center).abs() <= convergence {
236            return Some((next, count_in_band));
237        }
238        center = next;
239    }
240    // Did not converge: fall back to the last center and its in-band count.
241    let mut in_band = 0u32;
242    for &v in sorted {
243        if (v - center).abs() <= bandwidth {
244            in_band += 1;
245        }
246    }
247    Some((center, in_band))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn rectangular_grid(rows: u32, cols: u32, spacing: f32) -> Vec<Point2<f32>> {
255        let mut out = Vec::new();
256        for j in 0..rows {
257            for i in 0..cols {
258                out.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
259            }
260        }
261        out
262    }
263
264    #[test]
265    fn recovers_regular_grid_spacing() {
266        let params = GlobalStepParams::<f32>::default();
267        for &spacing in &[10.0_f32, 24.0, 50.0] {
268            let pts = rectangular_grid(5, 5, spacing);
269            let est = estimate_global_cell_size(&pts, &params).expect("estimate");
270            assert!(
271                (est.cell_size - spacing).abs() / spacing < 0.02,
272                "spacing {spacing}: estimate {} off >2 %",
273                est.cell_size
274            );
275            assert!(est.confidence > 0.9, "confidence {}", est.confidence);
276        }
277    }
278
279    #[test]
280    fn sparse_noise_does_not_drag_mode() {
281        // 5×5 board at spacing=24 plus a small sprinkling of noise corners
282        // offset by random non-grid distances. The dominant mode should stay
283        // on the board spacing.
284        let mut pts = rectangular_grid(5, 5, 24.0);
285        // 4 noise points at positions that do not land on the 24 px lattice.
286        for (dx, dy) in [(6.0, 9.0), (43.0, 9.0), (9.0, 43.0), (81.0, 81.0)] {
287            pts.push(Point2::new(dx, dy));
288        }
289        let est =
290            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
291        assert!(
292            (est.cell_size - 24.0).abs() < 2.0,
293            "expected board step ~24 but got {}",
294            est.cell_size
295        );
296        assert!(est.support >= 10); // most of the 25 board corners contribute.
297    }
298
299    #[test]
300    fn bimodal_density_weights_by_cell_size() {
301        // Two disjoint grids: a 4×4 "small" grid at spacing=4 and a 4×4
302        // "big" grid at spacing=40. Equal point counts. The `support ×
303        // cell_size` score biases us toward the larger spacing.
304        let mut pts = Vec::new();
305        for j in 0..4 {
306            for i in 0..4 {
307                pts.push(Point2::new(i as f32 * 4.0, j as f32 * 4.0));
308            }
309        }
310        for j in 0..4 {
311            for i in 0..4 {
312                pts.push(Point2::new(
313                    1000.0 + i as f32 * 40.0,
314                    1000.0 + j as f32 * 40.0,
315                ));
316            }
317        }
318        let est =
319            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
320        assert!(
321            (est.cell_size - 40.0).abs() < 4.0,
322            "expected larger-grid cell ~40 but got {}",
323            est.cell_size
324        );
325        // The two clusters' cell-size modes are an order of magnitude
326        // apart — multiple percentile seeds converge to different modes,
327        // so the multimodal flag fires.
328        assert!(est.multimodal, "expected multimodal=true on bimodal cloud");
329    }
330
331    #[test]
332    fn unimodal_grid_has_multimodal_false() {
333        let pts = rectangular_grid(7, 7, 25.0);
334        let est =
335            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
336        assert!(!est.multimodal, "expected multimodal=false on a clean grid");
337    }
338
339    #[test]
340    fn too_small_input_returns_none() {
341        let pts: Vec<Point2<f32>> = vec![];
342        assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
343        let pts = vec![Point2::new(0.0, 0.0)];
344        assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
345    }
346
347    #[test]
348    fn degenerate_duplicate_points_are_skipped() {
349        let pts = vec![
350            Point2::new(0.0, 0.0),
351            Point2::new(0.0, 0.0),
352            Point2::new(10.0, 0.0),
353            Point2::new(0.0, 10.0),
354            Point2::new(10.0, 10.0),
355        ];
356        let est =
357            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
358        assert!((est.cell_size - 10.0).abs() < 1.0);
359    }
360
361    #[test]
362    fn mild_jitter_still_recovers_mode() {
363        // 5×5 grid at 24 px spacing with 5 % positional jitter.
364        let pts: Vec<Point2<f32>> = rectangular_grid(5, 5, 24.0)
365            .into_iter()
366            .enumerate()
367            .map(|(i, p)| {
368                let jitter_x = ((i * 17 % 7) as f32 - 3.0) * 0.4;
369                let jitter_y = ((i * 23 % 9) as f32 - 4.0) * 0.4;
370                Point2::new(p.x + jitter_x, p.y + jitter_y)
371            })
372            .collect();
373        let est =
374            estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
375        assert!(
376            (est.cell_size - 24.0).abs() < 2.0,
377            "expected ~24 got {}",
378            est.cell_size
379        );
380    }
381}