Skip to main content

geotiff_reader/
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    /// Convert pixel coordinates (col, row) to map coordinates (x, y).
73    pub fn pixel_to_geo(&self, col: f64, row: f64) -> (f64, f64) {
74        let x = self.origin_x + col * self.pixel_width + row * self.skew_x;
75        let y = self.origin_y + col * self.skew_y + row * self.pixel_height;
76        (x, y)
77    }
78
79    /// Convert map coordinates (x, y) to pixel coordinates (col, row).
80    ///
81    /// Returns `None` if the transform is degenerate (zero determinant).
82    pub fn geo_to_pixel(&self, x: f64, y: f64) -> Option<(f64, f64)> {
83        let det = self.pixel_width * self.pixel_height - self.skew_x * self.skew_y;
84        if det.abs() < 1e-15 {
85            return None;
86        }
87        let dx = x - self.origin_x;
88        let dy = y - self.origin_y;
89        let col = (self.pixel_height * dx - self.skew_x * dy) / det;
90        let row = (-self.skew_y * dx + self.pixel_width * dy) / det;
91        Some((col, row))
92    }
93
94    /// Returns the geographic bounds (min_x, min_y, max_x, max_y) for an image
95    /// of the given width and height.
96    pub fn bounds(&self, width: u32, height: u32) -> [f64; 4] {
97        let corners = [
98            self.pixel_to_geo(0.0, 0.0),
99            self.pixel_to_geo(width as f64, 0.0),
100            self.pixel_to_geo(0.0, height as f64),
101            self.pixel_to_geo(width as f64, height as f64),
102        ];
103        let min_x = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
104        let max_x = corners
105            .iter()
106            .map(|c| c.0)
107            .fold(f64::NEG_INFINITY, f64::max);
108        let min_y = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
109        let max_y = corners
110            .iter()
111            .map(|c| c.1)
112            .fold(f64::NEG_INFINITY, f64::max);
113        [min_x, min_y, max_x, max_y]
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::crs::RasterType;
121
122    #[test]
123    fn tiepoint_and_scale_roundtrip() {
124        let tp = [0.0, 0.0, 0.0, -180.0, 90.0, 0.0];
125        let scale = [0.1, 0.1, 0.0];
126        let gt = GeoTransform::from_tiepoint_and_scale(&tp, &scale);
127
128        let (x, y) = gt.pixel_to_geo(0.0, 0.0);
129        assert!((x - (-180.0)).abs() < 1e-10);
130        assert!((y - 90.0).abs() < 1e-10);
131
132        let (x2, y2) = gt.pixel_to_geo(10.0, 10.0);
133        assert!((x2 - (-179.0)).abs() < 1e-10);
134        assert!((y2 - 89.0).abs() < 1e-10);
135
136        let (col, row) = gt.geo_to_pixel(x2, y2).unwrap();
137        assert!((col - 10.0).abs() < 1e-10);
138        assert!((row - 10.0).abs() < 1e-10);
139    }
140
141    #[test]
142    fn bounds_calculation() {
143        let tp = [0.0, 0.0, 0.0, 0.0, 10.0, 0.0];
144        let scale = [1.0, 1.0, 0.0];
145        let gt = GeoTransform::from_tiepoint_and_scale(&tp, &scale);
146        let bounds = gt.bounds(10, 10);
147        assert!((bounds[0] - 0.0).abs() < 1e-10); // min_x
148        assert!((bounds[1] - 0.0).abs() < 1e-10); // min_y
149        assert!((bounds[2] - 10.0).abs() < 1e-10); // max_x
150        assert!((bounds[3] - 10.0).abs() < 1e-10); // max_y
151    }
152
153    #[test]
154    fn pixel_is_point_tiepoint_is_normalized_to_outer_bounds() {
155        let tp = [0.0, 0.0, 0.0, 100.0, 200.0, 0.0];
156        let scale = [2.0, 2.0, 0.0];
157        let gt = GeoTransform::from_tiepoint_and_scale_with_raster_type(
158            &tp,
159            &scale,
160            RasterType::PixelIsPoint,
161        );
162
163        let (min_x, max_y) = gt.pixel_to_geo(0.0, 0.0);
164        assert!((min_x - 99.0).abs() < 1e-10);
165        assert!((max_y - 201.0).abs() < 1e-10);
166
167        let (center_x, center_y) = gt.pixel_to_geo(0.5, 0.5);
168        assert!((center_x - 100.0).abs() < 1e-10);
169        assert!((center_y - 200.0).abs() < 1e-10);
170    }
171}