Skip to main content

projective_grid/
grid_smoothness.rs

1//! Grid smoothness analysis: predict corner positions from neighbors.
2//!
3//! For each grid corner, the expected position can be predicted from its
4//! immediate cardinal neighbors via midpoint averaging. Corners that deviate
5//! significantly from the prediction are likely false detections.
6
7use crate::float_helpers::lit;
8use crate::grid_index::GridIndex;
9use crate::Float;
10use nalgebra::Point2;
11use std::collections::HashMap;
12
13/// Predict a grid corner's position from its cardinal neighbors.
14///
15/// Uses midpoint averaging:
16/// - Horizontal: `0.5 * (P(i-1,j) + P(i+1,j))`
17/// - Vertical: `0.5 * (P(i,j-1) + P(i,j+1))`
18///
19/// Returns the average of available predictions, or `None` if no complete
20/// neighbor pair exists (need at least one horizontal or vertical pair).
21pub fn predict_grid_position<F: Float>(
22    grid: &HashMap<GridIndex, Point2<F>>,
23    idx: GridIndex,
24) -> Option<Point2<F>> {
25    let half: F = lit(0.5);
26    let mut pred_sum = Point2::new(F::zero(), F::zero());
27    let mut pred_count = 0u32;
28
29    // Horizontal pair
30    let left = GridIndex {
31        i: idx.i - 1,
32        j: idx.j,
33    };
34    let right = GridIndex {
35        i: idx.i + 1,
36        j: idx.j,
37    };
38    if let (Some(&pl), Some(&pr)) = (grid.get(&left), grid.get(&right)) {
39        let mid = Point2::new(half * (pl.x + pr.x), half * (pl.y + pr.y));
40        pred_sum.x += mid.x;
41        pred_sum.y += mid.y;
42        pred_count += 1;
43    }
44
45    // Vertical pair
46    let up = GridIndex {
47        i: idx.i,
48        j: idx.j - 1,
49    };
50    let down = GridIndex {
51        i: idx.i,
52        j: idx.j + 1,
53    };
54    if let (Some(&pu), Some(&pd)) = (grid.get(&up), grid.get(&down)) {
55        let mid = Point2::new(half * (pu.x + pd.x), half * (pu.y + pd.y));
56        pred_sum.x += mid.x;
57        pred_sum.y += mid.y;
58        pred_count += 1;
59    }
60
61    if pred_count == 0 {
62        return None;
63    }
64
65    let n: F = lit(pred_count as f64);
66    Some(Point2::new(pred_sum.x / n, pred_sum.y / n))
67}
68
69/// Find grid corners whose position deviates from the neighbor-predicted
70/// position by more than `threshold` pixels.
71///
72/// Returns `(grid_index, predicted_position)` for each inconsistent corner.
73pub fn find_inconsistent_corners<F: Float>(
74    grid: &HashMap<GridIndex, Point2<F>>,
75    threshold: F,
76) -> Vec<(GridIndex, Point2<F>)> {
77    let threshold_sq = threshold * threshold;
78    let mut flagged = Vec::new();
79
80    for (&idx, &pos) in grid {
81        if let Some(predicted) = predict_grid_position(grid, idx) {
82            let dx = pos.x - predicted.x;
83            let dy = pos.y - predicted.y;
84            if dx * dx + dy * dy > threshold_sq {
85                flagged.push((idx, predicted));
86            }
87        }
88    }
89
90    flagged
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    fn make_grid(rows: i32, cols: i32, spacing: f32) -> HashMap<GridIndex, Point2<f32>> {
98        let mut map = HashMap::new();
99        for j in 0..rows {
100            for i in 0..cols {
101                map.insert(
102                    GridIndex { i, j },
103                    Point2::new(i as f32 * spacing, j as f32 * spacing),
104                );
105            }
106        }
107        map
108    }
109
110    #[test]
111    fn clean_grid_has_no_inconsistencies() {
112        let grid = make_grid(5, 5, 60.0);
113        let flagged = find_inconsistent_corners(&grid, 3.0);
114        assert!(flagged.is_empty());
115    }
116
117    #[test]
118    fn displaced_corner_is_flagged() {
119        let mut grid = make_grid(3, 3, 60.0);
120        let center = GridIndex { i: 1, j: 1 };
121        grid.insert(center, Point2::new(69.0, 69.0)); // displaced by 9px each axis
122
123        let flagged = find_inconsistent_corners(&grid, 3.0);
124        assert_eq!(1, flagged.len());
125        assert_eq!(center, flagged[0].0);
126
127        // Predicted position should be the midpoint of neighbors = (60, 60)
128        let pred = flagged[0].1;
129        assert!((pred.x - 60.0).abs() < 0.01);
130        assert!((pred.y - 60.0).abs() < 0.01);
131    }
132
133    #[test]
134    fn perspective_distorted_grid_passes() {
135        let spacing = 60.0;
136        let mut grid = HashMap::new();
137        for j in 0..5 {
138            let scale = 1.0 + 0.02 * j as f32;
139            for i in 0..5 {
140                grid.insert(
141                    GridIndex { i, j },
142                    Point2::new(i as f32 * spacing * scale, j as f32 * spacing * scale),
143                );
144            }
145        }
146
147        // Mild perspective should not flag anything at a 3px threshold
148        let flagged = find_inconsistent_corners(&grid, 3.0);
149        assert!(flagged.is_empty());
150    }
151
152    #[test]
153    fn isolated_corners_are_skipped() {
154        let mut grid = HashMap::new();
155        grid.insert(GridIndex { i: 0, j: 0 }, Point2::new(0.0, 0.0));
156        grid.insert(GridIndex { i: 5, j: 5 }, Point2::new(300.0, 300.0));
157
158        let flagged = find_inconsistent_corners(&grid, 3.0);
159        assert!(flagged.is_empty());
160    }
161
162    #[test]
163    fn predict_from_single_pair() {
164        let mut grid = HashMap::new();
165        grid.insert(GridIndex { i: 0, j: 0 }, Point2::new(0.0, 0.0));
166        grid.insert(GridIndex { i: 1, j: 0 }, Point2::new(60.0, 0.0));
167        grid.insert(GridIndex { i: 2, j: 0 }, Point2::new(120.0, 0.0));
168
169        let pred = predict_grid_position(&grid, GridIndex { i: 1, j: 0 }).unwrap();
170        assert!((pred.x - 60.0f32).abs() < 0.01);
171        assert!((pred.y - 0.0f32).abs() < 0.01);
172    }
173}