Skip to main content

geotiff_core/
transform.rs

1//! Geo-transform: pixel coordinates to/from geographic coordinates.
2
3use crate::crs::RasterType;
4
5/// An affine geo-transform mapping pixel (col, row) to map (x, y).
6///
7/// Follows the GDAL convention:
8/// ```text
9/// x = origin_x + col * pixel_width + row * skew_x
10/// y = origin_y + col * skew_y     + row * pixel_height
11/// ```
12///
13/// For north-up images, `skew_x` and `skew_y` are 0 and `pixel_height` is negative.
14#[derive(Debug, Clone, Copy)]
15pub struct GeoTransform {
16    pub origin_x: f64,
17    pub pixel_width: f64,
18    pub skew_x: f64,
19    pub origin_y: f64,
20    pub skew_y: f64,
21    pub pixel_height: f64,
22}
23
24impl GeoTransform {
25    /// Build from ModelTiepoint (tag 33922) and ModelPixelScale (tag 33550).
26    pub fn from_tiepoint_and_scale(tiepoint: &[f64; 6], pixel_scale: &[f64; 3]) -> Self {
27        Self::from_tiepoint_and_scale_with_raster_type(
28            tiepoint,
29            pixel_scale,
30            RasterType::PixelIsArea,
31        )
32    }
33
34    /// Build from ModelTiepoint and ModelPixelScale using the GeoTIFF raster type.
35    ///
36    /// The returned transform is normalized to a corner-based affine transform so
37    /// bounds and pixel-space math stay consistent for both PixelIsArea and
38    /// PixelIsPoint rasters.
39    pub fn from_tiepoint_and_scale_with_raster_type(
40        tiepoint: &[f64; 6],
41        pixel_scale: &[f64; 3],
42        raster_type: RasterType,
43    ) -> Self {
44        // tiepoint: [I, J, K, X, Y, Z]
45        // pixel_scale: [ScaleX, ScaleY, ScaleZ]
46        let pixel_offset = match raster_type {
47            RasterType::PixelIsPoint => 0.5,
48            RasterType::PixelIsArea | RasterType::Unknown(_) => 0.0,
49        };
50        Self {
51            origin_x: tiepoint[3] - (tiepoint[0] + pixel_offset) * pixel_scale[0],
52            pixel_width: pixel_scale[0],
53            skew_x: 0.0,
54            origin_y: tiepoint[4] + (tiepoint[1] + pixel_offset) * pixel_scale[1],
55            skew_y: 0.0,
56            pixel_height: -pixel_scale[1],
57        }
58    }
59
60    /// Build from a 4x4 ModelTransformation matrix (tag 34264), row-major.
61    pub fn from_transformation_matrix(matrix: &[f64; 16]) -> Self {
62        Self {
63            origin_x: matrix[3],
64            pixel_width: matrix[0],
65            skew_x: matrix[1],
66            origin_y: matrix[7],
67            skew_y: matrix[4],
68            pixel_height: matrix[5],
69        }
70    }
71
72    /// Create from origin + pixel size (north-up, no skew).
73    pub fn from_origin_and_pixel_size(
74        origin_x: f64,
75        origin_y: f64,
76        pixel_width: f64,
77        pixel_height: f64,
78    ) -> Self {
79        Self {
80            origin_x,
81            pixel_width,
82            skew_x: 0.0,
83            origin_y,
84            skew_y: 0.0,
85            pixel_height,
86        }
87    }
88
89    /// Convert pixel coordinates (col, row) to map coordinates (x, y).
90    pub fn pixel_to_geo(&self, col: f64, row: f64) -> (f64, f64) {
91        let x = self.origin_x + col * self.pixel_width + row * self.skew_x;
92        let y = self.origin_y + col * self.skew_y + row * self.pixel_height;
93        (x, y)
94    }
95
96    /// Convert map coordinates (x, y) to pixel coordinates (col, row).
97    ///
98    /// Returns `None` if the transform is degenerate or numerically singular.
99    pub fn geo_to_pixel(&self, x: f64, y: f64) -> Option<(f64, f64)> {
100        let det = self.pixel_width * self.pixel_height - self.skew_x * self.skew_y;
101        let determinant_scale =
102            (self.pixel_width * self.pixel_height).abs() + (self.skew_x * self.skew_y).abs();
103        if !det.is_finite() || det == 0.0 || det.abs() <= f64::EPSILON * determinant_scale {
104            return None;
105        }
106        let dx = x - self.origin_x;
107        let dy = y - self.origin_y;
108        let col = (self.pixel_height * dx - self.skew_x * dy) / det;
109        let row = (-self.skew_y * dx + self.pixel_width * dy) / det;
110        Some((col, row))
111    }
112
113    /// Returns the geographic bounds (min_x, min_y, max_x, max_y) for an image
114    /// of the given width and height.
115    pub fn bounds(&self, width: u32, height: u32) -> [f64; 4] {
116        let corners = [
117            self.pixel_to_geo(0.0, 0.0),
118            self.pixel_to_geo(width as f64, 0.0),
119            self.pixel_to_geo(0.0, height as f64),
120            self.pixel_to_geo(width as f64, height as f64),
121        ];
122        let min_x = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
123        let max_x = corners
124            .iter()
125            .map(|c| c.0)
126            .fold(f64::NEG_INFINITY, f64::max);
127        let min_y = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
128        let max_y = corners
129            .iter()
130            .map(|c| c.1)
131            .fold(f64::NEG_INFINITY, f64::max);
132        [min_x, min_y, max_x, max_y]
133    }
134
135    /// Serialize to a tiepoint + pixel_scale pair (for north-up, no-skew images).
136    /// Returns `None` if there is skew (use `to_transformation_matrix` instead).
137    ///
138    /// This uses PixelIsArea semantics. Use
139    /// [`Self::to_tiepoint_and_scale_with_raster_type`] when writing a GeoTIFF
140    /// with an explicit raster type.
141    pub fn to_tiepoint_and_scale(&self) -> Option<([f64; 6], [f64; 3])> {
142        self.to_tiepoint_and_scale_with_raster_type(RasterType::PixelIsArea)
143    }
144
145    /// Serialize to a tiepoint + pixel_scale pair using the GeoTIFF raster type.
146    ///
147    /// The transform is stored internally as a corner-based affine transform.
148    /// PixelIsPoint tiepoints, however, refer to pixel centers, so the emitted
149    /// tiepoint is shifted by half a pixel to roundtrip through
150    /// [`Self::from_tiepoint_and_scale_with_raster_type`] without changing the
151    /// normalized transform.
152    pub fn to_tiepoint_and_scale_with_raster_type(
153        &self,
154        raster_type: RasterType,
155    ) -> Option<([f64; 6], [f64; 3])> {
156        // ModelPixelScale values are positive by definition. Preserve flipped
157        // axes and every non-zero skew exactly by using ModelTransformation
158        // instead of silently approximating them as north-up.
159        if self.skew_x != 0.0
160            || self.skew_y != 0.0
161            || !self.pixel_width.is_finite()
162            || !self.pixel_height.is_finite()
163            || self.pixel_width <= 0.0
164            || self.pixel_height >= 0.0
165        {
166            return None;
167        }
168        let scale = [self.pixel_width, -self.pixel_height, 0.0];
169        let pixel_offset = match raster_type {
170            RasterType::PixelIsPoint => 0.5,
171            RasterType::PixelIsArea | RasterType::Unknown(_) => 0.0,
172        };
173        let tiepoint = [
174            0.0,
175            0.0,
176            0.0,
177            self.origin_x + pixel_offset * scale[0],
178            self.origin_y - pixel_offset * scale[1],
179            0.0,
180        ];
181        Some((tiepoint, scale))
182    }
183
184    /// Serialize to a 4x4 transformation matrix (row-major).
185    pub fn to_transformation_matrix(&self) -> [f64; 16] {
186        [
187            self.pixel_width,
188            self.skew_x,
189            0.0,
190            self.origin_x,
191            self.skew_y,
192            self.pixel_height,
193            0.0,
194            self.origin_y,
195            0.0,
196            0.0,
197            0.0,
198            0.0,
199            0.0,
200            0.0,
201            0.0,
202            1.0,
203        ]
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::crs::RasterType;
211
212    #[test]
213    fn tiepoint_and_scale_roundtrip() {
214        let tp = [0.0, 0.0, 0.0, -180.0, 90.0, 0.0];
215        let scale = [0.1, 0.1, 0.0];
216        let gt = GeoTransform::from_tiepoint_and_scale(&tp, &scale);
217
218        let (x, y) = gt.pixel_to_geo(0.0, 0.0);
219        assert!((x - (-180.0)).abs() < 1e-10);
220        assert!((y - 90.0).abs() < 1e-10);
221
222        let (x2, y2) = gt.pixel_to_geo(10.0, 10.0);
223        assert!((x2 - (-179.0)).abs() < 1e-10);
224        assert!((y2 - 89.0).abs() < 1e-10);
225
226        let (col, row) = gt.geo_to_pixel(x2, y2).unwrap();
227        assert!((col - 10.0).abs() < 1e-10);
228        assert!((row - 10.0).abs() < 1e-10);
229    }
230
231    #[test]
232    fn bounds_calculation() {
233        let tp = [0.0, 0.0, 0.0, 0.0, 10.0, 0.0];
234        let scale = [1.0, 1.0, 0.0];
235        let gt = GeoTransform::from_tiepoint_and_scale(&tp, &scale);
236        let bounds = gt.bounds(10, 10);
237        assert!((bounds[0] - 0.0).abs() < 1e-10);
238        assert!((bounds[1] - 0.0).abs() < 1e-10);
239        assert!((bounds[2] - 10.0).abs() < 1e-10);
240        assert!((bounds[3] - 10.0).abs() < 1e-10);
241    }
242
243    #[test]
244    fn pixel_is_point_tiepoint_is_normalized_to_outer_bounds() {
245        let tp = [0.0, 0.0, 0.0, 100.0, 200.0, 0.0];
246        let scale = [2.0, 2.0, 0.0];
247        let gt = GeoTransform::from_tiepoint_and_scale_with_raster_type(
248            &tp,
249            &scale,
250            RasterType::PixelIsPoint,
251        );
252
253        let (min_x, max_y) = gt.pixel_to_geo(0.0, 0.0);
254        assert!((min_x - 99.0).abs() < 1e-10);
255        assert!((max_y - 201.0).abs() < 1e-10);
256
257        let (center_x, center_y) = gt.pixel_to_geo(0.5, 0.5);
258        assert!((center_x - 100.0).abs() < 1e-10);
259        assert!((center_y - 200.0).abs() < 1e-10);
260    }
261
262    #[test]
263    fn to_tiepoint_and_scale_roundtrips() {
264        let gt = GeoTransform::from_origin_and_pixel_size(-180.0, 90.0, 0.1, -0.1);
265        let (tp, scale) = gt.to_tiepoint_and_scale().unwrap();
266        let gt2 = GeoTransform::from_tiepoint_and_scale(&tp, &scale);
267        assert!((gt2.origin_x - gt.origin_x).abs() < 1e-10);
268        assert!((gt2.origin_y - gt.origin_y).abs() < 1e-10);
269        assert!((gt2.pixel_width - gt.pixel_width).abs() < 1e-10);
270        assert!((gt2.pixel_height - gt.pixel_height).abs() < 1e-10);
271    }
272
273    #[test]
274    fn pixel_is_point_tiepoint_and_scale_roundtrips_normalized_transform() {
275        let gt = GeoTransform::from_origin_and_pixel_size(99.0, 201.0, 2.0, -2.0);
276        let (tp, scale) = gt
277            .to_tiepoint_and_scale_with_raster_type(RasterType::PixelIsPoint)
278            .unwrap();
279        assert!((tp[3] - 100.0).abs() < 1e-10);
280        assert!((tp[4] - 200.0).abs() < 1e-10);
281
282        let gt2 = GeoTransform::from_tiepoint_and_scale_with_raster_type(
283            &tp,
284            &scale,
285            RasterType::PixelIsPoint,
286        );
287        assert!((gt2.origin_x - gt.origin_x).abs() < 1e-10);
288        assert!((gt2.origin_y - gt.origin_y).abs() < 1e-10);
289        assert!((gt2.pixel_width - gt.pixel_width).abs() < 1e-10);
290        assert!((gt2.pixel_height - gt.pixel_height).abs() < 1e-10);
291    }
292
293    #[test]
294    fn skewed_transform_returns_none_for_tiepoint_scale() {
295        let gt = GeoTransform {
296            origin_x: 0.0,
297            pixel_width: 1.0,
298            skew_x: 0.5,
299            origin_y: 0.0,
300            skew_y: 0.0,
301            pixel_height: -1.0,
302        };
303        assert!(gt.to_tiepoint_and_scale().is_none());
304    }
305
306    #[test]
307    fn tiny_but_invertible_transform_roundtrips() {
308        let gt = GeoTransform::from_origin_and_pixel_size(10.0, 20.0, 1e-12, -1e-12);
309        let (x, y) = gt.pixel_to_geo(3.0, 4.0);
310        let (col, row) = gt.geo_to_pixel(x, y).unwrap();
311        assert!((col - 3.0).abs() < 1e-3);
312        assert!((row - 4.0).abs() < 1e-3);
313    }
314
315    #[test]
316    fn flipped_or_tiny_skewed_transforms_require_a_matrix() {
317        let flipped = GeoTransform::from_origin_and_pixel_size(0.0, 0.0, -1.0, -1.0);
318        assert!(flipped.to_tiepoint_and_scale().is_none());
319
320        let skewed = GeoTransform {
321            origin_x: 0.0,
322            pixel_width: 1.0,
323            skew_x: 1e-16,
324            origin_y: 0.0,
325            skew_y: 0.0,
326            pixel_height: -1.0,
327        };
328        assert!(skewed.to_tiepoint_and_scale().is_none());
329    }
330
331    #[test]
332    fn transformation_matrix_roundtrips() {
333        let gt = GeoTransform::from_origin_and_pixel_size(100.0, 200.0, 0.5, -0.5);
334        let matrix = gt.to_transformation_matrix();
335        let gt2 = GeoTransform::from_transformation_matrix(&matrix);
336        assert!((gt2.origin_x - gt.origin_x).abs() < 1e-10);
337        assert!((gt2.origin_y - gt.origin_y).abs() < 1e-10);
338        assert!((gt2.pixel_width - gt.pixel_width).abs() < 1e-10);
339        assert!((gt2.pixel_height - gt.pixel_height).abs() < 1e-10);
340    }
341}