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