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