Skip to main content

projective_grid/lattice/
predict.rs

1//! Neighbour-midpoint position prediction on a labelled lattice.
2//!
3//! Given a map of already-labelled lattice coordinates to image positions,
4//! [`predict_grid_position`] predicts where a coordinate *should* sit by
5//! averaging the midpoints of its opposite neighbour pairs, one pair per
6//! lattice axis family (2 on square, 3 on hex axial). The prediction is
7//! purely local and homography-free, so it tolerates lens distortion that a
8//! single global projective fit cannot model.
9//!
10//! Because it only ever *interpolates* between opposite neighbours, the
11//! prediction is available exactly where it is reliable: a coordinate on the
12//! outer frontier of the labelled set (no complete opposite pair) yields
13//! `None` rather than an extrapolated guess. Typical uses are smoothness
14//! checks (compare a detected position against its neighbour-midpoint
15//! prediction to flag outliers) and seeding a local search for a lattice
16//! point that the feature detector missed.
17
18use std::collections::HashMap;
19
20use nalgebra::{Point2, RealField};
21
22use super::{Coord, LatticeKind};
23
24/// A neighbour-midpoint position prediction from [`predict_grid_position`].
25#[derive(Clone, Copy, Debug, PartialEq)]
26#[non_exhaustive]
27pub struct PredictedPosition<F: RealField + Copy> {
28    /// Predicted image position: the average of the available opposite-pair
29    /// midpoints.
30    pub position: Point2<F>,
31    /// Number of complete opposite neighbour pairs the prediction averages
32    /// (1..= the family's axis count). More pairs constrain the prediction
33    /// more tightly.
34    pub n_axis_pairs: usize,
35}
36
37/// Predict a lattice coordinate's image position from its labelled
38/// neighbours.
39///
40/// For each axis family of `kind`, if both opposite neighbours of `at` are
41/// present in `labelled`, their midpoint predicts `at`; the returned position
42/// averages the available midpoints. Returns `None` when no complete
43/// opposite pair exists — this function interpolates only, it never
44/// extrapolates outward from the labelled set.
45///
46/// On a square lattice the pairs are `(±1, 0)` and `(0, ±1)`; on a hex axial
47/// lattice they are `(±1, 0)`, `(0, ±1)`, and `±(1, -1)`.
48///
49/// ```
50/// use nalgebra::Point2;
51/// use projective_grid::{Coord, LatticeKind};
52/// use projective_grid::expert::lattice::predict_grid_position;
53/// use std::collections::HashMap;
54///
55/// let mut labelled: HashMap<Coord, Point2<f32>> = HashMap::new();
56/// labelled.insert(Coord::new(-1, 0), Point2::new(10.0, 50.0));
57/// labelled.insert(Coord::new(1, 0), Point2::new(90.0, 50.0));
58///
59/// let pred = predict_grid_position(&labelled, Coord::new(0, 0), LatticeKind::Square).unwrap();
60/// assert_eq!(pred.position, Point2::new(50.0, 50.0));
61/// assert_eq!(pred.n_axis_pairs, 1);
62///
63/// // A frontier coordinate with no opposite pair is not predicted.
64/// assert!(predict_grid_position(&labelled, Coord::new(2, 0), LatticeKind::Square).is_none());
65/// ```
66pub fn predict_grid_position<F: RealField + Copy>(
67    labelled: &HashMap<Coord, Point2<F>>,
68    at: Coord,
69    kind: LatticeKind,
70) -> Option<PredictedPosition<F>> {
71    let half = F::from_subset(&0.5_f64);
72    let mut sum_x = F::zero();
73    let mut sum_y = F::zero();
74    let mut n_axis_pairs = 0usize;
75
76    for &off in kind.neighbour_offsets() {
77        // Each opposite pair {off, -off} appears twice in the neighbour set;
78        // keep the lexicographically-positive representative.
79        if off.u < 0 || (off.u == 0 && off.v < 0) {
80            continue;
81        }
82        let fwd = Coord::new(at.u + off.u, at.v + off.v);
83        let bwd = Coord::new(at.u - off.u, at.v - off.v);
84        if let (Some(pf), Some(pb)) = (labelled.get(&fwd), labelled.get(&bwd)) {
85            sum_x += half * (pf.x + pb.x);
86            sum_y += half * (pf.y + pb.y);
87            n_axis_pairs += 1;
88        }
89    }
90
91    if n_axis_pairs == 0 {
92        return None;
93    }
94    let n = F::from_subset(&(n_axis_pairs as f64));
95    Some(PredictedPosition {
96        position: Point2::new(sum_x / n, sum_y / n),
97        n_axis_pairs,
98    })
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use nalgebra::Matrix3;
105
106    fn assert_close(actual: Point2<f64>, expected: Point2<f64>) {
107        let err = (actual - expected).norm();
108        assert!(
109            err < 1e-9,
110            "expected {expected:?}, got {actual:?} (err {err:e})"
111        );
112    }
113
114    fn square_grid(n: i32, spacing: f64) -> HashMap<Coord, Point2<f64>> {
115        let mut map = HashMap::new();
116        for v in 0..n {
117            for u in 0..n {
118                map.insert(
119                    Coord::new(u, v),
120                    Point2::new(u as f64 * spacing, v as f64 * spacing),
121                );
122            }
123        }
124        map
125    }
126
127    fn hex_grid(radius: i32, spacing: f64) -> HashMap<Coord, Point2<f64>> {
128        let sqrt3 = 3.0f64.sqrt();
129        let mut map = HashMap::new();
130        for q in -radius..=radius {
131            for r in -radius..=radius {
132                if (q + r).abs() > radius {
133                    continue;
134                }
135                let x = spacing * (q as f64 + r as f64 * 0.5);
136                let y = spacing * (r as f64 * sqrt3 / 2.0);
137                map.insert(Coord::new(q, r), Point2::new(x, y));
138            }
139        }
140        map
141    }
142
143    fn warp(map: &mut HashMap<Coord, Point2<f64>>, h: &Matrix3<f64>) {
144        for p in map.values_mut() {
145            let w = h * nalgebra::Vector3::new(p.x, p.y, 1.0);
146            *p = Point2::new(w.x / w.z, w.y / w.z);
147        }
148    }
149
150    #[test]
151    fn square_interior_predicts_exactly() {
152        let grid = square_grid(5, 40.0);
153        let pred = predict_grid_position(&grid, Coord::new(2, 2), LatticeKind::Square).unwrap();
154        assert_close(pred.position, Point2::new(80.0, 80.0));
155        assert_eq!(pred.n_axis_pairs, 2);
156    }
157
158    #[test]
159    fn square_missing_cell_is_predicted_from_neighbours() {
160        let mut grid = square_grid(5, 40.0);
161        grid.remove(&Coord::new(2, 2));
162        let pred = predict_grid_position(&grid, Coord::new(2, 2), LatticeKind::Square).unwrap();
163        assert_close(pred.position, Point2::new(80.0, 80.0));
164        assert_eq!(pred.n_axis_pairs, 2);
165    }
166
167    #[test]
168    fn square_edge_uses_single_pair() {
169        let grid = square_grid(5, 40.0);
170        // (2, 0): vertical pair needs (2, -1) which does not exist.
171        let pred = predict_grid_position(&grid, Coord::new(2, 0), LatticeKind::Square).unwrap();
172        assert_eq!(pred.n_axis_pairs, 1);
173        assert_close(pred.position, Point2::new(80.0, 0.0));
174    }
175
176    #[test]
177    fn square_corner_has_no_pair() {
178        let grid = square_grid(5, 40.0);
179        assert!(predict_grid_position(&grid, Coord::new(0, 0), LatticeKind::Square).is_none());
180        assert!(predict_grid_position(&grid, Coord::new(-1, 2), LatticeKind::Square).is_none());
181    }
182
183    #[test]
184    fn hex_interior_averages_three_pairs() {
185        let grid = hex_grid(3, 60.0);
186        let pred = predict_grid_position(&grid, Coord::new(0, 0), LatticeKind::Hex).unwrap();
187        assert_eq!(pred.n_axis_pairs, 3);
188        assert_close(pred.position, Point2::new(0.0, 0.0));
189    }
190
191    #[test]
192    fn hex_frontier_yields_none() {
193        let grid = hex_grid(2, 60.0);
194        // (3, 0) is outside the radius-2 disc with no bracketing neighbours.
195        assert!(predict_grid_position(&grid, Coord::new(3, 0), LatticeKind::Hex).is_none());
196    }
197
198    #[test]
199    fn perspective_warp_keeps_prediction_close() {
200        // Midpoint interpolation is exact for affine maps and first-order
201        // accurate under projective warps; a mild perspective term must keep
202        // the residual well under a pixel at 40 px pitch.
203        let h = Matrix3::new(
204            0.9, 0.05, 12.0, //
205            -0.04, 1.1, -7.0, //
206            2e-4, 1e-4, 1.0,
207        );
208        for (kind, mut grid) in [
209            (LatticeKind::Square, square_grid(7, 40.0)),
210            (LatticeKind::Hex, hex_grid(3, 40.0)),
211        ] {
212            warp(&mut grid, &h);
213            for (&at, &truth) in &grid {
214                let Some(pred) = predict_grid_position(&grid, at, kind) else {
215                    continue;
216                };
217                let err = (pred.position - truth).norm();
218                assert!(
219                    err < 0.5,
220                    "{kind:?} at {at:?}: prediction off by {err:.3} px"
221                );
222            }
223        }
224    }
225
226    #[test]
227    fn displaced_point_does_not_poison_its_own_prediction() {
228        // The prediction at `at` never reads `labelled[at]`, so a displaced
229        // detection is still compared against a clean neighbour consensus.
230        let mut grid = square_grid(5, 40.0);
231        grid.insert(Coord::new(2, 2), Point2::new(95.0, 71.0));
232        let pred = predict_grid_position(&grid, Coord::new(2, 2), LatticeKind::Square).unwrap();
233        assert_close(pred.position, Point2::new(80.0, 80.0));
234    }
235}