Skip to main content

oxigeo_algorithms/resampling/
nearest.rs

1//! Nearest neighbor resampling algorithm
2//!
3//! This is the fastest resampling method, which assigns each output pixel
4//! the value of the nearest input pixel. No interpolation is performed.
5//!
6//! # Characteristics
7//!
8//! - **Speed**: Fastest (O(1) per pixel)
9//! - **Quality**: Lowest (blocky for upsampling)
10//! - **Preservation**: Exact input values preserved
11//! - **Best for**: Categorical data, classifications, masks
12//!
13//! # Algorithm
14//!
15//! For each output pixel at (dst_x, dst_y):
16//! 1. Compute source position: src_x = dst_x * scale_x, src_y = dst_y * scale_y
17//! 2. Round to nearest integer: src_x = round(src_x), src_y = round(src_y)
18//! 3. Copy value: dst[dst_y][dst_x] = src[src_y][src_x]
19
20use crate::error::{AlgorithmError, Result};
21use oxigeo_core::buffer::RasterBuffer;
22
23/// Nearest neighbor resampler
24#[derive(Debug, Clone, Copy, Default)]
25pub struct NearestResampler;
26
27impl NearestResampler {
28    /// Creates a new nearest neighbor resampler
29    #[must_use]
30    pub const fn new() -> Self {
31        Self
32    }
33
34    /// Resamples a raster buffer using nearest neighbor
35    ///
36    /// # Arguments
37    ///
38    /// * `src` - Source raster buffer
39    /// * `dst_width` - Target width in pixels
40    /// * `dst_height` - Target height in pixels
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if:
45    /// - Target dimensions are zero
46    /// - Source buffer is invalid
47    /// - Memory allocation fails
48    pub fn resample(
49        &self,
50        src: &RasterBuffer,
51        dst_width: u64,
52        dst_height: u64,
53    ) -> Result<RasterBuffer> {
54        if dst_width == 0 || dst_height == 0 {
55            return Err(AlgorithmError::InvalidParameter {
56                parameter: "dimensions",
57                message: "Target dimensions must be non-zero".to_string(),
58            });
59        }
60
61        let src_width = src.width();
62        let src_height = src.height();
63
64        if src_width == 0 || src_height == 0 {
65            return Err(AlgorithmError::EmptyInput {
66                operation: "nearest neighbor resampling",
67            });
68        }
69
70        // Create output buffer with same data type as source
71        let mut dst = RasterBuffer::zeros(dst_width, dst_height, src.data_type());
72
73        // Compute scaling factors
74        let scale_x = src_width as f64 / dst_width as f64;
75        let scale_y = src_height as f64 / dst_height as f64;
76
77        // For each output pixel
78        for dst_y in 0..dst_height {
79            for dst_x in 0..dst_width {
80                // Find nearest source pixel
81                let src_x = self.map_coordinate(dst_x as f64, scale_x, src_width);
82                let src_y = self.map_coordinate(dst_y as f64, scale_y, src_height);
83
84                // Copy value
85                let value = src.get_pixel(src_x, src_y).map_err(AlgorithmError::Core)?;
86                dst.set_pixel(dst_x, dst_y, value)
87                    .map_err(AlgorithmError::Core)?;
88            }
89        }
90
91        Ok(dst)
92    }
93
94    /// Maps a destination coordinate to the nearest source coordinate
95    ///
96    /// Uses pixel-center registration: pixel coordinates are at integer positions,
97    /// and pixel centers are at (i + 0.5, j + 0.5).
98    ///
99    /// # Arguments
100    ///
101    /// * `dst_coord` - Destination coordinate
102    /// * `scale` - Scaling factor (src_size / dst_size)
103    /// * `src_size` - Source dimension size
104    ///
105    /// # Returns
106    ///
107    /// The nearest source coordinate, clamped to [0, src_size)
108    #[inline]
109    fn map_coordinate(&self, dst_coord: f64, scale: f64, src_size: u64) -> u64 {
110        // Map from destination pixel center to source coordinate
111        let src_coord = (dst_coord + 0.5) * scale - 0.5;
112
113        // Round to nearest integer
114        let src_coord_rounded = src_coord.round();
115
116        // Clamp to valid range
117        let clamped = src_coord_rounded.max(0.0).min((src_size - 1) as f64);
118
119        clamped as u64
120    }
121
122    /// Resamples with explicit scaling factors
123    ///
124    /// This variant allows precise control over the resampling transformation.
125    ///
126    /// # Arguments
127    ///
128    /// * `src` - Source raster buffer
129    /// * `dst_width` - Target width
130    /// * `dst_height` - Target height
131    /// * `scale_x` - Horizontal scaling factor
132    /// * `scale_y` - Vertical scaling factor
133    /// * `offset_x` - Horizontal offset in source coordinates
134    /// * `offset_y` - Vertical offset in source coordinates
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if parameters are invalid
139    #[allow(clippy::too_many_arguments)]
140    pub fn resample_with_transform(
141        &self,
142        src: &RasterBuffer,
143        dst_width: u64,
144        dst_height: u64,
145        scale_x: f64,
146        scale_y: f64,
147        offset_x: f64,
148        offset_y: f64,
149    ) -> Result<RasterBuffer> {
150        if dst_width == 0 || dst_height == 0 {
151            return Err(AlgorithmError::InvalidParameter {
152                parameter: "dimensions",
153                message: "Target dimensions must be non-zero".to_string(),
154            });
155        }
156
157        if scale_x <= 0.0 || scale_y <= 0.0 {
158            return Err(AlgorithmError::InvalidParameter {
159                parameter: "scale",
160                message: "Scale factors must be positive".to_string(),
161            });
162        }
163
164        let src_width = src.width();
165        let src_height = src.height();
166
167        let mut dst = RasterBuffer::zeros(dst_width, dst_height, src.data_type());
168
169        for dst_y in 0..dst_height {
170            for dst_x in 0..dst_width {
171                // Apply transform
172                let src_x_f64 = dst_x as f64 * scale_x + offset_x;
173                let src_y_f64 = dst_y as f64 * scale_y + offset_y;
174
175                // Round and clamp
176                let src_x = src_x_f64.round().max(0.0).min((src_width - 1) as f64) as u64;
177                let src_y = src_y_f64.round().max(0.0).min((src_height - 1) as f64) as u64;
178
179                // Copy value
180                let value = src.get_pixel(src_x, src_y).map_err(AlgorithmError::Core)?;
181                dst.set_pixel(dst_x, dst_y, value)
182                    .map_err(AlgorithmError::Core)?;
183            }
184        }
185
186        Ok(dst)
187    }
188
189    /// Resamples with repeat edge mode (instead of clamp)
190    ///
191    /// When sampling outside the source bounds, this wraps coordinates
192    /// rather than clamping them. Useful for tiled or periodic data.
193    pub fn resample_repeat(
194        &self,
195        src: &RasterBuffer,
196        dst_width: u64,
197        dst_height: u64,
198    ) -> Result<RasterBuffer> {
199        if dst_width == 0 || dst_height == 0 {
200            return Err(AlgorithmError::InvalidParameter {
201                parameter: "dimensions",
202                message: "Target dimensions must be non-zero".to_string(),
203            });
204        }
205
206        let src_width = src.width();
207        let src_height = src.height();
208
209        if src_width == 0 || src_height == 0 {
210            return Err(AlgorithmError::EmptyInput {
211                operation: "nearest neighbor resampling",
212            });
213        }
214
215        let mut dst = RasterBuffer::zeros(dst_width, dst_height, src.data_type());
216
217        let scale_x = src_width as f64 / dst_width as f64;
218        let scale_y = src_height as f64 / dst_height as f64;
219
220        for dst_y in 0..dst_height {
221            for dst_x in 0..dst_width {
222                let src_coord_x = (dst_x as f64 + 0.5) * scale_x - 0.5;
223                let src_coord_y = (dst_y as f64 + 0.5) * scale_y - 0.5;
224
225                // Wrap coordinates
226                let src_x = (src_coord_x.round() as i64).rem_euclid(src_width as i64) as u64;
227                let src_y = (src_coord_y.round() as i64).rem_euclid(src_height as i64) as u64;
228
229                let value = src.get_pixel(src_x, src_y).map_err(AlgorithmError::Core)?;
230                dst.set_pixel(dst_x, dst_y, value)
231                    .map_err(AlgorithmError::Core)?;
232            }
233        }
234
235        Ok(dst)
236    }
237}
238
239// NOTE: A previous `simd_impl` module exposed a `resample_simd` method whose
240// doc promised SIMD acceleration but whose body silently delegated to the scalar
241// `resample`. It was dead (no call sites anywhere in the workspace) and
242// misleading, so it was removed. Genuine hardware-vectorized resampling kernels
243// (`nearest_f32`, `bilinear_f32`, `bicubic_f32` behind real AVX2/NEON intrinsics
244// with scalar-parity tests) live in `crate::simd::resampling`; use those when a
245// vectorized fast path is required.
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use approx::assert_abs_diff_eq;
251    use oxigeo_core::types::RasterDataType;
252
253    #[test]
254    fn test_nearest_identity() {
255        // Resampling to same size should be identity
256        let mut src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
257
258        // Fill with test pattern
259        for y in 0..10 {
260            for x in 0..10 {
261                src.set_pixel(x, y, (y * 10 + x) as f64).ok();
262            }
263        }
264
265        let resampler = NearestResampler::new();
266        let result = resampler.resample(&src, 10, 10);
267        assert!(result.is_ok());
268
269        if let Ok(dst) = result {
270            for y in 0..10 {
271                for x in 0..10 {
272                    if let (Ok(sv), Ok(dv)) = (src.get_pixel(x, y), dst.get_pixel(x, y)) {
273                        assert_abs_diff_eq!(sv, dv, epsilon = 1e-10);
274                    }
275                }
276            }
277        }
278    }
279
280    #[test]
281    fn test_nearest_downsample() {
282        // Create 4x4 checkerboard
283        let mut src = RasterBuffer::zeros(4, 4, RasterDataType::Float32);
284        for y in 0..4 {
285            for x in 0..4 {
286                let value = if (x + y) % 2 == 0 { 1.0 } else { 0.0 };
287                src.set_pixel(x, y, value).ok();
288            }
289        }
290
291        let resampler = NearestResampler::new();
292        let dst = resampler.resample(&src, 2, 2);
293        assert!(dst.is_ok());
294    }
295
296    #[test]
297    fn test_nearest_upsample() {
298        // Create 2x2 source
299        let mut src = RasterBuffer::zeros(2, 2, RasterDataType::Float32);
300        src.set_pixel(0, 0, 1.0).ok();
301        src.set_pixel(1, 0, 2.0).ok();
302        src.set_pixel(0, 1, 3.0).ok();
303        src.set_pixel(1, 1, 4.0).ok();
304
305        let resampler = NearestResampler::new();
306        let dst = resampler.resample(&src, 4, 4);
307        assert!(dst.is_ok());
308
309        // Values should be replicated (blocky)
310        if let Ok(dst) = dst {
311            // Top-left quadrant should be mostly 1.0
312            let val = dst.get_pixel(0, 0).ok();
313            assert!(val.is_some());
314        }
315    }
316
317    #[test]
318    fn test_nearest_zero_dimensions() {
319        let src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
320        let resampler = NearestResampler::new();
321
322        assert!(resampler.resample(&src, 0, 10).is_err());
323        assert!(resampler.resample(&src, 10, 0).is_err());
324    }
325
326    #[test]
327    fn test_map_coordinate() {
328        let resampler = NearestResampler::new();
329
330        // Downsampling 2:1 (scale = 2.0)
331        // Formula: src_coord = (dst_coord + 0.5) * scale - 0.5, then round
332
333        // dst=0.0: (0.0 + 0.5) * 2.0 - 0.5 = 0.5 → rounds to 1 (Rust rounds 0.5 away from zero)
334        assert_eq!(resampler.map_coordinate(0.0, 2.0, 10), 1);
335
336        // dst=1.0: (1.0 + 0.5) * 2.0 - 0.5 = 2.5 → rounds to 3
337        assert_eq!(resampler.map_coordinate(1.0, 2.0, 10), 3);
338
339        // dst=2.0: (2.0 + 0.5) * 2.0 - 0.5 = 4.5 → rounds to 5
340        assert_eq!(resampler.map_coordinate(2.0, 2.0, 10), 5);
341
342        // dst=3.0: (3.0 + 0.5) * 2.0 - 0.5 = 6.5 → rounds to 7
343        assert_eq!(resampler.map_coordinate(3.0, 2.0, 10), 7);
344
345        // Upsampling 1:2 (scale = 0.5)
346        // dst=0.0: (0.0 + 0.5) * 0.5 - 0.5 = -0.25 → rounds to 0, clamped to 0
347        assert_eq!(resampler.map_coordinate(0.0, 0.5, 10), 0);
348
349        // dst=1.0: (1.0 + 0.5) * 0.5 - 0.5 = 0.25 → rounds to 0
350        assert_eq!(resampler.map_coordinate(1.0, 0.5, 10), 0);
351
352        // dst=2.0: (2.0 + 0.5) * 0.5 - 0.5 = 0.75 → rounds to 1
353        assert_eq!(resampler.map_coordinate(2.0, 0.5, 10), 1);
354
355        // Test clamping at upper bound
356        // dst=20.0: (20.0 + 0.5) * 2.0 - 0.5 = 40.5 → rounds to 41, clamped to 9
357        assert_eq!(resampler.map_coordinate(20.0, 2.0, 10), 9);
358    }
359
360    #[test]
361    fn test_nearest_with_transform() {
362        let src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
363        let resampler = NearestResampler::new();
364
365        // No offset, 1:1 scale
366        let result = resampler.resample_with_transform(&src, 10, 10, 1.0, 1.0, 0.0, 0.0);
367        assert!(result.is_ok());
368
369        // Invalid scale
370        let result = resampler.resample_with_transform(&src, 10, 10, 0.0, 1.0, 0.0, 0.0);
371        assert!(result.is_err());
372
373        let result = resampler.resample_with_transform(&src, 10, 10, 1.0, -1.0, 0.0, 0.0);
374        assert!(result.is_err());
375    }
376
377    #[test]
378    fn test_nearest_repeat() {
379        let mut src = RasterBuffer::zeros(3, 3, RasterDataType::Float32);
380        for y in 0..3 {
381            for x in 0..3 {
382                src.set_pixel(x, y, (y * 3 + x) as f64).ok();
383            }
384        }
385
386        let resampler = NearestResampler::new();
387        let result = resampler.resample_repeat(&src, 6, 6);
388        assert!(result.is_ok());
389    }
390}