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