Skip to main content

projective_grid/
grid_mesh.rs

1//! Per-cell homography mesh for projective grid rectification.
2//!
3//! Given a map of grid corners to image positions, builds one homography per
4//! grid cell. This is robust to lens distortion because it does not assume
5//! a single global homography.
6//!
7//! This module provides geometry only (coordinate mapping). Pixel warping
8//! is left to the caller.
9
10use crate::grid_index::GridIndex;
11use crate::homography::{estimate_homography, Homography};
12use nalgebra::Point2;
13use std::collections::HashMap;
14
15#[non_exhaustive]
16#[derive(thiserror::Error, Debug)]
17pub enum GridMeshError {
18    #[error("not enough grid corners (need at least 2×2)")]
19    NotEnoughCorners,
20    #[error("no valid grid cells found (each cell needs all 4 corners)")]
21    NoValidCells,
22    #[error("homography estimation failed for cell ({ci}, {cj})")]
23    HomographyFailed { ci: usize, cj: usize },
24}
25
26#[derive(Clone, Copy, Debug)]
27struct CellHomography {
28    h_img_from_cellrect: Homography,
29    valid: bool,
30}
31
32/// Per-cell homography mesh over a 2D grid.
33///
34/// Maps between a rectified coordinate system (uniform grid spacing) and
35/// the original image coordinates, using one homography per grid cell.
36#[derive(Clone, Debug)]
37pub struct GridHomographyMesh {
38    /// Minimum grid index (corner space).
39    pub min_i: i32,
40    /// Minimum grid index (corner space).
41    pub min_j: i32,
42    /// Number of cells (squares) horizontally.
43    pub cells_x: usize,
44    /// Number of cells (squares) vertically.
45    pub cells_y: usize,
46    /// Rectified pixels per grid cell.
47    pub px_per_cell: f32,
48    /// Number of cells that have all 4 corners and a valid homography.
49    pub valid_cells: usize,
50    /// Width of the rectified image in pixels.
51    pub rect_width: usize,
52    /// Height of the rectified image in pixels.
53    pub rect_height: usize,
54
55    cells: Vec<CellHomography>,
56}
57
58impl GridHomographyMesh {
59    /// Build per-cell homographies from a grid corner map.
60    ///
61    /// - `corners`: map from grid index to image position.
62    /// - `px_per_cell`: rectified pixels per grid cell.
63    pub fn from_corners(
64        corners: &HashMap<GridIndex, Point2<f32>>,
65        px_per_cell: f32,
66    ) -> Result<Self, GridMeshError> {
67        if corners.len() < 4 {
68            return Err(GridMeshError::NotEnoughCorners);
69        }
70
71        let (mut min_i, mut min_j) = (i32::MAX, i32::MAX);
72        let (mut max_i, mut max_j) = (i32::MIN, i32::MIN);
73        for g in corners.keys() {
74            min_i = min_i.min(g.i);
75            min_j = min_j.min(g.j);
76            max_i = max_i.max(g.i);
77            max_j = max_j.max(g.j);
78        }
79
80        if max_i - min_i < 1 || max_j - min_j < 1 {
81            return Err(GridMeshError::NoValidCells);
82        }
83
84        let cells_x = (max_i - min_i) as usize;
85        let cells_y = (max_j - min_j) as usize;
86
87        let rect_width = ((cells_x as f32) * px_per_cell).floor().max(1.0) as usize;
88        let rect_height = ((cells_y as f32) * px_per_cell).floor().max(1.0) as usize;
89
90        let s = px_per_cell;
91        let cell_rect = [
92            Point2::new(0.0, 0.0),
93            Point2::new(s, 0.0),
94            Point2::new(0.0, s),
95            Point2::new(s, s),
96        ];
97
98        let mut cells = vec![
99            CellHomography {
100                h_img_from_cellrect: Homography::zero(),
101                valid: false,
102            };
103            cells_x * cells_y
104        ];
105
106        let mut valid_cells = 0usize;
107
108        for cj in 0..cells_y {
109            for ci in 0..cells_x {
110                let i0 = min_i + ci as i32;
111                let j0 = min_j + cj as i32;
112
113                let g00 = GridIndex { i: i0, j: j0 };
114                let g10 = GridIndex { i: i0 + 1, j: j0 };
115                let g01 = GridIndex { i: i0, j: j0 + 1 };
116                let g11 = GridIndex {
117                    i: i0 + 1,
118                    j: j0 + 1,
119                };
120
121                let Some(p00) = corners.get(&g00).copied() else {
122                    continue;
123                };
124                let Some(p10) = corners.get(&g10).copied() else {
125                    continue;
126                };
127                let Some(p01) = corners.get(&g01).copied() else {
128                    continue;
129                };
130                let Some(p11) = corners.get(&g11).copied() else {
131                    continue;
132                };
133
134                let img_quad = [p00, p10, p01, p11];
135
136                let h = estimate_homography(&cell_rect, &img_quad)
137                    .ok_or(GridMeshError::HomographyFailed { ci, cj })?;
138
139                let idx = cj * cells_x + ci;
140                cells[idx] = CellHomography {
141                    h_img_from_cellrect: h,
142                    valid: true,
143                };
144                valid_cells += 1;
145            }
146        }
147
148        if valid_cells == 0 {
149            return Err(GridMeshError::NoValidCells);
150        }
151
152        Ok(Self {
153            min_i,
154            min_j,
155            cells_x,
156            cells_y,
157            px_per_cell,
158            valid_cells,
159            rect_width,
160            rect_height,
161            cells,
162        })
163    }
164
165    /// Map a point in **global rectified pixel coordinates** to image coordinates.
166    ///
167    /// Returns `None` if the point lies outside the mesh or the cell is invalid.
168    pub fn rect_to_img(&self, p_rect: Point2<f32>) -> Option<Point2<f32>> {
169        let s = self.px_per_cell;
170        if s <= 0.0 {
171            return None;
172        }
173
174        let ci = (p_rect.x / s).floor() as i32;
175        let cj = (p_rect.y / s).floor() as i32;
176        if ci < 0 || cj < 0 || ci >= self.cells_x as i32 || cj >= self.cells_y as i32 {
177            return None;
178        }
179
180        let x_local = p_rect.x - (ci as f32) * s;
181        let y_local = p_rect.y - (cj as f32) * s;
182        self.cell_rect_to_img(ci as usize, cj as usize, Point2::new(x_local, y_local))
183    }
184
185    /// Map a point in **cell-local rectified coordinates** to image coordinates.
186    ///
187    /// - `ci`, `cj`: cell indices in `0..cells_x × 0..cells_y`
188    /// - `p_cell`: point in `[0..px_per_cell]²`
189    pub fn cell_rect_to_img(
190        &self,
191        ci: usize,
192        cj: usize,
193        p_cell: Point2<f32>,
194    ) -> Option<Point2<f32>> {
195        let idx = cj.checked_mul(self.cells_x)?.checked_add(ci)?;
196        let cell = *self.cells.get(idx)?;
197        if !cell.valid {
198            return None;
199        }
200        Some(cell.h_img_from_cellrect.apply(p_cell))
201    }
202
203    /// Get the 4 image-space corners of a cell (TL, TR, BR, BL order).
204    pub fn cell_corners_img(&self, ci: usize, cj: usize) -> Option<[Point2<f32>; 4]> {
205        let s = self.px_per_cell;
206        let pts = [
207            Point2::new(0.0, 0.0),
208            Point2::new(s, 0.0),
209            Point2::new(s, s),
210            Point2::new(0.0, s),
211        ];
212        Some([
213            self.cell_rect_to_img(ci, cj, pts[0])?,
214            self.cell_rect_to_img(ci, cj, pts[1])?,
215            self.cell_rect_to_img(ci, cj, pts[2])?,
216            self.cell_rect_to_img(ci, cj, pts[3])?,
217        ])
218    }
219}