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