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