Skip to main content

geonative_processing/raster/
warp.rs

1//! Raster warp — reproject a raster from one CRS to another using
2//! `geonative-proj` for the coordinate transform plus a resampling kernel
3//! for the pixel interpolation.
4//!
5//! ## How it works
6//!
7//! For each output pixel:
8//! 1. Compute its world coords in the target CRS via the target tile's
9//!    `GeoTransform`
10//! 2. Reproject those world coords to the source CRS using the
11//!    `Transformer`
12//! 3. Project source-CRS world coords back to source pixel coords using
13//!    the source tile's `GeoTransform`
14//! 4. Sample the source band at those (fractional) pixel coords using the
15//!    chosen kernel
16//!
17//! ## Memory model
18//!
19//! v0.1 takes a single source `RasterTile` and produces a single output
20//! `RasterTile`. For large inputs the caller should:
21//! - Read source tiles via `RasterLayer::read_tile` (cheap with mmap)
22//! - Warp each tile individually
23//! - Write to output via the `geonative-geotiff` writer
24//!
25//! For tile-server hot paths (per HTTP request), the future
26//! `warp_tile_window` function reads only the source pixels needed for one
27//! output tile — that's a v0.2 add when streaming warp is needed.
28
29use geonative_core::raster::{Band, GeoTransform, RasterTile, ResamplingMethod};
30use geonative_core::{Coord, Crs};
31use geonative_proj::Transformer;
32
33use crate::raster::{pixel, resample};
34
35#[derive(Debug, Clone)]
36pub struct WarpOptions {
37    pub method: ResamplingMethod,
38    /// Fill value for output pixels whose source projection falls outside
39    /// the source extent. Defaults to 0 (works for most U8/U16/I16
40    /// imagery; for DEMs you may want NaN-equivalent).
41    pub nodata: f64,
42}
43
44impl Default for WarpOptions {
45    fn default() -> Self {
46        Self {
47            method: ResamplingMethod::Bilinear,
48            nodata: 0.0,
49        }
50    }
51}
52
53/// Reproject `source` (with its own CRS + geo-transform) onto the target
54/// `(crs, geo_transform, width, height)`. Returns a new `RasterTile` with
55/// the same band descriptors as `source` but in the target CRS.
56pub fn warp_tile(
57    source: &RasterTile,
58    target_crs: &Crs,
59    target_geo_transform: GeoTransform,
60    target_width: u32,
61    target_height: u32,
62    opts: &WarpOptions,
63) -> Result<RasterTile, geonative_proj::ProjError> {
64    // Build a transformer: target → source (we iterate output pixels and
65    // look up where to sample in the source).
66    let transformer = Transformer::from_crs(target_crs, &source.crs)?;
67
68    let bands_out: Vec<Band> = source
69        .bands
70        .iter()
71        .map(|src_band| {
72            let bpp = src_band.descriptor.dtype.size_bytes();
73            Band::new(
74                src_band.descriptor.clone(),
75                vec![0u8; (target_width as usize) * (target_height as usize) * bpp],
76            )
77        })
78        .collect();
79
80    let mut result = RasterTile {
81        width: target_width,
82        height: target_height,
83        bands: bands_out,
84        geo_transform: target_geo_transform,
85        crs: target_crs.clone(),
86    };
87
88    // Source geo-transform inverse (world → source pixel coords).
89    let src_gt = source.geo_transform;
90
91    for ty in 0..target_height as usize {
92        for tx in 0..target_width as usize {
93            // 1) target pixel centre → target world coords
94            let tw = target_geo_transform.pixel_to_world(tx as f64 + 0.5, ty as f64 + 0.5);
95            // 2) target world → source world via transformer (in place)
96            let mut sw = tw;
97            if transformer.transform(&mut sw).is_err() {
98                continue;
99            }
100            // 3) source world → source pixel coords
101            let [sc, sr] = src_gt.world_to_pixel(Coord {
102                x: sw.x,
103                y: sw.y,
104                z: None,
105                m: None,
106            });
107            // 4) sample each band at that fractional position
108            for (band_idx, src_band) in source.bands.iter().enumerate() {
109                let value =
110                    resample::sample(src_band, source.width, source.height, sc, sr, opts.method)
111                        .unwrap_or(opts.nodata);
112                pixel::write(
113                    &mut result.bands[band_idx],
114                    tx,
115                    ty,
116                    target_width as usize,
117                    value,
118                );
119            }
120        }
121    }
122
123    Ok(result)
124}
125
126/// Compute the world-space bounds in the **target** CRS that contain the
127/// reprojected `source` extent. Useful for deciding the size of the
128/// output `RasterTile` when the caller wants "warp everything into target
129/// CRS at a reasonable resolution."
130///
131/// Returns `(target_geo_transform, target_width, target_height)`.
132pub fn compute_target_grid(
133    source: &RasterTile,
134    target_crs: &Crs,
135    target_width: u32,
136) -> Result<(GeoTransform, u32, u32), geonative_proj::ProjError> {
137    let transformer = Transformer::from_crs(&source.crs, target_crs)?;
138    let source_bounds = source.bounds();
139    // Reproject all 4 corners + midpoint to get a target bbox.
140    let mut corners = [
141        Coord::xy(source_bounds[0], source_bounds[1]),
142        Coord::xy(source_bounds[2], source_bounds[1]),
143        Coord::xy(source_bounds[0], source_bounds[3]),
144        Coord::xy(source_bounds[2], source_bounds[3]),
145        // Midpoint helps when the source-to-target reprojection bends edges.
146        Coord::xy(
147            (source_bounds[0] + source_bounds[2]) * 0.5,
148            (source_bounds[1] + source_bounds[3]) * 0.5,
149        ),
150    ];
151    for c in corners.iter_mut() {
152        transformer.transform(c)?;
153    }
154    let xs: Vec<f64> = corners.iter().map(|c| c.x).collect();
155    let ys: Vec<f64> = corners.iter().map(|c| c.y).collect();
156    let xmin = xs.iter().cloned().fold(f64::INFINITY, f64::min);
157    let xmax = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
158    let ymin = ys.iter().cloned().fold(f64::INFINITY, f64::min);
159    let ymax = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
160
161    let pixel_w = (xmax - xmin) / target_width as f64;
162    // Aspect-preserving target height
163    let target_height = (((ymax - ymin) / pixel_w).round() as u32).max(1);
164    let pixel_h = -(ymax - ymin) / target_height as f64;
165
166    let gt = GeoTransform {
167        origin: [xmin, ymax],
168        pixel_size: [pixel_w, pixel_h],
169        rotation: [0.0, 0.0],
170    };
171    Ok((gt, target_width, target_height))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use geonative_core::raster::{BandDescriptor, PixelType};
178
179    /// Build a 4×4 EPSG:4326 tile near Melbourne (lng/lat).
180    fn melbourne_tile_4326() -> RasterTile {
181        let mut data = Vec::with_capacity(16);
182        for r in 0..4u8 {
183            for c in 0..4u8 {
184                data.push(r * 64 + c * 16);
185            }
186        }
187        RasterTile {
188            width: 4,
189            height: 4,
190            bands: vec![Band::new(
191                BandDescriptor::new(Some("v".into()), PixelType::U8),
192                data,
193            )],
194            geo_transform: GeoTransform::north_up(144.9, -37.85, 0.01, 0.01),
195            crs: Crs::Epsg(4326),
196        }
197    }
198
199    #[test]
200    fn warp_4326_to_3857_preserves_shape() {
201        let src = melbourne_tile_4326();
202        let (gt, w, h) = compute_target_grid(&src, &Crs::Epsg(3857), 8).unwrap();
203        let result = warp_tile(&src, &Crs::Epsg(3857), gt, w, h, &WarpOptions::default()).unwrap();
204        assert_eq!(result.width, w);
205        assert_eq!(result.height, h);
206        assert_eq!(result.crs, Crs::Epsg(3857));
207        // At least some output pixels should have non-zero values (we
208        // didn't get all-zero from the warp going through valid coords).
209        assert!(result.bands[0].data.iter().any(|&v| v != 0));
210    }
211
212    #[test]
213    fn warp_round_trip_4326_to_3857_back_to_4326_is_close() {
214        let src = melbourne_tile_4326();
215        // Forward 4326 → 3857
216        let (gt_3857, w_3857, h_3857) = compute_target_grid(&src, &Crs::Epsg(3857), 16).unwrap();
217        let in_3857 = warp_tile(
218            &src,
219            &Crs::Epsg(3857),
220            gt_3857,
221            w_3857,
222            h_3857,
223            &WarpOptions::default(),
224        )
225        .unwrap();
226        // Back 3857 → 4326
227        let (gt_back, w_back, h_back) =
228            compute_target_grid(&in_3857, &Crs::Epsg(4326), 16).unwrap();
229        let back = warp_tile(
230            &in_3857,
231            &Crs::Epsg(4326),
232            gt_back,
233            w_back,
234            h_back,
235            &WarpOptions::default(),
236        )
237        .unwrap();
238        assert_eq!(back.crs, Crs::Epsg(4326));
239        assert!(back.width > 0);
240    }
241
242    #[test]
243    fn warp_identity_crs_keeps_pixels_intact() {
244        let src = melbourne_tile_4326();
245        // Target = source CRS/extent — warp should be ~identity (with
246        // resampling artefacts only at sub-pixel level).
247        let result = warp_tile(
248            &src,
249            &Crs::Epsg(4326),
250            src.geo_transform,
251            src.width,
252            src.height,
253            &WarpOptions::default(),
254        )
255        .unwrap();
256        assert_eq!(result.width, src.width);
257        // Corner pixel should match closely.
258        assert!((result.bands[0].data[0] as i32 - src.bands[0].data[0] as i32).abs() < 5);
259    }
260}