Skip to main content

projective_grid/
local_step.rs

1//! Generic per-corner local grid-step estimation.
2//!
3//! For each input point this module returns an estimate of the spatial step
4//! `|offset|` along the point's two local axes, plus a confidence score based
5//! on how many neighbors contributed to the estimate. It is pattern-agnostic;
6//! chessboards, ChArUco lattices, PuzzleBoards — any consumer with per-point
7//! two-axis angles — can feed it.
8//!
9//! Algorithm (per point):
10//! 1. Query up to `k_neighbors` nearest neighbors via a KD-tree.
11//! 2. Drop neighbors farther than `max_step_factor × median(|offset|)` — a
12//!    coarse outlier reject that avoids bleed-through from distant marker
13//!    cells or second-order lattice copies.
14//! 3. Classify each surviving neighbor into the axis-u or axis-v sector,
15//!    using the point's own `(axis_u, axis_v)` folded to undirected lines
16//!    (mod π). Neighbors outside `sector_half_width_rad` of either axis are
17//!    discarded as ambiguous.
18//! 4. Per sector, run 1-D mean-shift on the collected `|offset|` values with
19//!    bandwidth `bandwidth_rel × median(|offset|_sector)` to recover the
20//!    dominant step. Fall back to the median when mean-shift fails to
21//!    converge in a small fixed number of iterations.
22//! 5. Confidence = `min(1, supporters / confidence_denominator)`.
23//!
24//! Dual-scale awareness (ChArUco marker-internal corners sit at ~0.2× the
25//! board step). Because sector binning uses each point's own axes — which
26//! typically deviate from the marker's rotated axes — marker-internal
27//! neighbors fall outside the sector and never reach the step-mode stage.
28//! Any that do reach it are a minority per neighborhood, so the dominant
29//! mode corresponds to the board scale.
30//!
31//! See `docs/grid_plan.md` Phase 2 and the plan file stored under
32//! `.claude/plans/we-need-to-plan-breezy-pixel.md` for the full context.
33
34use crate::Float;
35use kiddo::{KdTree, SquaredEuclidean};
36use nalgebra::{Point2, RealField, Vector2};
37
38/// Estimated per-point local grid-step along axis u and axis v.
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct LocalStep<F: Float = f32> {
41    /// Estimated step length along axis u, in pixels. `0.0` when there were no
42    /// supporters in this sector.
43    pub step_u: F,
44    /// Estimated step length along axis v.
45    pub step_v: F,
46    /// Confidence in `[0, 1]`: `min(1, supporters / confidence_denominator)`
47    /// where supporters = (u-sector supporters + v-sector supporters).
48    pub confidence: F,
49    /// How many neighbors fed the u-sector mode (for diagnostics).
50    pub supporters_u: u32,
51    /// How many neighbors fed the v-sector mode.
52    pub supporters_v: u32,
53}
54
55impl<F: Float> Default for LocalStep<F> {
56    fn default() -> Self {
57        Self {
58            step_u: F::zero(),
59            step_v: F::zero(),
60            confidence: F::zero(),
61            supporters_u: 0,
62            supporters_v: 0,
63        }
64    }
65}
66
67/// Per-point data consumed by [`estimate_local_steps`].
68///
69/// `axis_u` and `axis_v` are the point's two local grid-axis directions in
70/// radians. They need not be orthogonal — the routine treats them as
71/// undirected lines and folds every angle to `[0, π)` before sector
72/// classification, so perspective-warped corners whose axes deviate from 90°
73/// are handled naturally.
74#[derive(Clone, Copy, Debug)]
75pub struct LocalStepPointData<F: Float = f32> {
76    pub position: Point2<F>,
77    pub axis_u: F,
78    pub axis_v: F,
79}
80
81/// Tuning knobs for [`estimate_local_steps`].
82#[derive(Clone, Copy, Debug)]
83pub struct LocalStepParams<F: Float = f32> {
84    /// Nearest-neighbor count fed to the KD-tree per point. Defaults to 8 —
85    /// enough for a 4-connected grid even when a handful of neighbors are
86    /// missing.
87    pub k_neighbors: usize,
88    /// Clamp neighbors whose `|offset|` exceeds this factor times the local
89    /// median distance. Defaults to `3.0`.
90    pub max_step_factor: F,
91    /// Half-width (radians) of the u and v sectors. Defaults to `π/6` (30°)
92    /// so that grid diagonals — which sit exactly at 45° on an orthogonal
93    /// chessboard — are excluded from both sectors rather than polluting one
94    /// of them. Widen this if the detector emits heavily-warped grids whose
95    /// on-axis neighbors rotate more than 30° away from the lattice axes.
96    pub sector_half_width_rad: F,
97    /// Bandwidth for the 1-D mean-shift mode finder, expressed as a fraction
98    /// of each sector's median `|offset|`. Defaults to `0.15`.
99    pub bandwidth_rel: F,
100    /// Maximum mean-shift iterations before falling back to the sector
101    /// median. Defaults to `20`.
102    pub mean_shift_max_iters: u32,
103    /// Mean-shift converges when the update magnitude drops below
104    /// `bandwidth × convergence_rel`. Defaults to `1e-3`.
105    pub mean_shift_convergence_rel: F,
106    /// Denominator used when converting supporter count to confidence. A
107    /// well-connected interior corner has up to 4 supporters in each axis
108    /// (2 left/right, 2 up/down), so the default of `4.0` keeps well-supported
109    /// corners at confidence ≥ 1.0.
110    pub confidence_denominator: F,
111}
112
113impl<F: Float> Default for LocalStepParams<F> {
114    fn default() -> Self {
115        Self {
116            k_neighbors: 8,
117            max_step_factor: F::from_subset(&3.0),
118            sector_half_width_rad: F::pi() / F::from_subset(&6.0),
119            bandwidth_rel: F::from_subset(&0.15),
120            mean_shift_max_iters: 20,
121            mean_shift_convergence_rel: F::from_subset(&1e-3),
122            confidence_denominator: F::from_subset(&4.0),
123        }
124    }
125}
126
127/// Compute a per-point local grid step along each point's two local axes.
128///
129/// Returns a vector whose length matches `points`. Points that end up with no
130/// usable neighbors receive [`LocalStep::default`] (all zeros + zero
131/// confidence), letting downstream validators fall back to a global step.
132pub fn estimate_local_steps<F: Float + kiddo::float::kdtree::Axis>(
133    points: &[LocalStepPointData<F>],
134    params: &LocalStepParams<F>,
135) -> Vec<LocalStep<F>> {
136    if points.is_empty() {
137        return Vec::new();
138    }
139
140    // Build the KD-tree once, reuse for every query.
141    let coords: Vec<[F; 2]> = points
142        .iter()
143        .map(|p| [p.position.x, p.position.y])
144        .collect();
145    let tree: KdTree<F, 2> = (&coords).into();
146
147    let mut out = Vec::with_capacity(points.len());
148    for (i, p) in points.iter().enumerate() {
149        out.push(estimate_one(i, p, &tree, points, params));
150    }
151    out
152}
153
154fn estimate_one<F: Float + kiddo::float::kdtree::Axis>(
155    source_index: usize,
156    source: &LocalStepPointData<F>,
157    tree: &KdTree<F, 2>,
158    points: &[LocalStepPointData<F>],
159    params: &LocalStepParams<F>,
160) -> LocalStep<F> {
161    let k = params.k_neighbors.saturating_add(1); // +1 because the source itself will come back
162    let results =
163        tree.nearest_n::<SquaredEuclidean>(&[source.position.x, source.position.y], k.max(2));
164
165    // Collect (distance, offset) for real neighbors.
166    let mut offsets: Vec<Vector2<F>> = Vec::with_capacity(k);
167    for nn in results {
168        let j = nn.item as usize;
169        if j == source_index {
170            continue;
171        }
172        let other = &points[j];
173        let offset = other.position - source.position;
174        if offset.norm_squared().is_zero() {
175            continue;
176        }
177        offsets.push(offset);
178    }
179
180    if offsets.is_empty() {
181        return LocalStep::default();
182    }
183
184    // Coarse outlier reject by distance.
185    let distances: Vec<F> = offsets.iter().map(|o| o.norm()).collect();
186    let median_dist = median_f(&mut distances.clone());
187    let cutoff = median_dist * params.max_step_factor;
188    let mut kept: Vec<Vector2<F>> = offsets
189        .into_iter()
190        .zip(distances.iter())
191        .filter_map(|(o, d)| if *d <= cutoff { Some(o) } else { None })
192        .collect();
193
194    if kept.is_empty() {
195        return LocalStep::default();
196    }
197
198    // Bin into u/v sectors via each axis folded to [0, π).
199    let line_u = fold_to_line(source.axis_u);
200    let line_v = fold_to_line(source.axis_v);
201    let mut u_steps: Vec<F> = Vec::new();
202    let mut v_steps: Vec<F> = Vec::new();
203
204    while let Some(offset) = kept.pop() {
205        let edge_line = fold_to_line(offset.y.atan2(offset.x));
206        let diff_u = line_diff(edge_line, line_u);
207        let diff_v = line_diff(edge_line, line_v);
208        if RealField::min(diff_u, diff_v) > params.sector_half_width_rad {
209            continue;
210        }
211        let d = offset.norm();
212        if diff_u <= diff_v {
213            u_steps.push(d);
214        } else {
215            v_steps.push(d);
216        }
217    }
218
219    let (step_u, sup_u) = sector_mode(&mut u_steps, params);
220    let (step_v, sup_v) = sector_mode(&mut v_steps, params);
221
222    let total_sup = F::from_subset(&((sup_u + sup_v) as f64));
223    let confidence = RealField::max(
224        RealField::min(total_sup / params.confidence_denominator, F::one()),
225        F::zero(),
226    );
227
228    LocalStep {
229        step_u,
230        step_v,
231        confidence,
232        supporters_u: sup_u,
233        supporters_v: sup_v,
234    }
235}
236
237/// 1-D mode via mean-shift on the collected `|offset|` samples. Returns
238/// `(mode_value, supporter_count)`; `(0, 0)` when the sector is empty.
239fn sector_mode<F: Float>(values: &mut [F], params: &LocalStepParams<F>) -> (F, u32) {
240    if values.is_empty() {
241        return (F::zero(), 0);
242    }
243    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
244    let med = median_sorted(values);
245    let sup = values.len() as u32;
246
247    if values.len() < 2 {
248        return (med, sup);
249    }
250    let bandwidth = med * params.bandwidth_rel;
251    if bandwidth.is_zero() {
252        return (med, sup);
253    }
254
255    let mut center = med;
256    let convergence = bandwidth * params.mean_shift_convergence_rel;
257    for _ in 0..params.mean_shift_max_iters {
258        let mut sum = F::zero();
259        let mut weight = F::zero();
260        for &v in values.iter() {
261            let diff = v - center;
262            if diff.abs() > bandwidth {
263                continue;
264            }
265            // Epanechnikov-style weight: 1 - (diff/bandwidth)^2, clamped to 0.
266            let t = diff / bandwidth;
267            let w = F::one() - t * t;
268            let w = if w < F::zero() { F::zero() } else { w };
269            sum += v * w;
270            weight += w;
271        }
272        if weight.is_zero() {
273            return (med, sup);
274        }
275        let next = sum / weight;
276        if (next - center).abs() <= convergence {
277            return (next, sup);
278        }
279        center = next;
280    }
281    // Mean-shift did not converge; fall back to the median.
282    (med, sup)
283}
284
285/// Fold an angle into the undirected-line range `[0, π)`.
286#[inline]
287fn fold_to_line<F: Float>(theta: F) -> F {
288    let pi = F::pi();
289    let two_pi = pi + pi;
290    let mut t = theta - two_pi * (theta / two_pi).floor();
291    if t >= pi {
292        t -= pi;
293    }
294    if t < F::zero() {
295        t += pi;
296    }
297    t
298}
299
300/// Absolute angular difference between two undirected lines (both in `[0, π)`).
301/// Result is in `[0, π/2]`.
302#[inline]
303fn line_diff<F: Float>(a: F, b: F) -> F {
304    let pi = F::pi();
305    let frac_pi_2 = F::frac_pi_2();
306    let mut diff = (a - b).abs();
307    if diff > frac_pi_2 {
308        diff = pi - diff;
309    }
310    diff
311}
312
313fn median_f<F: Float>(values: &mut [F]) -> F {
314    if values.is_empty() {
315        return F::zero();
316    }
317    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
318    median_sorted(values)
319}
320
321fn median_sorted<F: Float>(sorted: &[F]) -> F {
322    let n = sorted.len();
323    if n == 0 {
324        return F::zero();
325    }
326    if n % 2 == 1 {
327        sorted[n / 2]
328    } else {
329        (sorted[n / 2 - 1] + sorted[n / 2]) * F::from_subset(&0.5)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use nalgebra::Point2;
337
338    fn lspd(x: f32, y: f32, axis_u: f32) -> LocalStepPointData<f32> {
339        LocalStepPointData {
340            position: Point2::new(x, y),
341            axis_u,
342            axis_v: axis_u + std::f32::consts::FRAC_PI_2,
343        }
344    }
345
346    fn regular_grid(
347        rows: u32,
348        cols: u32,
349        spacing: f32,
350        angle: f32,
351    ) -> Vec<LocalStepPointData<f32>> {
352        let (cx, sx) = (angle.cos(), angle.sin());
353        let mut out = Vec::new();
354        for j in 0..rows {
355            for i in 0..cols {
356                let i_f = i as f32 * spacing;
357                let j_f = j as f32 * spacing;
358                let x = i_f * cx - j_f * sx;
359                let y = i_f * sx + j_f * cx;
360                out.push(lspd(x, y, angle));
361            }
362        }
363        out
364    }
365
366    #[test]
367    fn regular_grid_recovers_spacing_at_multiple_scales() {
368        let params = LocalStepParams::<f32>::default();
369        for &spacing in &[10.0_f32, 20.0, 40.0] {
370            let pts = regular_grid(5, 5, spacing, 0.0);
371            let steps = estimate_local_steps(&pts, &params);
372            // Interior point (center of the 5×5 grid, index 12).
373            let s = &steps[12];
374            assert!(
375                (s.step_u - spacing).abs() / spacing < 0.05,
376                "spacing {spacing}: step_u {} off >5%",
377                s.step_u
378            );
379            assert!((s.step_v - spacing).abs() / spacing < 0.05);
380            assert!(s.supporters_u >= 2 && s.supporters_v >= 2);
381            assert!(s.confidence > 0.8);
382        }
383    }
384
385    #[test]
386    fn rotated_grid_is_sector_invariant() {
387        let params = LocalStepParams::<f32>::default();
388        for &deg in &[0.0_f32, 15.0, 30.0, 45.0] {
389            let angle = deg.to_radians();
390            let pts = regular_grid(5, 5, 20.0, angle);
391            let steps = estimate_local_steps(&pts, &params);
392            let s = &steps[12];
393            assert!(
394                (s.step_u - 20.0).abs() < 1.0,
395                "angle {deg}°: step_u {} deviates",
396                s.step_u
397            );
398            assert!((s.step_v - 20.0).abs() < 1.0);
399        }
400    }
401
402    #[test]
403    fn mild_barrel_distortion_is_tolerated() {
404        // Apply a mild pincushion/barrel-like radial perturbation and check
405        // that the estimator still recovers step ~ spacing at interior points
406        // to within ~10 %.
407        let spacing = 25.0;
408        let mut pts = regular_grid(7, 7, spacing, 0.0);
409        for p in &mut pts {
410            let cx = 3.0 * spacing;
411            let cy = 3.0 * spacing;
412            let dx = p.position.x - cx;
413            let dy = p.position.y - cy;
414            let r2 = dx * dx + dy * dy;
415            let scale = 1.0 + 1e-5 * r2;
416            p.position = Point2::new(cx + dx * scale, cy + dy * scale);
417        }
418        let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
419        let interior = 24usize; // center of 7×7.
420        let s = &steps[interior];
421        assert!(
422            (s.step_u - spacing).abs() / spacing < 0.1,
423            "step_u {} far from spacing {spacing}",
424            s.step_u
425        );
426    }
427
428    #[test]
429    fn dual_scale_grid_picks_dominant_mode() {
430        // Board-scale 5×5 lattice at spacing=20.
431        let mut pts = regular_grid(5, 5, 20.0, 0.0);
432        // Inject a minority of "marker-internal" neighbors at ~0.2× spacing
433        // around each interior cell. The markers sit OFF the board axes
434        // (at a 45° diagonal inside the cell) and carry their own rotated
435        // axes, so the default sector filter should reject them. Even if one
436        // sneaks into the k-NN window it is outnumbered by the 4 cardinal
437        // board neighbors.
438        let marker_angle = 20.0_f32.to_radians();
439        let interior_pts: Vec<usize> = (1..4)
440            .flat_map(|j| (1..4).map(move |i| j * 5 + i))
441            .collect();
442        for &idx in &interior_pts {
443            let c = pts[idx].position;
444            pts.push(LocalStepPointData {
445                position: Point2::new(c.x + 3.0, c.y + 3.0),
446                axis_u: marker_angle,
447                axis_v: marker_angle + std::f32::consts::FRAC_PI_2,
448            });
449        }
450        let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
451        let s = &steps[12]; // center of the board-scale grid.
452                            // Expect the board-scale ~20 px step, not the marker-scale ~4 px.
453        assert!(
454            (s.step_u - 20.0).abs() < 2.0,
455            "expected board step ~20 for u, got {}",
456            s.step_u
457        );
458        assert!(
459            (s.step_v - 20.0).abs() < 2.0,
460            "expected board step ~20 for v, got {}",
461            s.step_v
462        );
463    }
464
465    #[test]
466    fn isolated_point_reports_zero_confidence() {
467        let pts = vec![lspd(0.0, 0.0, 0.0)];
468        let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
469        assert_eq!(steps.len(), 1);
470        assert_eq!(steps[0].confidence, 0.0);
471        assert_eq!(steps[0].step_u, 0.0);
472        assert_eq!(steps[0].step_v, 0.0);
473    }
474
475    #[test]
476    fn fold_and_line_diff_roundtrip() {
477        let pi = std::f32::consts::PI;
478        for &theta in &[-pi, -0.5, 0.0, 0.5, pi - 1e-3, pi, 1.5 * pi, 2.5 * pi] {
479            let folded = fold_to_line(theta);
480            assert!(
481                (0.0..pi).contains(&folded),
482                "fold({theta}) = {folded} escaped [0, π)"
483            );
484        }
485        // Axes 0 and π/2 are orthogonal → line_diff = π/2.
486        assert!(
487            (line_diff(0.0, std::f32::consts::FRAC_PI_2) - std::f32::consts::FRAC_PI_2).abs()
488                < 1e-5
489        );
490        // Axes 0 and π-ε are nearly parallel.
491        assert!(line_diff(0.0, pi - 1e-3) < 1e-2);
492    }
493}