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