Skip to main content

geonative_processing/raster/
resample.rs

1//! Resampling kernels — the math that makes pyramid building and raster
2//! warp possible.
3//!
4//! ## API shape
5//!
6//! Two layers of API:
7//!
8//! - **Point sampling** (`sample_*`): given a band + fractional `(x, y)`
9//!   in source pixel space, return the resampled value. Used by `warp` to
10//!   map one output pixel to its source value.
11//! - **Tile-to-tile** ([`resample_tile`]): given a whole source
12//!   `RasterTile` + target dimensions, return a new `RasterTile` resampled
13//!   across all bands. Used by `pyramid` to downsample for overview levels.
14//!
15//! ## Kernels covered
16//!
17//! - [`ResamplingMethod::Nearest`] — fastest; preserves categorical values
18//!   exactly. Use for landcover/classification.
19//! - [`ResamplingMethod::Bilinear`] — 2×2 weighted average. The default for
20//!   continuous imagery (DEMs, RGB photos).
21//! - [`ResamplingMethod::Cubic`] — 4×4 cubic-convolution kernel. Sharper
22//!   than bilinear at moderate extra cost.
23//! - [`ResamplingMethod::Lanczos`] — 6×6 windowed-sinc. Highest quality;
24//!   slowest. Used for final-output downsampling where artefacts matter.
25//! - [`ResamplingMethod::Average`] — area-weighted mean. Best for building
26//!   pyramid overviews of continuous data (preserves means under
27//!   downsampling, unlike bilinear).
28//! - [`ResamplingMethod::Mode`] — most-frequent value within target area.
29//!   For categorical pyramid overviews.
30
31use geonative_core::raster::{Band, RasterTile, ResamplingMethod};
32
33use crate::raster::pixel;
34
35/// Sample one fractional `(x, y)` from `band` using the chosen method.
36///
37/// `x` and `y` are in source pixel coordinates — `(0.5, 0.5)` is the centre
38/// of pixel `(0, 0)`, `(1.5, 0.5)` is the centre of pixel `(1, 0)`, etc.
39/// Returns `None` if out of bounds or the dtype is unsupported.
40pub fn sample(
41    band: &Band,
42    width: u32,
43    height: u32,
44    x: f64,
45    y: f64,
46    method: ResamplingMethod,
47) -> Option<f64> {
48    let w = width as usize;
49    let h = height as usize;
50    match method {
51        ResamplingMethod::Nearest => sample_nearest(band, w, h, x, y),
52        ResamplingMethod::Bilinear => sample_bilinear(band, w, h, x, y),
53        ResamplingMethod::Cubic => sample_cubic(band, w, h, x, y),
54        ResamplingMethod::Lanczos => sample_lanczos(band, w, h, x, y, 3),
55        // Average / Mode are area-based: they collapse a SOURCE region to one
56        // value, not a point. For point-sampling we fall through to bilinear
57        // — the caller wanted a point read, not an area collapse.
58        ResamplingMethod::Average | ResamplingMethod::Mode => sample_bilinear(band, w, h, x, y),
59        _ => sample_nearest(band, w, h, x, y),
60    }
61}
62
63fn sample_nearest(band: &Band, w: usize, h: usize, x: f64, y: f64) -> Option<f64> {
64    let c = x.floor() as isize;
65    let r = y.floor() as isize;
66    if c < 0 || r < 0 || (c as usize) >= w || (r as usize) >= h {
67        return None;
68    }
69    pixel::read(band, c as usize, r as usize, w)
70}
71
72fn sample_bilinear(band: &Band, w: usize, h: usize, x: f64, y: f64) -> Option<f64> {
73    // Bilinear samples a 2×2 neighbourhood at pixel-centre coords.
74    // Subtract 0.5 so x/y describe distance from the centre of pixel (0, 0).
75    let xc = x - 0.5;
76    let yc = y - 0.5;
77    let c0 = xc.floor() as isize;
78    let r0 = yc.floor() as isize;
79    let dx = xc - c0 as f64;
80    let dy = yc - r0 as f64;
81    let p00 = read_clamped(band, c0, r0, w, h)?;
82    let p10 = read_clamped(band, c0 + 1, r0, w, h)?;
83    let p01 = read_clamped(band, c0, r0 + 1, w, h)?;
84    let p11 = read_clamped(band, c0 + 1, r0 + 1, w, h)?;
85    let top = p00 * (1.0 - dx) + p10 * dx;
86    let bot = p01 * (1.0 - dx) + p11 * dx;
87    Some(top * (1.0 - dy) + bot * dy)
88}
89
90fn sample_cubic(band: &Band, w: usize, h: usize, x: f64, y: f64) -> Option<f64> {
91    // Catmull-Rom cubic kernel — 4×4 neighbourhood.
92    let xc = x - 0.5;
93    let yc = y - 0.5;
94    let c0 = xc.floor() as isize;
95    let r0 = yc.floor() as isize;
96    let dx = xc - c0 as f64;
97    let dy = yc - r0 as f64;
98
99    let mut rows = [0.0f64; 4];
100    for (j, row_slot) in rows.iter_mut().enumerate() {
101        let r = r0 + j as isize - 1;
102        let mut row = [0.0f64; 4];
103        for (i, slot) in row.iter_mut().enumerate() {
104            let c = c0 + i as isize - 1;
105            *slot = read_clamped(band, c, r, w, h).unwrap_or(0.0);
106        }
107        *row_slot = cubic_interp(&row, dx);
108    }
109    Some(cubic_interp(&rows, dy))
110}
111
112/// Catmull-Rom cubic interpolation over 4 samples.
113fn cubic_interp(p: &[f64; 4], t: f64) -> f64 {
114    let t2 = t * t;
115    let t3 = t2 * t;
116    0.5 * ((2.0 * p[1])
117        + (-p[0] + p[2]) * t
118        + (2.0 * p[0] - 5.0 * p[1] + 4.0 * p[2] - p[3]) * t2
119        + (-p[0] + 3.0 * p[1] - 3.0 * p[2] + p[3]) * t3)
120}
121
122fn sample_lanczos(band: &Band, w: usize, h: usize, x: f64, y: f64, a: i32) -> Option<f64> {
123    let xc = x - 0.5;
124    let yc = y - 0.5;
125    let cf = xc.floor();
126    let rf = yc.floor();
127    let dx = xc - cf;
128    let dy = yc - rf;
129
130    let mut num = 0.0f64;
131    let mut den = 0.0f64;
132    for j in -(a - 1)..=a {
133        let r = rf as isize + j as isize;
134        let ly = lanczos_kernel(j as f64 - dy, a);
135        for i in -(a - 1)..=a {
136            let c = cf as isize + i as isize;
137            let lx = lanczos_kernel(i as f64 - dx, a);
138            let w_ij = lx * ly;
139            let p = read_clamped(band, c, r, w, h).unwrap_or(0.0);
140            num += w_ij * p;
141            den += w_ij;
142        }
143    }
144    if den.abs() < 1e-12 {
145        None
146    } else {
147        Some(num / den)
148    }
149}
150
151fn lanczos_kernel(x: f64, a: i32) -> f64 {
152    use std::f64::consts::PI;
153    if x.abs() < 1e-12 {
154        return 1.0;
155    }
156    let af = a as f64;
157    if x.abs() >= af {
158        return 0.0;
159    }
160    let px = PI * x;
161    (af * (px).sin() * (px / af).sin()) / (px * px)
162}
163
164fn read_clamped(band: &Band, c: isize, r: isize, w: usize, h: usize) -> Option<f64> {
165    let cc = c.clamp(0, w as isize - 1) as usize;
166    let rr = r.clamp(0, h as isize - 1) as usize;
167    pixel::read(band, cc, rr, w)
168}
169
170// --- Tile-to-tile resampling ----------------------------------------------
171
172/// Resize a `RasterTile` to new `(target_width, target_height)`. Used by
173/// pyramid building and warp output preparation.
174pub fn resample_tile(
175    source: &RasterTile,
176    target_width: u32,
177    target_height: u32,
178    method: ResamplingMethod,
179) -> RasterTile {
180    let sw = source.width as f64;
181    let sh = source.height as f64;
182    let tw = target_width.max(1);
183    let th = target_height.max(1);
184    let sx = sw / tw as f64;
185    let sy = sh / th as f64;
186
187    let mut bands = Vec::with_capacity(source.bands.len());
188    for src_band in &source.bands {
189        let mut dst_band = Band::new(
190            src_band.descriptor.clone(),
191            vec![0u8; (tw as usize) * (th as usize) * src_band.descriptor.dtype.size_bytes()],
192        );
193
194        // Area-based downsampling needs a different inner loop.
195        let is_downsample = sx > 1.0 || sy > 1.0;
196        let use_area_kernel =
197            matches!(method, ResamplingMethod::Average | ResamplingMethod::Mode) && is_downsample;
198
199        for ty in 0..th as usize {
200            for tx in 0..tw as usize {
201                let value = if use_area_kernel {
202                    let x0 = tx as f64 * sx;
203                    let x1 = (tx + 1) as f64 * sx;
204                    let y0 = ty as f64 * sy;
205                    let y1 = (ty + 1) as f64 * sy;
206                    match method {
207                        ResamplingMethod::Average => area_average(
208                            src_band,
209                            source.width as usize,
210                            source.height as usize,
211                            x0,
212                            y0,
213                            x1,
214                            y1,
215                        ),
216                        ResamplingMethod::Mode => area_mode(
217                            src_band,
218                            source.width as usize,
219                            source.height as usize,
220                            x0,
221                            y0,
222                            x1,
223                            y1,
224                        ),
225                        _ => unreachable!(),
226                    }
227                } else {
228                    // Point-sample mapped from target pixel centre back to source.
229                    let sxp = (tx as f64 + 0.5) * sx;
230                    let syp = (ty as f64 + 0.5) * sy;
231                    sample(src_band, source.width, source.height, sxp, syp, method)
232                };
233                if let Some(v) = value {
234                    pixel::write(&mut dst_band, tx, ty, tw as usize, v);
235                }
236            }
237        }
238        bands.push(dst_band);
239    }
240
241    // Adjust geo-transform so the destination still maps to the same world
242    // region. Pixel size scales by source/target ratio per axis.
243    let mut gt = source.geo_transform;
244    gt.pixel_size[0] *= sx;
245    gt.pixel_size[1] *= sy;
246
247    RasterTile {
248        width: tw,
249        height: th,
250        bands,
251        geo_transform: gt,
252        crs: source.crs.clone(),
253    }
254}
255
256fn area_average(
257    band: &Band,
258    w: usize,
259    h: usize,
260    x0: f64,
261    y0: f64,
262    x1: f64,
263    y1: f64,
264) -> Option<f64> {
265    let c0 = (x0.floor() as isize).max(0);
266    let c1 = ((x1.ceil() as isize) - 1).min(w as isize - 1);
267    let r0 = (y0.floor() as isize).max(0);
268    let r1 = ((y1.ceil() as isize) - 1).min(h as isize - 1);
269    if c1 < c0 || r1 < r0 {
270        return None;
271    }
272    let mut sum = 0.0f64;
273    let mut count = 0usize;
274    for r in r0..=r1 {
275        for c in c0..=c1 {
276            if let Some(v) = pixel::read(band, c as usize, r as usize, w) {
277                sum += v;
278                count += 1;
279            }
280        }
281    }
282    if count == 0 {
283        None
284    } else {
285        Some(sum / count as f64)
286    }
287}
288
289fn area_mode(band: &Band, w: usize, h: usize, x0: f64, y0: f64, x1: f64, y1: f64) -> Option<f64> {
290    use std::collections::HashMap;
291    let c0 = (x0.floor() as isize).max(0);
292    let c1 = ((x1.ceil() as isize) - 1).min(w as isize - 1);
293    let r0 = (y0.floor() as isize).max(0);
294    let r1 = ((y1.ceil() as isize) - 1).min(h as isize - 1);
295    if c1 < c0 || r1 < r0 {
296        return None;
297    }
298    let mut counts: HashMap<u64, u32> = HashMap::new();
299    for r in r0..=r1 {
300        for c in c0..=c1 {
301            if let Some(v) = pixel::read(band, c as usize, r as usize, w) {
302                // Bucket by f64 bit pattern — categorical / integer values
303                // round-trip exactly through f64.
304                *counts.entry(v.to_bits()).or_insert(0) += 1;
305            }
306        }
307    }
308    counts
309        .into_iter()
310        .max_by_key(|&(_, c)| c)
311        .map(|(bits, _)| f64::from_bits(bits))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use geonative_core::raster::{BandDescriptor, GeoTransform, PixelType};
318    use geonative_core::Crs;
319
320    fn u8_tile(width: u32, height: u32, fill: u8) -> RasterTile {
321        let pixels = (width as usize) * (height as usize);
322        RasterTile {
323            width,
324            height,
325            bands: vec![Band::new(
326                BandDescriptor::new(Some("v".into()), PixelType::U8),
327                vec![fill; pixels],
328            )],
329            geo_transform: GeoTransform::north_up(0.0, 0.0, 1.0, 1.0),
330            crs: Crs::Epsg(3857),
331        }
332    }
333
334    /// A 4×4 tile with a horizontal gradient 0..3 across columns.
335    fn gradient_tile() -> RasterTile {
336        let mut data = Vec::with_capacity(16);
337        for _r in 0..4u8 {
338            for c in 0..4u8 {
339                data.push(c * 80); // 0, 80, 160, 240 across the row
340            }
341        }
342        RasterTile {
343            width: 4,
344            height: 4,
345            bands: vec![Band::new(
346                BandDescriptor::new(Some("v".into()), PixelType::U8),
347                data,
348            )],
349            geo_transform: GeoTransform::north_up(0.0, 0.0, 1.0, 1.0),
350            crs: Crs::Epsg(3857),
351        }
352    }
353
354    #[test]
355    fn nearest_returns_exact_pixel_value() {
356        let t = gradient_tile();
357        // Centre of pixel (2, 1) — should be 160.
358        let v = sample(&t.bands[0], 4, 4, 2.5, 1.5, ResamplingMethod::Nearest).unwrap();
359        assert_eq!(v, 160.0);
360    }
361
362    #[test]
363    fn bilinear_interpolates_between_pixels() {
364        let t = gradient_tile();
365        // Halfway between centres of pixels (0, 0) and (1, 0) — gradient is 0 → 80.
366        let v = sample(&t.bands[0], 4, 4, 1.0, 0.5, ResamplingMethod::Bilinear).unwrap();
367        assert!((v - 40.0).abs() < 1e-9);
368    }
369
370    #[test]
371    fn cubic_handles_smooth_gradient() {
372        let t = gradient_tile();
373        // Catmull-Rom cubic produces ~35 at the midpoint of a step gradient
374        // (sharper curve than bilinear, deliberately so). Just verify the
375        // sample is finite + in the expected ballpark.
376        let v = sample(&t.bands[0], 4, 4, 1.0, 0.5, ResamplingMethod::Cubic).unwrap();
377        assert!(v.is_finite());
378        assert!((20.0..=60.0).contains(&v), "cubic got {v}, expected 20..60");
379    }
380
381    #[test]
382    fn out_of_bounds_returns_none() {
383        let t = gradient_tile();
384        assert!(sample(&t.bands[0], 4, 4, -1.0, 0.0, ResamplingMethod::Nearest).is_none());
385        assert!(sample(&t.bands[0], 4, 4, 0.0, 5.0, ResamplingMethod::Nearest).is_none());
386    }
387
388    #[test]
389    fn resample_tile_upsamples() {
390        let t = gradient_tile();
391        let resized = resample_tile(&t, 8, 8, ResamplingMethod::Bilinear);
392        assert_eq!(resized.width, 8);
393        assert_eq!(resized.height, 8);
394        // Pixel size halved
395        assert_eq!(resized.geo_transform.pixel_size, [0.5, -0.5]);
396    }
397
398    #[test]
399    fn resample_tile_downsamples_average() {
400        // 4×4 with gradient → 2×2 with average; first 2×2 averages to (0+80)/2=40
401        // but across both rows of the gradient it's still 40, so result(0,0) = 40.
402        let t = gradient_tile();
403        let resized = resample_tile(&t, 2, 2, ResamplingMethod::Average);
404        assert_eq!(resized.width, 2);
405        assert_eq!(resized.height, 2);
406        // (0, 0) covers source pixels (0..2, 0..2) — values 0, 80 horizontally,
407        // averaged over 4 pixels → (0 + 80 + 0 + 80) / 4 = 40.
408        let v = resized.bands[0].data[0];
409        assert!((v as i32 - 40).abs() < 2);
410    }
411
412    #[test]
413    fn resample_tile_downsamples_mode() {
414        // 4×4 with a dominant value of 5, plus one different pixel
415        let mut t = u8_tile(4, 4, 5);
416        t.bands[0].data[0] = 99;
417        let resized = resample_tile(&t, 2, 2, ResamplingMethod::Mode);
418        assert_eq!(resized.width, 2);
419        // Mode of the top-left 2×2 (values: 99, 5, 5, 5) → 5
420        assert_eq!(resized.bands[0].data[0], 5);
421    }
422
423    #[test]
424    fn lanczos_kernel_at_zero_is_one() {
425        assert!((lanczos_kernel(0.0, 3) - 1.0).abs() < 1e-12);
426        // At integer offsets ≥ a, kernel is zero
427        assert_eq!(lanczos_kernel(3.0, 3), 0.0);
428        assert_eq!(lanczos_kernel(-3.0, 3), 0.0);
429    }
430}