Skip to main content

projective_grid/hex/
rectify.rs

1//! Single global homography from hex grid corners.
2//!
3//! Computes a global projective mapping between a rectified coordinate system
4//! (uniform hex lattice spacing) and the original image. Suitable when lens
5//! distortion is negligible.
6
7use crate::grid_index::GridIndex;
8use crate::homography::{estimate_homography, Homography};
9use nalgebra::Point2;
10use std::collections::HashMap;
11
12/// Sqrt(3) / 2, the vertical spacing factor for pointy-top hex grids.
13const SQRT3_HALF: f64 = 0.866_025_403_784_438_6;
14
15#[derive(thiserror::Error, Debug)]
16pub enum HexRectifyError {
17    #[error("not enough grid corners with positions (need >= 4, got {got})")]
18    NotEnoughPoints { got: usize },
19    #[error("homography estimation failed")]
20    HomographyFailed,
21    #[error("homography not invertible")]
22    NonInvertible,
23}
24
25/// A global homography mapping between rectified hex grid space and image space.
26///
27/// Rectified coordinates map axial `(q, r)` to 2D using:
28/// ```text
29/// x = px_per_cell * (q + r * 0.5)
30/// y = px_per_cell * (r * sqrt(3) / 2)
31/// ```
32#[derive(Clone, Debug)]
33pub struct HexGridHomography {
34    /// Maps rectified coordinates to image coordinates.
35    pub h_img_from_rect: Homography,
36    /// Maps image coordinates to rectified coordinates.
37    pub h_rect_from_img: Homography,
38    /// Axial bounding box (with margin).
39    pub min_q: i32,
40    pub min_r: i32,
41    pub max_q: i32,
42    pub max_r: i32,
43    /// Rectified pixels per grid cell edge.
44    pub px_per_cell: f32,
45    /// Rectified image dimensions.
46    pub rect_width: usize,
47    pub rect_height: usize,
48}
49
50impl HexGridHomography {
51    /// Compute a global homography from hex grid corners to a rectified coordinate system.
52    ///
53    /// - `corners`: map from axial grid index `(q=i, r=j)` to image position.
54    /// - `px_per_cell`: rectified pixels per grid cell edge.
55    /// - `margin_cells`: extra margin around the grid bounding box (in cell units).
56    pub fn from_corners(
57        corners: &HashMap<GridIndex, Point2<f32>>,
58        px_per_cell: f32,
59        margin_cells: f32,
60    ) -> Result<Self, HexRectifyError> {
61        if corners.len() < 4 {
62            return Err(HexRectifyError::NotEnoughPoints { got: corners.len() });
63        }
64
65        // Find axial bounding box
66        let (mut min_q, mut min_r) = (i32::MAX, i32::MAX);
67        let (mut max_q, mut max_r) = (i32::MIN, i32::MIN);
68        for g in corners.keys() {
69            min_q = min_q.min(g.i);
70            min_r = min_r.min(g.j);
71            max_q = max_q.max(g.i);
72            max_r = max_r.max(g.j);
73        }
74
75        // Compute rectified bounding box with margin
76        // x = px_per_cell * (q + r * 0.5), y = px_per_cell * (r * sqrt3/2)
77        // We need to find min/max x and y over all possible (q, r) in bounds
78        let s = px_per_cell as f64;
79        let sqrt3_half = SQRT3_HALF;
80
81        // Compute x-range: x depends on both q and r
82        let mut x_min = f64::MAX;
83        let mut x_max = f64::MIN;
84        let mut y_min = f64::MAX;
85        let mut y_max = f64::MIN;
86
87        for g in corners.keys() {
88            let x = s * (g.i as f64 + g.j as f64 * 0.5);
89            let y = s * (g.j as f64 * sqrt3_half);
90            x_min = x_min.min(x);
91            x_max = x_max.max(x);
92            y_min = y_min.min(y);
93            y_max = y_max.max(y);
94        }
95
96        let margin_px = margin_cells as f64 * s;
97        x_min -= margin_px;
98        y_min -= margin_px;
99        x_max += margin_px;
100        y_max += margin_px;
101
102        let rect_width = ((x_max - x_min).round().max(1.0)) as usize;
103        let rect_height = ((y_max - y_min).round().max(1.0)) as usize;
104
105        // Build correspondences: rectified positions vs image positions
106        let mut rect_pts = Vec::with_capacity(corners.len());
107        let mut img_pts = Vec::with_capacity(corners.len());
108        for (g, &pos) in corners {
109            let rx = s * (g.i as f64 + g.j as f64 * 0.5) - x_min;
110            let ry = s * (g.j as f64 * sqrt3_half) - y_min;
111            rect_pts.push(Point2::new(rx as f32, ry as f32));
112            img_pts.push(pos);
113        }
114
115        let h_img_from_rect =
116            estimate_homography(&rect_pts, &img_pts).ok_or(HexRectifyError::HomographyFailed)?;
117
118        let h_rect_from_img = h_img_from_rect
119            .inverse()
120            .ok_or(HexRectifyError::NonInvertible)?;
121
122        // Apply margin to axial bounds
123        let mq = min_q - margin_cells.ceil() as i32;
124        let mr = min_r - margin_cells.ceil() as i32;
125        let aq = max_q + margin_cells.ceil() as i32;
126        let ar = max_r + margin_cells.ceil() as i32;
127
128        Ok(Self {
129            h_img_from_rect,
130            h_rect_from_img,
131            min_q: mq,
132            min_r: mr,
133            max_q: aq,
134            max_r: ar,
135            px_per_cell,
136            rect_width,
137            rect_height,
138        })
139    }
140
141    /// Map a point from rectified space to image space.
142    pub fn rect_to_img(&self, p_rect: Point2<f32>) -> Point2<f32> {
143        self.h_img_from_rect.apply(p_rect)
144    }
145
146    /// Map a point from image space to rectified space.
147    pub fn img_to_rect(&self, p_img: Point2<f32>) -> Point2<f32> {
148        self.h_rect_from_img.apply(p_img)
149    }
150
151    /// Convert axial coordinates `(q, r)` to rectified pixel coordinates.
152    ///
153    /// This does **not** apply the homography — it maps grid indices to the
154    /// rectified coordinate system directly.
155    pub fn axial_to_rect(&self, q: f64, r: f64) -> Point2<f64> {
156        let s = self.px_per_cell as f64;
157        // We need the same x_min, y_min used during construction.
158        // Reconstruct from stored bounds (approximate — for exact use, store offsets).
159        // For now, use the stored min_q/min_r with margin to reconstruct.
160        let x = s * (q + r * 0.5);
161        let y = s * (r * SQRT3_HALF);
162        Point2::new(x, y)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn make_hex_corners(radius: i32, spacing: f32) -> HashMap<GridIndex, Point2<f32>> {
171        let sqrt3 = 3.0f32.sqrt();
172        let mut map = HashMap::new();
173        for q in -radius..=radius {
174            for r in -radius..=radius {
175                if (q + r).abs() > radius {
176                    continue;
177                }
178                let x = spacing * (q as f32 + r as f32 * 0.5);
179                let y = spacing * (r as f32 * sqrt3 / 2.0);
180                map.insert(GridIndex { i: q, j: r }, Point2::new(x, y));
181            }
182        }
183        map
184    }
185
186    #[test]
187    fn round_trip_rect_to_img() {
188        let corners = make_hex_corners(3, 60.0);
189        let h = HexGridHomography::from_corners(&corners, 60.0, 1.0).unwrap();
190
191        for &pos in corners.values() {
192            let rect = h.img_to_rect(pos);
193            let recovered = h.rect_to_img(rect);
194            assert!(
195                (recovered.x - pos.x).abs() < 0.5,
196                "x: {} vs {}",
197                recovered.x,
198                pos.x
199            );
200            assert!(
201                (recovered.y - pos.y).abs() < 0.5,
202                "y: {} vs {}",
203                recovered.y,
204                pos.y
205            );
206        }
207    }
208
209    #[test]
210    fn identity_case_with_ideal_positions() {
211        // When corners are at ideal hex positions, the homography should be
212        // close to a translation (the offset to make coordinates non-negative).
213        let corners = make_hex_corners(2, 50.0);
214        let h = HexGridHomography::from_corners(&corners, 50.0, 0.0).unwrap();
215
216        assert!(h.rect_width > 0);
217        assert!(h.rect_height > 0);
218
219        // The homography should map rectified positions close to image positions
220        for &img_pos in corners.values() {
221            // Verify round-trip: the rectified offset makes direct comparison tricky.
222            let rect_pos = h.img_to_rect(img_pos);
223            let recovered = h.rect_to_img(rect_pos);
224            assert!((recovered.x - img_pos.x).abs() < 0.1);
225            assert!((recovered.y - img_pos.y).abs() < 0.1);
226        }
227    }
228
229    #[test]
230    fn too_few_corners_errors() {
231        let mut corners = HashMap::new();
232        corners.insert(GridIndex { i: 0, j: 0 }, Point2::new(0.0, 0.0));
233        corners.insert(GridIndex { i: 1, j: 0 }, Point2::new(50.0, 0.0));
234
235        let result = HexGridHomography::from_corners(&corners, 50.0, 0.0);
236        assert!(result.is_err());
237    }
238}