Skip to main content

scirs2_ndimage/interpolation/
optimized.rs

1//! Performance-optimized interpolation implementations
2//!
3//! This module provides high-performance interpolation with:
4//! - Pre-computed coefficient caching
5//! - SIMD-optimized interpolation kernels
6//! - Parallel implementation for large images
7//! - Memory-efficient processing
8
9use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
10use scirs2_core::numeric::{Float, FromPrimitive};
11use scirs2_core::parallel_ops::*;
12use std::collections::HashMap;
13use std::fmt::Debug;
14use std::sync::{Arc, RwLock};
15
16use super::{BoundaryMode, InterpolationOrder};
17use crate::error::{NdimageError, NdimageResult};
18
19/// Helper function for safe i32 conversion
20#[allow(dead_code)]
21fn safe_i32_to_float<T: Float + FromPrimitive>(value: i32) -> NdimageResult<T> {
22    T::from_i32(value).ok_or_else(|| {
23        NdimageError::ComputationError(format!("Failed to convert i32 {} to float type", value))
24    })
25}
26
27/// Helper function for safe usize conversion
28#[allow(dead_code)]
29fn safe_to_usize<T: Float>(value: T) -> NdimageResult<usize> {
30    value.to_usize().ok_or_else(|| {
31        NdimageError::ComputationError("Failed to convert value to usize".to_string())
32    })
33}
34
35/// Helper function for safe isize conversion
36#[allow(dead_code)]
37fn safe_to_isize<T: Float>(value: T) -> NdimageResult<isize> {
38    value.to_isize().ok_or_else(|| {
39        NdimageError::ComputationError("Failed to convert value to isize".to_string())
40    })
41}
42
43/// Helper function for safe i32 conversion
44#[allow(dead_code)]
45fn safe_to_i32<T: Float>(value: T) -> NdimageResult<i32> {
46    value
47        .to_i32()
48        .ok_or_else(|| NdimageError::ComputationError("Failed to convert value to i32".to_string()))
49}
50
51/// Helper function for safe usize to float conversion
52#[allow(dead_code)]
53fn safe_usize_to_float<T: Float + FromPrimitive>(value: usize) -> NdimageResult<T> {
54    T::from_usize(value).ok_or_else(|| {
55        NdimageError::ComputationError(format!("Failed to convert usize {} to float type", value))
56    })
57}
58
59/// Cache for pre-computed interpolation coefficients
60pub struct CoefficientCache<T> {
61    cache: Arc<RwLock<HashMap<CacheKey, Vec<T>>>>,
62    max_entries: usize,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66struct CacheKey {
67    order: u8,
68    offset: i32, // Quantized to 1/1000th precision
69}
70
71impl<
72        T: Float + FromPrimitive + Debug + Clone + std::ops::AddAssign + std::ops::DivAssign + 'static,
73    > CoefficientCache<T>
74{
75    pub fn new(max_entries: usize) -> Self {
76        Self {
77            cache: Arc::new(RwLock::new(HashMap::new())),
78            max_entries,
79        }
80    }
81
82    /// Get or compute interpolation coefficients
83    pub fn get_coefficients(&self, order: InterpolationOrder, offset: T) -> NdimageResult<Vec<T>> {
84        // Quantize offset for caching
85        let offset_quantized = (offset * safe_i32_to_float(1000)?)
86            .to_i32()
87            .ok_or_else(|| {
88                NdimageError::ComputationError("Failed to quantize offset for caching".to_string())
89            })?;
90        let key = CacheKey {
91            order: order as u8,
92            offset: offset_quantized,
93        };
94
95        // Try to get from cache
96        {
97            let cache = self.cache.read().map_err(|_| {
98                NdimageError::ComputationError(
99                    "Failed to acquire read lock on coefficient cache".to_string(),
100                )
101            })?;
102            if let Some(coeffs) = cache.get(&key) {
103                return Ok(coeffs.clone());
104            }
105        }
106
107        // Compute coefficients
108        let coeffs = compute_interpolation_coefficients(order, offset)?;
109
110        // Store in cache if not full
111        {
112            let mut cache = self.cache.write().map_err(|_| {
113                NdimageError::ComputationError(
114                    "Failed to acquire write lock on coefficient cache".to_string(),
115                )
116            })?;
117            if cache.len() < self.max_entries {
118                cache.insert(key, coeffs.clone());
119            }
120        }
121
122        Ok(coeffs)
123    }
124
125    /// Clear the cache
126    pub fn clear(&self) -> NdimageResult<()> {
127        self.cache
128            .write()
129            .map_err(|_| {
130                NdimageError::ComputationError(
131                    "Failed to acquire write lock to clear cache".to_string(),
132                )
133            })?
134            .clear();
135        Ok(())
136    }
137}
138
139/// Compute interpolation coefficients for a given order and offset
140#[allow(dead_code)]
141fn compute_interpolation_coefficients<T>(
142    order: InterpolationOrder,
143    offset: T,
144) -> NdimageResult<Vec<T>>
145where
146    T: Float + FromPrimitive + std::ops::AddAssign + std::ops::DivAssign + 'static,
147{
148    match order {
149        InterpolationOrder::Nearest => Ok(vec![T::one()]),
150        InterpolationOrder::Linear => Ok(vec![T::one() - offset, offset]),
151        InterpolationOrder::Cubic => {
152            // Cubic interpolation coefficients (Catmull-Rom)
153            let t = offset;
154            let t2 = t * t;
155            let t3 = t2 * t;
156
157            let neg_half: T = crate::utils::safe_f64_to_float::<T>(-0.5)?;
158            let half: T = crate::utils::safe_f64_to_float::<T>(0.5)?;
159            let one_half: T = crate::utils::safe_f64_to_float::<T>(1.5)?;
160            let two_half: T = crate::utils::safe_f64_to_float::<T>(2.5)?;
161            let two: T = crate::utils::safe_f64_to_float::<T>(2.0)?;
162
163            Ok(vec![
164                neg_half * t3 + t2 - half * t,
165                one_half * t3 - two_half * t2 + T::one(),
166                -one_half * t3 + two * t2 + half * t,
167                half * t3 - half * t2,
168            ])
169        }
170        InterpolationOrder::Spline => {
171            // B-spline coefficients (order 5)
172            compute_bspline_coefficients(5, offset)
173        }
174    }
175}
176
177/// Compute B-spline coefficients
178#[allow(dead_code)]
179fn compute_bspline_coefficients<T>(order: usize, offset: T) -> NdimageResult<Vec<T>>
180where
181    T: Float + FromPrimitive + std::ops::AddAssign + std::ops::DivAssign + 'static,
182{
183    let mut coeffs = vec![T::zero(); order + 1];
184
185    // Simplified B-spline computation for order 5
186    if order == 5 {
187        let t = offset;
188        let t2 = t * t;
189        let t3 = t2 * t;
190        let t4 = t3 * t;
191        let t5 = t4 * t;
192
193        // Pre-computed B-spline basis functions with constants
194        let c120: T = crate::utils::safe_f64_to_float::<T>(1.0 / 120.0)?;
195        let c24: T = crate::utils::safe_f64_to_float::<T>(1.0 / 24.0)?;
196        let c12: T = crate::utils::safe_f64_to_float::<T>(1.0 / 12.0)?;
197        let c2: T = crate::utils::safe_f64_to_float::<T>(2.0)?;
198        let c3: T = crate::utils::safe_f64_to_float::<T>(3.0)?;
199        let c4: T = crate::utils::safe_f64_to_float::<T>(4.0)?;
200        let c5: T = crate::utils::safe_f64_to_float::<T>(5.0)?;
201        let c6: T = crate::utils::safe_f64_to_float::<T>(6.0)?;
202        let c10: T = crate::utils::safe_f64_to_float::<T>(10.0)?;
203
204        coeffs[0] = c120 * (-t5 + c5 * t4 - c10 * t3 + c10 * t2 - c5 * t + T::one());
205        coeffs[1] = c24 * (t5 - c2 * t4 - c3 * t3 + c6 * t2 + c4 * t + T::one());
206        coeffs[2] = c12 * (-t5 + t4 + c3 * t3 + c3 * t2 - c3 * t + T::one());
207        coeffs[3] = c12 * (t5 - t4 - c3 * t3 + c3 * t2 + c3 * t + T::one());
208        coeffs[4] = c24 * (-t5 + c2 * t4 + c3 * t3 + c6 * t2 - c4 * t + T::one());
209        coeffs[5] = c120 * (t5 + c5 * t4 + c10 * t3 + c10 * t2 + c5 * t + T::one());
210    }
211
212    Ok(coeffs)
213}
214
215/// Optimized 1D interpolation with coefficient caching
216pub struct Interpolator1D<T> {
217    cache: CoefficientCache<T>,
218    order: InterpolationOrder,
219}
220
221impl<
222        T: Float + FromPrimitive + Debug + Clone + std::ops::AddAssign + std::ops::DivAssign + 'static,
223    > Interpolator1D<T>
224{
225    pub fn new(order: InterpolationOrder) -> Self {
226        Self {
227            cache: CoefficientCache::new(1000),
228            order,
229        }
230    }
231
232    /// Interpolate a single value
233    #[inline]
234    pub fn interpolate(
235        &self,
236        data: &ArrayView1<T>,
237        position: T,
238        mode: BoundaryMode,
239        cval: T,
240    ) -> NdimageResult<T> {
241        let _n = data.len();
242        let idx = position.floor();
243        let offset = position - idx;
244
245        // Get coefficients
246        let coeffs = self.cache.get_coefficients(self.order, offset)?;
247        let num_coeffs = coeffs.len();
248
249        // Compute interpolated value
250        let mut result = T::zero();
251        let base_idx = safe_to_isize(idx)? - ((num_coeffs / 2) as isize - 1);
252
253        for (i, &coeff) in coeffs.iter().enumerate() {
254            let sample_idx = base_idx + i as isize;
255            let sample_val = get_boundary_value_1d(data, sample_idx, mode, cval);
256            result = result + coeff * sample_val;
257        }
258
259        Ok(result)
260    }
261}
262
263/// Get value with boundary handling for 1D arrays
264#[inline]
265#[allow(dead_code)]
266fn get_boundary_value_1d<T>(data: &ArrayView1<T>, idx: isize, mode: BoundaryMode, cval: T) -> T
267where
268    T: Float + FromPrimitive + Clone + std::ops::AddAssign + std::ops::DivAssign + 'static,
269{
270    let n = data.len() as isize;
271
272    let valid_idx = match mode {
273        BoundaryMode::Constant => {
274            if idx < 0 || idx >= n {
275                return cval;
276            }
277            idx as usize
278        }
279        BoundaryMode::Nearest => idx.clamp(0, n - 1) as usize,
280        BoundaryMode::Reflect => {
281            let mut i = idx;
282            if i < 0 {
283                i = -i;
284            }
285            if i >= n {
286                i = 2 * n - i - 2;
287            }
288            i.clamp(0, n - 1) as usize
289        }
290        BoundaryMode::Mirror => {
291            let mut i = idx;
292            while i < 0 {
293                i = -i - 1;
294            }
295            while i >= n {
296                i = 2 * n - i - 1;
297            }
298            i as usize
299        }
300        BoundaryMode::Wrap => ((idx % n + n) % n) as usize,
301    };
302
303    data[valid_idx]
304}
305
306/// Optimized 2D interpolation with SIMD support
307pub struct Interpolator2D<T> {
308    cache: CoefficientCache<T>,
309    order: InterpolationOrder,
310}
311
312impl<
313        T: Float
314            + FromPrimitive
315            + Debug
316            + Clone
317            + Send
318            + Sync
319            + 'static
320            + std::ops::AddAssign
321            + std::ops::DivAssign,
322    > Interpolator2D<T>
323{
324    pub fn new(order: InterpolationOrder) -> Self {
325        Self {
326            cache: CoefficientCache::new(2000),
327            order,
328        }
329    }
330
331    /// Interpolate at multiple positions in parallel
332    pub fn interpolate_batch(
333        &self,
334        data: &Array2<T>,
335        positions: &[(T, T)],
336        mode: BoundaryMode,
337        cval: T,
338    ) -> NdimageResult<Vec<T>> {
339        let (h, w) = data.dim();
340
341        if positions.len() > 1000 && num_threads() > 1 {
342            // Parallel processing for large batches
343            let chunks: Vec<&[(T, T)]> = positions.chunks(256).collect();
344
345            let process_chunk = |chunk: &&[(T, T)]| -> Result<Vec<T>, scirs2_core::CoreError> {
346                let mut results = Vec::with_capacity(chunk.len());
347
348                for &(y, x) in chunk.iter() {
349                    let val = self
350                        .interpolate_single(data.view(), y, x, mode, cval)
351                        .map_err(|e| {
352                            scirs2_core::CoreError::ComputationError(
353                                scirs2_core::ErrorContext::new(format!(
354                                    "interpolation error: {:?}",
355                                    e
356                                )),
357                            )
358                        })?;
359                    results.push(val);
360                }
361
362                Ok(results)
363            };
364
365            let chunk_results = parallel_map_result(&chunks, process_chunk)?;
366
367            // Flatten results
368            Ok(chunk_results.into_iter().flatten().collect())
369        } else {
370            // Sequential processing
371            let mut results = Vec::with_capacity(positions.len());
372
373            for &(y, x) in positions {
374                let val = self.interpolate_single(data.view(), y, x, mode, cval)?;
375                results.push(val);
376            }
377
378            Ok(results)
379        }
380    }
381
382    /// Interpolate a single position
383    pub fn interpolate_single(
384        &self,
385        data: ArrayView2<T>,
386        y: T,
387        x: T,
388        mode: BoundaryMode,
389        cval: T,
390    ) -> NdimageResult<T> {
391        let _h_w = data.dim();
392
393        // Get integer and fractional parts
394        let yi = y.floor();
395        let xi = x.floor();
396        let yf = y - yi;
397        let xf = x - xi;
398
399        // Get coefficients
400        let y_coeffs = self.cache.get_coefficients(self.order, yf)?;
401        let x_coeffs = self.cache.get_coefficients(self.order, xf)?;
402
403        let ny = y_coeffs.len();
404        let nx = x_coeffs.len();
405
406        // Base indices
407        let base_y = safe_to_isize(yi)? - ((ny / 2) as isize - 1);
408        let base_x = safe_to_isize(xi)? - ((nx / 2) as isize - 1);
409
410        // Perform 2D interpolation
411        let mut result = T::zero();
412
413        for (iy, &cy) in y_coeffs.iter().enumerate() {
414            let mut row_sum = T::zero();
415            let sample_y = base_y + iy as isize;
416
417            for (ix, &cx) in x_coeffs.iter().enumerate() {
418                let sample_x = base_x + ix as isize;
419                let val = get_boundary_value_2d(&data, sample_y, sample_x, mode, cval);
420                row_sum = row_sum + cx * val;
421            }
422
423            result = result + cy * row_sum;
424        }
425
426        Ok(result)
427    }
428}
429
430/// Get value with boundary handling for 2D arrays
431#[inline]
432#[allow(dead_code)]
433fn get_boundary_value_2d<T>(
434    data: &ArrayView2<T>,
435    y: isize,
436    x: isize,
437    mode: BoundaryMode,
438    cval: T,
439) -> T
440where
441    T: Float + FromPrimitive + Clone + std::ops::AddAssign + std::ops::DivAssign + 'static,
442{
443    let (h, w) = data.dim();
444    let h = h as isize;
445    let w = w as isize;
446
447    let (valid_y, valid_x) = match mode {
448        BoundaryMode::Constant => {
449            if y < 0 || y >= h || x < 0 || x >= w {
450                return cval;
451            }
452            (y as usize, x as usize)
453        }
454        BoundaryMode::Nearest => (y.clamp(0, h - 1) as usize, x.clamp(0, w - 1) as usize),
455        BoundaryMode::Reflect => {
456            let mut yi = y;
457            let mut xi = x;
458
459            if yi < 0 {
460                yi = -yi;
461            }
462            if yi >= h {
463                yi = 2 * h - yi - 2;
464            }
465
466            if xi < 0 {
467                xi = -xi;
468            }
469            if xi >= w {
470                xi = 2 * w - xi - 2;
471            }
472
473            (yi.clamp(0, h - 1) as usize, xi.clamp(0, w - 1) as usize)
474        }
475        BoundaryMode::Mirror => {
476            let mut yi = y;
477            let mut xi = x;
478
479            while yi < 0 {
480                yi = -yi - 1;
481            }
482            while yi >= h {
483                yi = 2 * h - yi - 1;
484            }
485
486            while xi < 0 {
487                xi = -xi - 1;
488            }
489            while xi >= w {
490                xi = 2 * w - xi - 1;
491            }
492
493            (yi as usize, xi as usize)
494        }
495        BoundaryMode::Wrap => (((y % h + h) % h) as usize, ((x % w + w) % w) as usize),
496    };
497
498    data[[valid_y, valid_x]]
499}
500
501/// Optimized map_coordinates implementation
502#[allow(dead_code)]
503pub fn map_coordinates_optimized<T>(
504    input: &Array2<T>,
505    coordinates: &[Array1<T>],
506    order: Option<InterpolationOrder>,
507    mode: Option<BoundaryMode>,
508    cval: Option<T>,
509) -> NdimageResult<Array1<T>>
510where
511    T: Float
512        + FromPrimitive
513        + Debug
514        + Clone
515        + Send
516        + Sync
517        + 'static
518        + std::ops::AddAssign
519        + std::ops::DivAssign,
520{
521    if coordinates.len() != 2 {
522        return Err(NdimageError::InvalidInput(
523            "Coordinates must have length 2 for 2D input".into(),
524        ));
525    }
526
527    let order = order.unwrap_or(InterpolationOrder::Cubic);
528    let mode = mode.unwrap_or(BoundaryMode::Constant);
529    let cval = cval.unwrap_or_else(T::zero);
530
531    let n_points = coordinates[0].len();
532    if coordinates[1].len() != n_points {
533        return Err(NdimageError::InvalidInput(
534            "All coordinate arrays must have the same length".into(),
535        ));
536    }
537
538    // Create interpolator
539    let interpolator = Interpolator2D::new(order);
540
541    // Prepare positions
542    let positions: Vec<(T, T)> = (0..n_points)
543        .map(|i| (coordinates[0][i], coordinates[1][i]))
544        .collect();
545
546    // Interpolate
547    let results = interpolator.interpolate_batch(input, &positions, mode, cval)?;
548
549    Ok(Array1::from_vec(results))
550}
551
552/// Optimized zoom operation with caching
553#[allow(dead_code)]
554pub fn zoom_optimized<T>(
555    input: &Array2<T>,
556    zoom_factors: &[T],
557    order: Option<InterpolationOrder>,
558    mode: Option<BoundaryMode>,
559    cval: Option<T>,
560) -> NdimageResult<Array2<T>>
561where
562    T: Float
563        + FromPrimitive
564        + Debug
565        + Clone
566        + Send
567        + Sync
568        + 'static
569        + std::ops::AddAssign
570        + std::ops::DivAssign,
571{
572    if zoom_factors.len() != 2 {
573        return Err(NdimageError::InvalidInput(
574            "Zoom _factors must have length 2 for 2D input".into(),
575        ));
576    }
577
578    let order = order.unwrap_or(InterpolationOrder::Cubic);
579    let mode = mode.unwrap_or(BoundaryMode::Constant);
580    let cval = cval.unwrap_or_else(T::zero);
581
582    let (h, w) = input.dim();
583    let new_h: T = safe_usize_to_float::<T>(h)? * zoom_factors[0];
584    let new_h = safe_to_usize(new_h.round())?;
585    let new_w: T = safe_usize_to_float::<T>(w)? * zoom_factors[1];
586    let new_w = safe_to_usize(new_w.round())?;
587
588    let mut output = Array2::zeros((new_h, new_w));
589
590    // Create interpolator
591    let interpolator = Interpolator2D::new(order);
592
593    // Process in parallel for large images
594    if new_h * new_w > 10000 && num_threads() > 1 {
595        let rows: Vec<usize> = (0..new_h).collect();
596
597        let process_row = |&row: &usize| -> Result<Vec<T>, scirs2_core::CoreError> {
598            let mut row_data = Vec::with_capacity(new_w);
599            let y = safe_usize_to_float::<T>(row).map_err(|e| {
600                scirs2_core::CoreError::ComputationError(scirs2_core::ErrorContext::new(format!(
601                    "Failed to convert row to float: {}",
602                    e
603                )))
604            })? / zoom_factors[0];
605
606            for col in 0..new_w {
607                let x = safe_usize_to_float::<T>(col).map_err(|e| {
608                    scirs2_core::CoreError::ComputationError(scirs2_core::ErrorContext::new(
609                        format!("Failed to convert col to float: {}", e),
610                    ))
611                })? / zoom_factors[1];
612                let val = interpolator
613                    .interpolate_single(input.view(), y, x, mode, cval)
614                    .map_err(|e| {
615                        scirs2_core::CoreError::ComputationError(scirs2_core::ErrorContext::new(
616                            format!("interpolation error: {:?}", e),
617                        ))
618                    })?;
619                row_data.push(val);
620            }
621
622            Ok(row_data)
623        };
624
625        let results = parallel_map_result(&rows, process_row)?;
626
627        // Copy results to output
628        for (row, row_data) in results.into_iter().enumerate() {
629            for (col, val) in row_data.into_iter().enumerate() {
630                output[[row, col]] = val;
631            }
632        }
633    } else {
634        // Sequential processing
635        for row in 0..new_h {
636            let y = safe_usize_to_float::<T>(row)? / zoom_factors[0];
637
638            for col in 0..new_w {
639                let x = safe_usize_to_float::<T>(col)? / zoom_factors[1];
640                output[[row, col]] =
641                    interpolator.interpolate_single(input.view(), y, x, mode, cval)?;
642            }
643        }
644    }
645
646    Ok(output)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use scirs2_core::ndarray::{arr1, arr2};
653
654    #[test]
655    fn test_coefficient_cache() {
656        let cache: CoefficientCache<f64> = CoefficientCache::new(10);
657
658        // Test linear interpolation coefficients
659        let coeffs1 = cache
660            .get_coefficients(InterpolationOrder::Linear, 0.3)
661            .expect("Operation failed");
662        assert_eq!(coeffs1.len(), 2);
663        assert!((coeffs1[0] - 0.7).abs() < 1e-10);
664        assert!((coeffs1[1] - 0.3).abs() < 1e-10);
665
666        // Test that same coefficients are cached
667        let coeffs2 = cache
668            .get_coefficients(InterpolationOrder::Linear, 0.3)
669            .expect("Operation failed");
670        assert_eq!(coeffs1, coeffs2);
671    }
672
673    #[test]
674    fn test_interpolator_1d() {
675        let data = arr1(&[1.0, 2.0, 3.0, 4.0]);
676        let interpolator = Interpolator1D::new(InterpolationOrder::Linear);
677
678        // Test exact positions
679        assert_eq!(
680            interpolator
681                .interpolate(&data.view(), 0.0, BoundaryMode::Constant, 0.0)
682                .expect("interpolation at exact position should succeed"),
683            1.0
684        );
685        assert_eq!(
686            interpolator
687                .interpolate(&data.view(), 1.0, BoundaryMode::Constant, 0.0)
688                .expect("interpolation at exact position should succeed"),
689            2.0
690        );
691
692        // Test interpolated position
693        let result = interpolator
694            .interpolate(&data.view(), 1.5, BoundaryMode::Constant, 0.0)
695            .expect("interpolation should succeed");
696        assert!((result - 2.5).abs() < 1e-10);
697    }
698
699    #[test]
700    fn test_zoom_optimized() {
701        let input = arr2(&[[1.0f64, 2.0], [3.0, 4.0]]);
702        // Use linear interpolation so the expected values are predictable
703        let result = zoom_optimized(
704            &input,
705            &[2.0, 2.0],
706            Some(InterpolationOrder::Linear),
707            None,
708            None,
709        )
710        .expect("zoom_optimized should succeed for test");
711
712        assert_eq!(result.shape(), &[4, 4]);
713
714        // With idx/zoom semantics (output pixel p maps to input coord p/zoom):
715        // result[[0, 0]]: input at (0.0, 0.0) = 1.0
716        assert!(
717            (result[[0, 0]] - 1.0).abs() < 1e-6,
718            "result[0,0]={}",
719            result[[0, 0]]
720        );
721
722        // result[[1, 0]]: input at (0.5, 0.0) -> linear between input[0,0]=1 and input[1,0]=3 -> 2.0
723        assert!(
724            (result[[1, 0]] - 2.0).abs() < 1e-6,
725            "result[1,0]={}",
726            result[[1, 0]]
727        );
728
729        // result[[0, 1]]: input at (0.0, 0.5) -> linear between input[0,0]=1 and input[0,1]=2 -> 1.5
730        assert!(
731            (result[[0, 1]] - 1.5).abs() < 1e-6,
732            "result[0,1]={}",
733            result[[0, 1]]
734        );
735
736        // result[[1, 1]]: input at (0.5, 0.5) -> bilinear: 0.25*(1+2+3+4) = 2.5
737        assert!(
738            (result[[1, 1]] - 2.5).abs() < 1e-6,
739            "result[1,1]={}",
740            result[[1, 1]]
741        );
742    }
743}