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