Skip to main content

oxigeo_algorithms/resampling/
lanczos.rs

1//! Lanczos resampling algorithm
2//!
3//! Lanczos resampling provides the highest quality results using a windowed sinc filter.
4//! It preserves sharp edges better than bicubic while avoiding excessive ringing.
5//!
6//! # Characteristics
7//!
8//! - **Speed**: Slowest (36-100 samples per pixel)
9//! - **Quality**: Highest, excellent edge preservation
10//! - **Best for**: High-quality imagery, when quality is paramount
11//!
12//! # Algorithm
13//!
14//! For each output pixel:
15//! 1. Sample (2a) x (2a) neighborhood (where a = lobes, typically 2 or 3)
16//! 2. Apply Lanczos kernel: L(x) = sinc(x) * sinc(x/a)
17//! 3. Normalize weights and sum
18
19use crate::error::{AlgorithmError, Result};
20use crate::resampling::kernel::{lanczos, normalize_weights};
21use oxigeo_core::buffer::RasterBuffer;
22
23/// Lanczos resampler with configurable lobe count
24#[derive(Debug, Clone, Copy)]
25pub struct LanczosResampler {
26    /// Number of lobes (typically 2 or 3)
27    lobes: usize,
28}
29
30impl Default for LanczosResampler {
31    fn default() -> Self {
32        Self::new(3)
33    }
34}
35
36impl LanczosResampler {
37    /// Creates a new Lanczos resampler with specified lobe count
38    ///
39    /// # Arguments
40    ///
41    /// * `lobes` - Number of lobes (2 = faster, 3 = higher quality)
42    ///
43    /// Common values:
44    /// - 2: Lanczos2 - faster, still good quality
45    /// - 3: Lanczos3 - standard, excellent quality (default)
46    #[must_use]
47    pub const fn new(lobes: usize) -> Self {
48        Self { lobes }
49    }
50
51    /// Returns the lobe count
52    #[must_use]
53    pub const fn lobes(&self) -> usize {
54        self.lobes
55    }
56
57    /// Returns the kernel radius (same as lobe count)
58    #[must_use]
59    pub const fn radius(&self) -> usize {
60        self.lobes
61    }
62
63    /// Resamples a raster buffer using Lanczos interpolation
64    ///
65    /// Out-of-range source samples needed by the kernel window at the
66    /// image border are handled via `EdgeMode::Clamp`. Use
67    /// [`LanczosResampler::resample_with_edge_mode`] to select a different
68    /// edge-handling strategy.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if dimensions are invalid
73    pub fn resample(
74        &self,
75        src: &RasterBuffer,
76        dst_width: u64,
77        dst_height: u64,
78    ) -> Result<RasterBuffer> {
79        self.resample_with_edge_mode(src, dst_width, dst_height, EdgeMode::Clamp)
80    }
81
82    /// Interpolates value at fractional source coordinates using Lanczos
83    ///
84    /// `edge_mode` controls how out-of-range source coordinates within the
85    /// kernel window are mapped back into the valid `[0, size)` range (see
86    /// [`map_edge_coord`]).
87    fn interpolate_at(
88        &self,
89        src: &RasterBuffer,
90        src_x: f64,
91        src_y: f64,
92        edge_mode: EdgeMode,
93    ) -> Result<f64> {
94        let src_width = src.width();
95        let src_height = src.height();
96
97        // Clamp to valid range
98        let src_x_clamped = src_x.max(0.0).min((src_width - 1) as f64);
99        let src_y_clamped = src_y.max(0.0).min((src_height - 1) as f64);
100
101        // Get integer center
102        let x_center = src_x_clamped.floor() as i64;
103        let y_center = src_y_clamped.floor() as i64;
104
105        // Compute kernel range
106        let radius = self.radius() as i64;
107        let x_start = x_center - radius + 1;
108        let x_end = x_center + radius + 1;
109        let y_start = y_center - radius + 1;
110        let y_end = y_center + radius + 1;
111
112        let kernel_width = (x_end - x_start) as usize;
113        let kernel_height = (y_end - y_start) as usize;
114
115        // Allocate weight and value buffers
116        let total_samples = kernel_width * kernel_height;
117        let mut values = vec![0.0f64; total_samples];
118        let mut weights = vec![0.0f64; total_samples];
119
120        // Sample neighborhood and compute weights
121        let mut idx = 0;
122        for sy in y_start..y_end {
123            for sx in x_start..x_end {
124                // Map out-of-range coordinates back into the valid range
125                // according to the requested edge-handling mode.
126                let sample_x = map_edge_coord(sx, src_width, edge_mode);
127                let sample_y = map_edge_coord(sy, src_height, edge_mode);
128
129                // Get value
130                values[idx] = src
131                    .get_pixel(sample_x, sample_y)
132                    .map_err(AlgorithmError::Core)?;
133
134                // Compute kernel weight
135                let dx = sx as f64 - src_x_clamped;
136                let dy = sy as f64 - src_y_clamped;
137                let wx = lanczos(dx, self.lobes);
138                let wy = lanczos(dy, self.lobes);
139                weights[idx] = wx * wy;
140
141                idx += 1;
142            }
143        }
144
145        // Normalize weights
146        normalize_weights(&mut weights);
147
148        // Compute weighted sum
149        let mut result = 0.0;
150        for (value, weight) in values.iter().zip(weights.iter()) {
151            result += value * weight;
152        }
153
154        Ok(result)
155    }
156
157    /// Resamples with edge handling
158    ///
159    /// This variant allows specifying how to handle source samples that the
160    /// kernel window would otherwise read from outside `[0, width) x
161    /// [0, height)` near the image border.
162    ///
163    /// # Arguments
164    ///
165    /// * `src` - Source buffer
166    /// * `dst_width` - Destination width
167    /// * `dst_height` - Destination height
168    /// * `edge_mode` - How to handle edge pixels
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if parameters are invalid
173    pub fn resample_with_edge_mode(
174        &self,
175        src: &RasterBuffer,
176        dst_width: u64,
177        dst_height: u64,
178        edge_mode: EdgeMode,
179    ) -> Result<RasterBuffer> {
180        if dst_width == 0 || dst_height == 0 {
181            return Err(AlgorithmError::InvalidParameter {
182                parameter: "dimensions",
183                message: "Target dimensions must be non-zero".to_string(),
184            });
185        }
186
187        let src_width = src.width();
188        let src_height = src.height();
189
190        if src_width == 0 || src_height == 0 {
191            return Err(AlgorithmError::EmptyInput {
192                operation: "Lanczos resampling",
193            });
194        }
195
196        let mut dst = RasterBuffer::zeros(dst_width, dst_height, src.data_type());
197
198        let scale_x = src_width as f64 / dst_width as f64;
199        let scale_y = src_height as f64 / dst_height as f64;
200
201        for dst_y in 0..dst_height {
202            for dst_x in 0..dst_width {
203                let src_x = (dst_x as f64 + 0.5) * scale_x - 0.5;
204                let src_y = (dst_y as f64 + 0.5) * scale_y - 0.5;
205
206                let value = self.interpolate_at(src, src_x, src_y, edge_mode)?;
207                dst.set_pixel(dst_x, dst_y, value)
208                    .map_err(AlgorithmError::Core)?;
209            }
210        }
211
212        Ok(dst)
213    }
214
215    /// Separable Lanczos resampling (optimized 2-pass)
216    ///
217    /// This performs resampling in two passes (horizontal then vertical)
218    /// which is more cache-friendly and can be faster for large kernels.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if parameters are invalid
223    pub fn resample_separable(
224        &self,
225        src: &RasterBuffer,
226        dst_width: u64,
227        dst_height: u64,
228    ) -> Result<RasterBuffer> {
229        if dst_width == 0 || dst_height == 0 {
230            return Err(AlgorithmError::InvalidParameter {
231                parameter: "dimensions",
232                message: "Target dimensions must be non-zero".to_string(),
233            });
234        }
235
236        let src_width = src.width();
237        let src_height = src.height();
238
239        // Pass 1: Horizontal resampling
240        let mut temp = RasterBuffer::zeros(dst_width, src_height, src.data_type());
241        let scale_x = src_width as f64 / dst_width as f64;
242
243        for src_y in 0..src_height {
244            for dst_x in 0..dst_width {
245                let src_x = (dst_x as f64 + 0.5) * scale_x - 0.5;
246                let value = self.interpolate_horizontal(src, src_x, src_y)?;
247                temp.set_pixel(dst_x, src_y, value)
248                    .map_err(AlgorithmError::Core)?;
249            }
250        }
251
252        // Pass 2: Vertical resampling
253        let mut dst = RasterBuffer::zeros(dst_width, dst_height, src.data_type());
254        let scale_y = src_height as f64 / dst_height as f64;
255
256        for dst_x in 0..dst_width {
257            for dst_y in 0..dst_height {
258                let src_y = (dst_y as f64 + 0.5) * scale_y - 0.5;
259                let value = self.interpolate_vertical(&temp, dst_x, src_y)?;
260                dst.set_pixel(dst_x, dst_y, value)
261                    .map_err(AlgorithmError::Core)?;
262            }
263        }
264
265        Ok(dst)
266    }
267
268    /// Horizontal 1D interpolation
269    fn interpolate_horizontal(&self, src: &RasterBuffer, src_x: f64, y: u64) -> Result<f64> {
270        let src_width = src.width();
271        let src_x_clamped = src_x.max(0.0).min((src_width - 1) as f64);
272        let x_center = src_x_clamped.floor() as i64;
273
274        let radius = self.radius() as i64;
275        let x_start = x_center - radius + 1;
276        let x_end = x_center + radius + 1;
277
278        let kernel_width = (x_end - x_start) as usize;
279        let mut values = vec![0.0f64; kernel_width];
280        let mut weights = vec![0.0f64; kernel_width];
281
282        for (idx, sx) in (x_start..x_end).enumerate() {
283            let sample_x = sx.max(0).min(src_width as i64 - 1) as u64;
284            values[idx] = src.get_pixel(sample_x, y).map_err(AlgorithmError::Core)?;
285            let dx = sx as f64 - src_x_clamped;
286            weights[idx] = lanczos(dx, self.lobes);
287        }
288
289        normalize_weights(&mut weights);
290
291        let result = values.iter().zip(weights.iter()).map(|(v, w)| v * w).sum();
292
293        Ok(result)
294    }
295
296    /// Vertical 1D interpolation
297    fn interpolate_vertical(&self, src: &RasterBuffer, x: u64, src_y: f64) -> Result<f64> {
298        let src_height = src.height();
299        let src_y_clamped = src_y.max(0.0).min((src_height - 1) as f64);
300        let y_center = src_y_clamped.floor() as i64;
301
302        let radius = self.radius() as i64;
303        let y_start = y_center - radius + 1;
304        let y_end = y_center + radius + 1;
305
306        let kernel_height = (y_end - y_start) as usize;
307        let mut values = vec![0.0f64; kernel_height];
308        let mut weights = vec![0.0f64; kernel_height];
309
310        for (idx, sy) in (y_start..y_end).enumerate() {
311            let sample_y = sy.max(0).min(src_height as i64 - 1) as u64;
312            values[idx] = src.get_pixel(x, sample_y).map_err(AlgorithmError::Core)?;
313            let dy = sy as f64 - src_y_clamped;
314            weights[idx] = lanczos(dy, self.lobes);
315        }
316
317        normalize_weights(&mut weights);
318
319        let result = values.iter().zip(weights.iter()).map(|(v, w)| v * w).sum();
320
321        Ok(result)
322    }
323}
324
325/// Edge handling modes for resampling
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum EdgeMode {
328    /// Clamp coordinates to edge (default)
329    Clamp,
330    /// Wrap coordinates (for tiled data)
331    Wrap,
332    /// Mirror coordinates at edges
333    Mirror,
334}
335
336/// Maps a (possibly out-of-range) integer source coordinate `c` back into
337/// the valid range `[0, size)` according to `mode`.
338///
339/// - [`EdgeMode::Clamp`]: coordinates are clamped to `[0, size - 1]`.
340/// - [`EdgeMode::Wrap`]: coordinates wrap around modulo `size` (as if the
341///   source were tiled periodically).
342/// - [`EdgeMode::Mirror`]: coordinates are reflected at the boundary using
343///   "reflect-101" semantics, i.e. the edge pixel itself is *not*
344///   duplicated. For `size == 5` the sequence for `c` in `-3..=7` is
345///   `2 1 0 1 2 3 4 3 2 1`.
346///
347/// `size == 0` has no valid coordinates; `0` is returned defensively so the
348/// function never panics (callers are expected to reject empty rasters
349/// before reaching the resampling loop).
350#[must_use]
351fn map_edge_coord(c: i64, size: u64, mode: EdgeMode) -> u64 {
352    if size == 0 {
353        return 0;
354    }
355    let size_i = size as i64;
356
357    match mode {
358        EdgeMode::Clamp => c.clamp(0, size_i - 1) as u64,
359        EdgeMode::Wrap => c.rem_euclid(size_i) as u64,
360        EdgeMode::Mirror => {
361            if size_i == 1 {
362                return 0;
363            }
364            // Reflect-101 (no edge duplication): period is 2*(size-1).
365            let period = 2 * (size_i - 1);
366            let m = c.rem_euclid(period);
367            if m < size_i {
368                m as u64
369            } else {
370                (period - m) as u64
371            }
372        }
373    }
374}
375
376#[cfg(test)]
377#[allow(clippy::panic)]
378mod tests {
379    use super::*;
380    use oxigeo_core::types::RasterDataType;
381
382    #[test]
383    fn test_lanczos_creation() {
384        let l2 = LanczosResampler::new(2);
385        assert_eq!(l2.lobes(), 2);
386        assert_eq!(l2.radius(), 2);
387
388        let l3 = LanczosResampler::new(3);
389        assert_eq!(l3.lobes(), 3);
390        assert_eq!(l3.radius(), 3);
391    }
392
393    #[test]
394    fn test_lanczos_identity() {
395        let mut src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
396        for y in 0..10 {
397            for x in 0..10 {
398                src.set_pixel(x, y, (y * 10 + x) as f64).ok();
399            }
400        }
401
402        let resampler = LanczosResampler::new(3);
403        let dst = resampler.resample(&src, 10, 10);
404        assert!(dst.is_ok());
405    }
406
407    #[test]
408    fn test_lanczos_quality() {
409        // Lanczos should produce high-quality smooth results
410        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
411        for y in 0..5 {
412            for x in 0..5 {
413                src.set_pixel(x, y, ((x + y) * (x + y)) as f64).ok();
414            }
415        }
416
417        let resampler = LanczosResampler::new(3);
418        let dst = resampler.resample(&src, 10, 10);
419        assert!(dst.is_ok());
420
421        // Result should be smooth
422        if let Ok(dst) = dst {
423            let v1 = dst.get_pixel(4, 4).ok();
424            let v2 = dst.get_pixel(5, 5).ok();
425            assert!(v1.is_some());
426            assert!(v2.is_some());
427        }
428    }
429
430    #[test]
431    fn test_lanczos_separable() {
432        let mut src = RasterBuffer::zeros(8, 8, RasterDataType::Float32);
433        for y in 0..8 {
434            for x in 0..8 {
435                src.set_pixel(x, y, (x + y) as f64).ok();
436            }
437        }
438
439        let resampler = LanczosResampler::new(3);
440        let dst1 = resampler.resample(&src, 16, 16);
441        let dst2 = resampler.resample_separable(&src, 16, 16);
442
443        assert!(dst1.is_ok());
444        assert!(dst2.is_ok());
445
446        // Results should be similar (not identical due to rounding)
447    }
448
449    #[test]
450    fn test_lanczos_lobes() {
451        let src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
452
453        let l2 = LanczosResampler::new(2);
454        let l3 = LanczosResampler::new(3);
455
456        let dst2 = l2.resample(&src, 20, 20);
457        let dst3 = l3.resample(&src, 20, 20);
458
459        assert!(dst2.is_ok());
460        assert!(dst3.is_ok());
461    }
462
463    #[test]
464    fn test_map_edge_coord() {
465        // Clamp: saturate to [0, size).
466        assert_eq!(map_edge_coord(-1, 5, EdgeMode::Clamp), 0);
467        assert_eq!(map_edge_coord(-100, 5, EdgeMode::Clamp), 0);
468        assert_eq!(map_edge_coord(2, 5, EdgeMode::Clamp), 2);
469        assert_eq!(map_edge_coord(5, 5, EdgeMode::Clamp), 4);
470        assert_eq!(map_edge_coord(100, 5, EdgeMode::Clamp), 4);
471
472        // Wrap: modulo size.
473        assert_eq!(map_edge_coord(-1, 5, EdgeMode::Wrap), 4);
474        assert_eq!(map_edge_coord(-6, 5, EdgeMode::Wrap), 4);
475        assert_eq!(map_edge_coord(5, 5, EdgeMode::Wrap), 0);
476        assert_eq!(map_edge_coord(7, 5, EdgeMode::Wrap), 2);
477
478        // Mirror: reflect-101 (edge pixel not duplicated), period 2*(size-1).
479        // For size 5, the sequence for c in -4..=8 is: 4 3 2 1 0 1 2 3 4 3 2 1 0
480        let expected = [4, 3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1, 0];
481        for (i, c) in (-4i64..=8).enumerate() {
482            assert_eq!(
483                map_edge_coord(c, 5, EdgeMode::Mirror),
484                expected[i],
485                "mirror mismatch at c={c}"
486            );
487        }
488
489        // Degenerate single-pixel source: every mode collapses to index 0.
490        for mode in [EdgeMode::Clamp, EdgeMode::Wrap, EdgeMode::Mirror] {
491            assert_eq!(map_edge_coord(-3, 1, mode), 0);
492            assert_eq!(map_edge_coord(0, 1, mode), 0);
493            assert_eq!(map_edge_coord(3, 1, mode), 0);
494        }
495    }
496
497    #[test]
498    fn test_edge_modes_constant_image() {
499        // A constant-valued image must upsample to the same constant under
500        // every edge mode: whichever source pixels the kernel gathers at
501        // the border, they all carry the same value, so the normalized
502        // weighted sum is unchanged.
503        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
504        for y in 0..5 {
505            for x in 0..5 {
506                src.set_pixel(x, y, 42.0).ok();
507            }
508        }
509        let resampler = LanczosResampler::new(3);
510
511        for mode in [EdgeMode::Clamp, EdgeMode::Wrap, EdgeMode::Mirror] {
512            let dst = resampler
513                .resample_with_edge_mode(&src, 10, 10, mode)
514                .unwrap_or_else(|e| panic!("resample with {mode:?} failed: {e}"));
515            for y in 0..10 {
516                for x in 0..10 {
517                    let v = dst
518                        .get_pixel(x, y)
519                        .unwrap_or_else(|e| panic!("get_pixel({x},{y}) failed: {e}"));
520                    assert!(
521                        (v - 42.0).abs() < 1e-6,
522                        "mode {mode:?}: pixel ({x},{y}) = {v}, expected 42.0"
523                    );
524                }
525            }
526        }
527    }
528
529    #[test]
530    fn test_edge_modes_ramp_spot_check() {
531        // Horizontal ramp (value == x), height chosen so the y-axis kernel
532        // window is centered exactly on an integer row (weight ~1 on the
533        // center row, ~0 elsewhere) and therefore does not interfere with
534        // the x-axis edge-mode behaviour under test.
535        //
536        // width=8, dst_width=5, lobes=2 puts dst_x=0 at src_x=0.3, whose
537        // kernel window [-1, 3) reaches one column left of the image
538        // (sx = -1), forcing edge-mode-dependent sampling. Expected values
539        // below were derived independently (see WP-7 task notes) by
540        // replicating the exact same Lanczos kernel and 2-D normalization
541        // used in `interpolate_at`, for each of the three edge modes.
542        let width = 8u64;
543        let height = 3u64;
544        let mut src = RasterBuffer::zeros(width, height, RasterDataType::Float32);
545        for y in 0..height {
546            for x in 0..width {
547                src.set_pixel(x, y, x as f64).ok();
548            }
549        }
550
551        let resampler = LanczosResampler::new(2);
552        let dst_width = 5u64;
553        let dst_height = 3u64;
554
555        let expected: &[(EdgeMode, f64)] = &[
556            (EdgeMode::Clamp, 0.243_460_891_937_171_1),
557            (EdgeMode::Wrap, -0.353_871_041_402_479_4),
558            (EdgeMode::Mirror, 0.158_127_758_602_935_3),
559        ];
560
561        for &(mode, expected_value) in expected {
562            let dst = resampler
563                .resample_with_edge_mode(&src, dst_width, dst_height, mode)
564                .unwrap_or_else(|e| panic!("resample with {mode:?} failed: {e}"));
565            let v = dst
566                .get_pixel(0, 1)
567                .unwrap_or_else(|e| panic!("get_pixel(0,1) failed: {e}"));
568            assert!(
569                (v - expected_value).abs() < 1e-6,
570                "mode {mode:?}: pixel (0,1) = {v}, expected {expected_value}"
571            );
572        }
573
574        // Sanity: the three edge modes must actually disagree at this
575        // border pixel (otherwise the spot check above would not
576        // distinguish Wrap/Mirror from Clamp).
577        assert!((expected[0].1 - expected[1].1).abs() > 0.1);
578        assert!((expected[0].1 - expected[2].1).abs() > 0.05);
579    }
580
581    #[test]
582    fn test_lanczos_zero_dimensions() {
583        let src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
584        let resampler = LanczosResampler::new(3);
585
586        assert!(resampler.resample(&src, 0, 10).is_err());
587        assert!(resampler.resample(&src, 10, 0).is_err());
588    }
589}