Skip to main content

projective_grid/square/
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::homography::{estimate_homography_with_quality, Homography, HomographyQuality};
12use crate::Float;
13use crate::GridCoords;
14use nalgebra::Point2;
15use std::collections::HashMap;
16
17/// Reason a [`SquareGridHomographyMesh`] could not be built.
18#[non_exhaustive]
19#[derive(thiserror::Error, Debug)]
20pub enum GridMeshError {
21    /// Fewer than the 2×2 corners a mesh needs were supplied.
22    #[error("not enough grid corners (need at least 2×2)")]
23    NotEnoughCorners,
24    /// No grid cell had all 4 corners present, so no cell homography
25    /// could be fitted.
26    #[error("no valid grid cells found (each cell needs all 4 corners)")]
27    NoValidCells,
28    /// The homography estimator failed for one cell (degenerate corners).
29    #[error("homography estimation failed for cell ({ci}, {cj})")]
30    HomographyFailed {
31        /// Cell column index in mesh-local coordinates.
32        ci: usize,
33        /// Cell row index in mesh-local coordinates.
34        cj: usize,
35    },
36}
37
38#[derive(Clone, Copy, Debug)]
39struct CellHomography<F: Float> {
40    h_img_from_cellrect: Homography<F>,
41    quality: HomographyQuality<F>,
42    valid: bool,
43}
44
45/// Per-cell homography mesh over a 2D grid.
46///
47/// Maps between a rectified coordinate system (uniform grid spacing) and
48/// the original image coordinates, using one homography per grid cell.
49#[derive(Clone, Debug)]
50pub struct SquareGridHomographyMesh<F: Float = f32> {
51    /// Minimum grid index (corner space).
52    pub min_i: i32,
53    /// Minimum grid index (corner space).
54    pub min_j: i32,
55    /// Number of cells (squares) horizontally.
56    pub cells_x: usize,
57    /// Number of cells (squares) vertically.
58    pub cells_y: usize,
59    /// Rectified pixels per grid cell.
60    pub px_per_cell: F,
61    /// Number of cells that have all 4 corners and a valid homography.
62    pub valid_cells: usize,
63    /// Width of the rectified image in pixels.
64    pub rect_width: usize,
65    /// Height of the rectified image in pixels.
66    pub rect_height: usize,
67
68    cells: Vec<CellHomography<F>>,
69}
70
71impl<F: Float> SquareGridHomographyMesh<F> {
72    /// Build per-cell homographies from a grid corner map.
73    ///
74    /// - `corners`: map from grid index to image position.
75    /// - `px_per_cell`: rectified pixels per grid cell.
76    ///
77    /// Equivalent to [`Self::from_corners_with_min_singular_value`] with
78    /// `F::zero()` — every cell whose four corners are present and whose
79    /// homography solves successfully is accepted, regardless of
80    /// conditioning.
81    pub fn from_corners(
82        corners: &HashMap<GridCoords, Point2<F>>,
83        px_per_cell: F,
84    ) -> Result<Self, GridMeshError> {
85        Self::from_corners_with_min_singular_value(corners, px_per_cell, F::zero())
86    }
87
88    /// Variant of [`Self::from_corners`] that skips cells whose homography
89    /// is ill-conditioned.
90    ///
91    /// A cell is treated as invalid (a "hole") when the smallest singular
92    /// value of its 3×3 homography matrix falls below
93    /// `min_singular_value`. The threshold is in the same units as the
94    /// matrix entries; for the standard `(grid_corner_space → image_pixels)`
95    /// fit, a threshold around `1e-3 × px_per_cell` rejects cells where
96    /// three of the four corners are nearly collinear in pixel space.
97    ///
98    /// Cells skipped this way are silently ignored, exactly like cells
99    /// missing one or more corners. The error variant
100    /// [`GridMeshError::HomographyFailed`] is still returned only when the
101    /// underlying solver fails — a strictly different failure mode from
102    /// "the solver succeeded but the result is degenerate".
103    pub fn from_corners_with_min_singular_value(
104        corners: &HashMap<GridCoords, Point2<F>>,
105        px_per_cell: F,
106        min_singular_value: F,
107    ) -> Result<Self, GridMeshError> {
108        if corners.len() < 4 {
109            return Err(GridMeshError::NotEnoughCorners);
110        }
111
112        let (mut min_i, mut min_j) = (i32::MAX, i32::MAX);
113        let (mut max_i, mut max_j) = (i32::MIN, i32::MIN);
114        for g in corners.keys() {
115            min_i = min_i.min(g.i);
116            min_j = min_j.min(g.j);
117            max_i = max_i.max(g.i);
118            max_j = max_j.max(g.j);
119        }
120
121        if max_i - min_i < 1 || max_j - min_j < 1 {
122            return Err(GridMeshError::NoValidCells);
123        }
124
125        let cells_x = (max_i - min_i) as usize;
126        let cells_y = (max_j - min_j) as usize;
127
128        let rect_width = nalgebra::try_convert::<F, f64>(
129            (lit::<F>(cells_x as f64) * px_per_cell)
130                .floor()
131                .max(F::one()),
132        )
133        .unwrap_or(1.0) as usize;
134        let rect_height = nalgebra::try_convert::<F, f64>(
135            (lit::<F>(cells_y as f64) * px_per_cell)
136                .floor()
137                .max(F::one()),
138        )
139        .unwrap_or(1.0) as usize;
140
141        let s = px_per_cell;
142        let cell_rect = [
143            Point2::new(F::zero(), F::zero()),
144            Point2::new(s, F::zero()),
145            Point2::new(F::zero(), s),
146            Point2::new(s, s),
147        ];
148
149        let zero_quality = HomographyQuality {
150            max_singular_value: F::zero(),
151            min_singular_value: F::zero(),
152            condition: F::zero(),
153            determinant: F::zero(),
154        };
155        let mut cells = vec![
156            CellHomography {
157                h_img_from_cellrect: Homography::zero(),
158                quality: zero_quality,
159                valid: false,
160            };
161            cells_x * cells_y
162        ];
163
164        let mut valid_cells = 0usize;
165
166        for cj in 0..cells_y {
167            for ci in 0..cells_x {
168                let i0 = min_i + ci as i32;
169                let j0 = min_j + cj as i32;
170
171                let g00 = GridCoords { i: i0, j: j0 };
172                let g10 = GridCoords { i: i0 + 1, j: j0 };
173                let g01 = GridCoords { i: i0, j: j0 + 1 };
174                let g11 = GridCoords {
175                    i: i0 + 1,
176                    j: j0 + 1,
177                };
178
179                let Some(p00) = corners.get(&g00).copied() else {
180                    continue;
181                };
182                let Some(p10) = corners.get(&g10).copied() else {
183                    continue;
184                };
185                let Some(p01) = corners.get(&g01).copied() else {
186                    continue;
187                };
188                let Some(p11) = corners.get(&g11).copied() else {
189                    continue;
190                };
191
192                let img_quad = [p00, p10, p01, p11];
193
194                let (h, quality) = estimate_homography_with_quality(&cell_rect, &img_quad)
195                    .ok_or(GridMeshError::HomographyFailed { ci, cj })?;
196
197                let idx = cj * cells_x + ci;
198                let valid = quality.min_singular_value >= min_singular_value;
199                cells[idx] = CellHomography {
200                    h_img_from_cellrect: h,
201                    quality,
202                    valid,
203                };
204                if valid {
205                    valid_cells += 1;
206                }
207            }
208        }
209
210        if valid_cells == 0 {
211            return Err(GridMeshError::NoValidCells);
212        }
213
214        Ok(Self {
215            min_i,
216            min_j,
217            cells_x,
218            cells_y,
219            px_per_cell,
220            valid_cells,
221            rect_width,
222            rect_height,
223            cells,
224        })
225    }
226
227    /// Map a point in **global rectified pixel coordinates** to image coordinates.
228    ///
229    /// Returns `None` if the point lies outside the mesh or the cell is invalid.
230    pub fn rect_to_img(&self, p_rect: Point2<F>) -> Option<Point2<F>> {
231        let s = self.px_per_cell;
232        if s <= F::zero() {
233            return None;
234        }
235
236        let ci_f = (p_rect.x / s).floor();
237        let cj_f = (p_rect.y / s).floor();
238        let ci = nalgebra::try_convert::<F, f64>(ci_f).unwrap_or(0.0) as i32;
239        let cj = nalgebra::try_convert::<F, f64>(cj_f).unwrap_or(0.0) as i32;
240        if ci < 0 || cj < 0 || ci >= self.cells_x as i32 || cj >= self.cells_y as i32 {
241            return None;
242        }
243
244        let x_local = p_rect.x - lit::<F>(ci as f64) * s;
245        let y_local = p_rect.y - lit::<F>(cj as f64) * s;
246        self.cell_rect_to_img(ci as usize, cj as usize, Point2::new(x_local, y_local))
247    }
248
249    /// Per-cell homography quality, or `None` when the cell is missing or
250    /// outside the mesh. Returns the quality even for cells marked invalid
251    /// by the conditioning gate so callers can introspect *why* a cell was
252    /// rejected.
253    pub fn cell_quality(&self, ci: usize, cj: usize) -> Option<HomographyQuality<F>> {
254        let idx = cj.checked_mul(self.cells_x)?.checked_add(ci)?;
255        let cell = self.cells.get(idx)?;
256        if cell.quality.max_singular_value <= F::zero() {
257            None
258        } else {
259            Some(cell.quality)
260        }
261    }
262
263    /// Map a point in **cell-local rectified coordinates** to image coordinates.
264    ///
265    /// - `ci`, `cj`: cell indices in `0..cells_x × 0..cells_y`
266    /// - `p_cell`: point in `[0..px_per_cell]²`
267    pub fn cell_rect_to_img(&self, ci: usize, cj: usize, p_cell: Point2<F>) -> Option<Point2<F>> {
268        let idx = cj.checked_mul(self.cells_x)?.checked_add(ci)?;
269        let cell = *self.cells.get(idx)?;
270        if !cell.valid {
271            return None;
272        }
273        Some(cell.h_img_from_cellrect.apply(p_cell))
274    }
275
276    /// Get the 4 image-space corners of a cell (TL, TR, BR, BL order).
277    pub fn cell_corners_img(&self, ci: usize, cj: usize) -> Option<[Point2<F>; 4]> {
278        let s = self.px_per_cell;
279        let pts = [
280            Point2::new(F::zero(), F::zero()),
281            Point2::new(s, F::zero()),
282            Point2::new(s, s),
283            Point2::new(F::zero(), s),
284        ];
285        Some([
286            self.cell_rect_to_img(ci, cj, pts[0])?,
287            self.cell_rect_to_img(ci, cj, pts[1])?,
288            self.cell_rect_to_img(ci, cj, pts[2])?,
289            self.cell_rect_to_img(ci, cj, pts[3])?,
290        ])
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn axis_aligned_grid(rows: i32, cols: i32, spacing: f32) -> HashMap<GridCoords, Point2<f32>> {
299        let mut out = HashMap::new();
300        for j in 0..rows {
301            for i in 0..cols {
302                out.insert(
303                    GridCoords { i, j },
304                    Point2::new(i as f32 * spacing, j as f32 * spacing),
305                );
306            }
307        }
308        out
309    }
310
311    #[test]
312    fn from_corners_default_accepts_clean_grid() {
313        let corners = axis_aligned_grid(3, 3, 50.0);
314        let mesh =
315            SquareGridHomographyMesh::<f32>::from_corners(&corners, 32.0).expect("mesh builds");
316        // 3x3 corners = 2x2 cells.
317        assert_eq!(mesh.cells_x, 2);
318        assert_eq!(mesh.cells_y, 2);
319        assert_eq!(mesh.valid_cells, 4);
320        // Quality recorded on every cell.
321        for cj in 0..2 {
322            for ci in 0..2 {
323                let q = mesh.cell_quality(ci, cj).expect("quality");
324                assert!(q.min_singular_value > 0.0);
325                assert!(q.condition.is_finite());
326            }
327        }
328    }
329
330    #[test]
331    fn min_singular_value_threshold_skips_degenerate_cells() {
332        // A 2x2 grid of corners where 3 of 4 are collinear in pixel space.
333        // The cell homography is rank-deficient and should be flagged.
334        let mut corners = HashMap::new();
335        corners.insert(GridCoords { i: 0, j: 0 }, Point2::new(0.0_f32, 0.0));
336        corners.insert(GridCoords { i: 1, j: 0 }, Point2::new(50.0, 0.0));
337        // A near-collinear corner: (1, 1) sits 1e-4 px below the (1, 0) line.
338        corners.insert(GridCoords { i: 1, j: 1 }, Point2::new(50.0, 1e-4));
339        corners.insert(GridCoords { i: 0, j: 1 }, Point2::new(0.0, 1e-4));
340
341        // Lenient: accept everything.
342        let lenient =
343            SquareGridHomographyMesh::<f32>::from_corners(&corners, 32.0).expect("lenient");
344        assert_eq!(lenient.valid_cells, 1);
345
346        // Strict: reject ill-conditioned cells.
347        let strict = SquareGridHomographyMesh::<f32>::from_corners_with_min_singular_value(
348            &corners, 32.0, 0.01,
349        );
350        // All cells skipped → NoValidCells.
351        assert!(strict.is_err());
352    }
353
354    #[test]
355    fn cell_quality_returns_none_for_skipped_cell() {
356        // 3×3 corners, drop the centre one. Cells (0,0), (1,0), (0,1), (1,1)
357        // each need the centre corner for one of their four vertices, so
358        // every cell is skipped — but the surrounding bounding box still
359        // builds a 2×2 cell mesh with `valid_cells = 0`.
360        let mut corners = axis_aligned_grid(3, 3, 50.0);
361        corners.remove(&GridCoords { i: 1, j: 1 });
362        let result = SquareGridHomographyMesh::<f32>::from_corners(&corners, 32.0);
363        assert!(matches!(result, Err(GridMeshError::NoValidCells)));
364    }
365
366    #[test]
367    fn cell_quality_partial_mesh_keeps_intact_cells() {
368        // 4×4 corners, drop a corner that touches only a single cell (the
369        // top-left corner of the bottom-right cell).
370        let mut corners = axis_aligned_grid(4, 4, 50.0);
371        corners.remove(&GridCoords { i: 2, j: 2 });
372        let mesh =
373            SquareGridHomographyMesh::<f32>::from_corners(&corners, 32.0).expect("partial mesh");
374        // 9 total cells in a 4×4-corner grid, of which 4 share corner (2,2).
375        // (Each of the 4 corner-touching cells is invalidated.)
376        assert_eq!(mesh.valid_cells, 9 - 4);
377        // A cell that does not touch (2,2) is valid and has quality.
378        assert!(mesh.cell_quality(0, 0).is_some());
379        // A cell that touches (2,2) is skipped → quality returns None.
380        assert!(mesh.cell_quality(1, 1).is_none());
381    }
382}