Skip to main content

spatialrust_vision/
resize.rs

1use rayon::prelude::*;
2use spatialrust_image::{ColorSpace, Image, ImageMetadata, ImageView, ImageViewMut, PlanarImage};
3
4use crate::dispatch::{
5    is_packed_u8_fast_path, should_parallelize, LIGHT_PARALLEL_COMPONENTS, ROWS_PER_TILE,
6    TALL_IMAGE_ROWS,
7};
8use crate::{PixelComponent, VisionError, VisionResult};
9
10/// Sampling filter used by image resampling and geometric warps.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12pub enum Interpolation {
13    /// Closest source pixel using half-pixel coordinate mapping.
14    Nearest,
15    /// Bilinear interpolation using half-pixel coordinate mapping.
16    #[default]
17    Bilinear,
18    /// Bicubic interpolation with OpenCV-compatible cubic coefficient `-0.75`.
19    Bicubic,
20    /// Pixel-area integration when shrinking; bilinear when enlarging.
21    Area,
22}
23
24const BILINEAR_WEIGHT_BITS: u32 = 11;
25const BILINEAR_WEIGHT_SCALE: u32 = 1 << BILINEAR_WEIGHT_BITS;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28struct BilinearAxisSample {
29    lower: usize,
30    upper: usize,
31    upper_weight: u16,
32}
33
34/// Reusable source coordinates and fixed-point coefficients for `u8` bilinear resize.
35///
36/// Constructing a plan performs all half-pixel coordinate mapping once. Execution
37/// never allocates and accepts packed or explicitly strided input/output views.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct BilinearResizeU8Plan {
40    input_width: usize,
41    input_height: usize,
42    output_width: usize,
43    output_height: usize,
44    x_samples: Vec<BilinearAxisSample>,
45    y_samples: Vec<BilinearAxisSample>,
46}
47
48/// Reusable source-coordinate plan for packed `u8` nearest-neighbor resize.
49///
50/// Execution uses the cached source indices for packed one- and three-channel
51/// images. Other channel counts and explicitly strided views use the generic
52/// resize path so the public fallback contract remains unchanged.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct NearestResizeU8Plan {
55    input_width: usize,
56    input_height: usize,
57    output_width: usize,
58    output_height: usize,
59    x_indices: Vec<usize>,
60    y_indices: Vec<usize>,
61}
62
63impl NearestResizeU8Plan {
64    /// Builds a plan for the named input and output dimensions.
65    pub fn new(
66        input_width: usize,
67        input_height: usize,
68        output_width: usize,
69        output_height: usize,
70    ) -> VisionResult<Self> {
71        validate_resize_dimensions(input_width, input_height, output_width, output_height)?;
72        Ok(Self {
73            input_width,
74            input_height,
75            output_width,
76            output_height,
77            x_indices: nearest_axis_indices(input_width, output_width),
78            y_indices: nearest_axis_indices(input_height, output_height),
79        })
80    }
81
82    /// Returns the planned input dimensions as `(width, height)`.
83    #[must_use]
84    pub const fn input_dimensions(&self) -> (usize, usize) {
85        (self.input_width, self.input_height)
86    }
87
88    /// Returns the planned output dimensions as `(width, height)`.
89    #[must_use]
90    pub const fn output_dimensions(&self) -> (usize, usize) {
91        (self.output_width, self.output_height)
92    }
93
94    /// Resizes into a newly allocated packed image.
95    pub fn resize<const CHANNELS: usize>(
96        &self,
97        input: ImageView<'_, u8, CHANNELS>,
98    ) -> VisionResult<Image<u8, CHANNELS>> {
99        let len = resize_output_len::<CHANNELS>(self.output_width, self.output_height)?;
100        let mut output = Image::try_new_with_metadata(
101            self.output_width,
102            self.output_height,
103            vec![0; len],
104            input.metadata(),
105        )?;
106        self.resize_into(input, output.view_mut())?;
107        Ok(output)
108    }
109
110    /// Resizes into caller-owned storage without allocating.
111    pub fn resize_into<const CHANNELS: usize>(
112        &self,
113        input: ImageView<'_, u8, CHANNELS>,
114        mut output: ImageViewMut<'_, u8, CHANNELS>,
115    ) -> VisionResult<()> {
116        validate_plan_views(self.input_dimensions(), self.output_dimensions(), input, &output)?;
117        if self.output_width == 0 || self.output_height == 0 {
118            output.set_metadata(input.metadata())?;
119            return Ok(());
120        }
121        if !is_packed_u8_fast_path::<CHANNELS>(
122            self.input_width,
123            input.row_stride(),
124            self.output_width,
125            output.row_stride(),
126        ) {
127            return resize_into(input, output, Interpolation::Nearest);
128        }
129
130        output.set_metadata(input.metadata())?;
131        let row_stride = output.row_stride();
132        let output_span = row_stride * self.output_height;
133        let run_row = |y: usize, row: &mut [u8]| {
134            let source = input.row(self.y_indices[y]).expect("planned source row");
135            for (x, &source_x) in self.x_indices.iter().enumerate() {
136                let source_offset = source_x * CHANNELS;
137                let destination = x * CHANNELS;
138                row[destination..destination + CHANNELS]
139                    .copy_from_slice(&source[source_offset..source_offset + CHANNELS]);
140            }
141        };
142        if should_parallelize(
143            self.output_width * self.output_height * CHANNELS,
144            self.output_height,
145            LIGHT_PARALLEL_COMPONENTS,
146        ) {
147            output.as_mut_slice()[..output_span]
148                .par_chunks_mut(row_stride)
149                .enumerate()
150                .for_each(|(y, row)| run_row(y, row));
151        } else {
152            output.as_mut_slice()[..output_span]
153                .chunks_mut(row_stride)
154                .enumerate()
155                .for_each(|(y, row)| run_row(y, row));
156        }
157        Ok(())
158    }
159}
160
161#[derive(Clone, Debug, PartialEq)]
162struct AreaAxisSpan {
163    start: usize,
164    weights: Vec<f64>,
165}
166
167/// Reusable weighted-span plan for packed `u8` area resize.
168///
169/// Exact 2x and 4x downscales use integer block averages. Other shrinking
170/// shapes cache the contributing source spans and overlap weights. Enlargement,
171/// non-packed views, and channel counts other than one or three use the generic
172/// area implementation.
173#[derive(Clone, Debug, PartialEq)]
174pub struct AreaResizeU8Plan {
175    input_width: usize,
176    input_height: usize,
177    output_width: usize,
178    output_height: usize,
179    integer_factor: Option<usize>,
180    x_spans: Vec<AreaAxisSpan>,
181    y_spans: Vec<AreaAxisSpan>,
182}
183
184impl AreaResizeU8Plan {
185    /// Builds a plan for the named input and output dimensions.
186    pub fn new(
187        input_width: usize,
188        input_height: usize,
189        output_width: usize,
190        output_height: usize,
191    ) -> VisionResult<Self> {
192        validate_resize_dimensions(input_width, input_height, output_width, output_height)?;
193        let downsample = output_width < input_width || output_height < input_height;
194        let integer_factor = [2, 4].into_iter().find(|&factor| {
195            input_width == output_width.saturating_mul(factor)
196                && input_height == output_height.saturating_mul(factor)
197        });
198        Ok(Self {
199            input_width,
200            input_height,
201            output_width,
202            output_height,
203            integer_factor,
204            x_spans: if downsample && integer_factor.is_none() {
205                area_axis_spans(input_width, output_width)
206            } else {
207                Vec::new()
208            },
209            y_spans: if downsample && integer_factor.is_none() {
210                area_axis_spans(input_height, output_height)
211            } else {
212                Vec::new()
213            },
214        })
215    }
216
217    /// Returns the planned input dimensions as `(width, height)`.
218    #[must_use]
219    pub const fn input_dimensions(&self) -> (usize, usize) {
220        (self.input_width, self.input_height)
221    }
222
223    /// Returns the planned output dimensions as `(width, height)`.
224    #[must_use]
225    pub const fn output_dimensions(&self) -> (usize, usize) {
226        (self.output_width, self.output_height)
227    }
228
229    /// Resizes into a newly allocated packed image.
230    pub fn resize<const CHANNELS: usize>(
231        &self,
232        input: ImageView<'_, u8, CHANNELS>,
233    ) -> VisionResult<Image<u8, CHANNELS>> {
234        let len = resize_output_len::<CHANNELS>(self.output_width, self.output_height)?;
235        let mut output = Image::try_new_with_metadata(
236            self.output_width,
237            self.output_height,
238            vec![0; len],
239            input.metadata(),
240        )?;
241        self.resize_into(input, output.view_mut())?;
242        Ok(output)
243    }
244
245    /// Resizes into caller-owned storage without allocating.
246    pub fn resize_into<const CHANNELS: usize>(
247        &self,
248        input: ImageView<'_, u8, CHANNELS>,
249        mut output: ImageViewMut<'_, u8, CHANNELS>,
250    ) -> VisionResult<()> {
251        validate_plan_views(self.input_dimensions(), self.output_dimensions(), input, &output)?;
252        if self.output_width == 0 || self.output_height == 0 {
253            output.set_metadata(input.metadata())?;
254            return Ok(());
255        }
256        let downsample =
257            self.output_width < self.input_width || self.output_height < self.input_height;
258        if !downsample
259            || !is_packed_u8_fast_path::<CHANNELS>(
260                self.input_width,
261                input.row_stride(),
262                self.output_width,
263                output.row_stride(),
264            )
265        {
266            return resize_into(input, output, Interpolation::Area);
267        }
268
269        output.set_metadata(input.metadata())?;
270        let row_stride = output.row_stride();
271        let output_span = row_stride * self.output_height;
272        let run_row = |y: usize, row: &mut [u8]| {
273            if let Some(factor) = self.integer_factor {
274                resize_integer_area_row(input, row, y, self.output_width, factor);
275            } else {
276                self.resize_weighted_area_row(input, row, y);
277            }
278        };
279        if should_parallelize(
280            self.output_width * self.output_height * CHANNELS,
281            self.output_height,
282            LIGHT_PARALLEL_COMPONENTS,
283        ) {
284            output.as_mut_slice()[..output_span]
285                .par_chunks_mut(row_stride)
286                .enumerate()
287                .for_each(|(y, row)| run_row(y, row));
288        } else {
289            output.as_mut_slice()[..output_span]
290                .chunks_mut(row_stride)
291                .enumerate()
292                .for_each(|(y, row)| run_row(y, row));
293        }
294        Ok(())
295    }
296
297    fn resize_weighted_area_row<const CHANNELS: usize>(
298        &self,
299        input: ImageView<'_, u8, CHANNELS>,
300        output: &mut [u8],
301        y: usize,
302    ) {
303        let y_span = &self.y_spans[y];
304        for (x, x_span) in self.x_spans.iter().enumerate() {
305            let mut sums = [0.0_f64; CHANNELS];
306            let mut total_weight = 0.0;
307            for (y_offset, &weight_y) in y_span.weights.iter().enumerate() {
308                let row = input.row(y_span.start + y_offset).expect("planned source row");
309                for (x_offset, &weight_x) in x_span.weights.iter().enumerate() {
310                    let weight = weight_x * weight_y;
311                    let source = (x_span.start + x_offset) * CHANNELS;
312                    for channel in 0..CHANNELS {
313                        sums[channel] += f64::from(row[source + channel]) * weight;
314                    }
315                    total_weight += weight;
316                }
317            }
318            let destination = x * CHANNELS;
319            for channel in 0..CHANNELS {
320                output[destination + channel] =
321                    PixelComponent::from_f64(sums[channel] / total_weight);
322            }
323        }
324    }
325}
326
327impl BilinearResizeU8Plan {
328    /// Builds a plan for the named input and output dimensions.
329    pub fn new(
330        input_width: usize,
331        input_height: usize,
332        output_width: usize,
333        output_height: usize,
334    ) -> VisionResult<Self> {
335        if output_width != 0 && output_height != 0 && (input_width == 0 || input_height == 0) {
336            return Err(VisionError::InvalidDimensions(
337                "cannot resize an empty input to a non-empty output".to_owned(),
338            ));
339        }
340        let half_scale = input_width == output_width.saturating_mul(2)
341            && input_height == output_height.saturating_mul(2);
342        Ok(Self {
343            input_width,
344            input_height,
345            output_width,
346            output_height,
347            x_samples: if half_scale {
348                Vec::new()
349            } else {
350                bilinear_axis_samples(input_width, output_width)
351            },
352            y_samples: if half_scale {
353                Vec::new()
354            } else {
355                bilinear_axis_samples(input_height, output_height)
356            },
357        })
358    }
359
360    /// Returns the planned input dimensions as `(width, height)`.
361    #[must_use]
362    pub const fn input_dimensions(&self) -> (usize, usize) {
363        (self.input_width, self.input_height)
364    }
365
366    /// Returns the planned output dimensions as `(width, height)`.
367    #[must_use]
368    pub const fn output_dimensions(&self) -> (usize, usize) {
369        (self.output_width, self.output_height)
370    }
371
372    /// Resizes into a newly allocated image while preserving input metadata.
373    pub fn resize<const CHANNELS: usize>(
374        &self,
375        input: ImageView<'_, u8, CHANNELS>,
376    ) -> VisionResult<Image<u8, CHANNELS>> {
377        let len = self
378            .output_width
379            .checked_mul(self.output_height)
380            .and_then(|pixels| pixels.checked_mul(CHANNELS))
381            .ok_or_else(|| {
382                VisionError::InvalidDimensions("resize output is too large".to_owned())
383            })?;
384        let mut output = Image::try_new_with_metadata(
385            self.output_width,
386            self.output_height,
387            vec![0; len],
388            input.metadata(),
389        )?;
390        self.resize_into(input, output.view_mut())?;
391        Ok(output)
392    }
393
394    /// Resizes into caller-owned storage without allocating.
395    pub fn resize_into<const CHANNELS: usize>(
396        &self,
397        input: ImageView<'_, u8, CHANNELS>,
398        mut output: ImageViewMut<'_, u8, CHANNELS>,
399    ) -> VisionResult<()> {
400        if (input.width(), input.height()) != self.input_dimensions() {
401            return Err(VisionError::InvalidDimensions(format!(
402                "resize plan expects input {}x{}, found {}x{}",
403                self.input_width,
404                self.input_height,
405                input.width(),
406                input.height()
407            )));
408        }
409        if (output.width(), output.height()) != self.output_dimensions() {
410            return Err(VisionError::InvalidDimensions(format!(
411                "resize plan expects output {}x{}, found {}x{}",
412                self.output_width,
413                self.output_height,
414                output.width(),
415                output.height()
416            )));
417        }
418        output.set_metadata(input.metadata())?;
419        if self.output_width == 0 || self.output_height == 0 {
420            return Ok(());
421        }
422
423        let row_stride = output.row_stride();
424        let output_components = self.output_width * self.output_height * CHANNELS;
425        let rows = output.as_mut_slice().par_chunks_mut(row_stride).enumerate();
426        if self.input_width == self.output_width.saturating_mul(2)
427            && self.input_height == self.output_height.saturating_mul(2)
428        {
429            if should_parallelize(output_components, self.output_height, LIGHT_PARALLEL_COMPONENTS)
430            {
431                rows.for_each(|(y, row)| resize_half_row(input, row, y, self.output_width));
432            } else {
433                output
434                    .as_mut_slice()
435                    .chunks_mut(row_stride)
436                    .enumerate()
437                    .for_each(|(y, row)| resize_half_row(input, row, y, self.output_width));
438            }
439        } else if should_parallelize(
440            output_components,
441            self.output_height,
442            LIGHT_PARALLEL_COMPONENTS,
443        ) {
444            rows.for_each(|(y, row)| self.resize_bilinear_row(input, row, y));
445        } else {
446            output
447                .as_mut_slice()
448                .chunks_mut(row_stride)
449                .enumerate()
450                .for_each(|(y, row)| self.resize_bilinear_row(input, row, y));
451        }
452        Ok(())
453    }
454
455    /// Fuses bilinear RGB resize and BT.601 grayscale conversion into one pass.
456    ///
457    /// The result is bit-exact with this plan's RGB [`Self::resize`] followed by
458    /// SpatialRust's Q14 RGB-to-gray conversion, while avoiding the intermediate
459    /// three-channel image.
460    pub fn resize_rgb_to_gray(&self, input: ImageView<'_, u8, 3>) -> VisionResult<Image<u8, 1>> {
461        let len = self.output_width.checked_mul(self.output_height).ok_or_else(|| {
462            VisionError::InvalidDimensions("fused resize-to-gray output is too large".to_owned())
463        })?;
464        let metadata = ImageMetadata { color_space: ColorSpace::Gray, ..input.metadata() };
465        let mut output = Image::try_new_with_metadata(
466            self.output_width,
467            self.output_height,
468            vec![0; len],
469            metadata,
470        )?;
471        self.resize_rgb_to_gray_into(input, output.view_mut())?;
472        Ok(output)
473    }
474
475    /// Fuses bilinear RGB resize and BT.601 grayscale conversion into caller-owned storage.
476    pub fn resize_rgb_to_gray_into(
477        &self,
478        input: ImageView<'_, u8, 3>,
479        mut output: ImageViewMut<'_, u8, 1>,
480    ) -> VisionResult<()> {
481        if (input.width(), input.height()) != self.input_dimensions() {
482            return Err(VisionError::InvalidDimensions(format!(
483                "resize plan expects input {}x{}, found {}x{}",
484                self.input_width,
485                self.input_height,
486                input.width(),
487                input.height()
488            )));
489        }
490        if (output.width(), output.height()) != self.output_dimensions() {
491            return Err(VisionError::InvalidDimensions(format!(
492                "resize plan expects output {}x{}, found {}x{}",
493                self.output_width,
494                self.output_height,
495                output.width(),
496                output.height()
497            )));
498        }
499        output.set_metadata(ImageMetadata { color_space: ColorSpace::Gray, ..input.metadata() })?;
500        if self.output_width == 0 || self.output_height == 0 {
501            return Ok(());
502        }
503
504        let row_stride = output.row_stride();
505        let half_scale = self.input_width == self.output_width.saturating_mul(2)
506            && self.input_height == self.output_height.saturating_mul(2);
507        let run_row = |y: usize, row: &mut [u8]| {
508            if half_scale {
509                resize_half_rgb_to_gray_row(input, row, y, self.output_width);
510            } else {
511                self.resize_bilinear_rgb_to_gray_row(input, row, y);
512            }
513        };
514        if should_parallelize(
515            self.output_width * self.output_height,
516            self.output_height,
517            LIGHT_PARALLEL_COMPONENTS,
518        ) {
519            if self.output_height >= TALL_IMAGE_ROWS {
520                let block_stride = row_stride.checked_mul(ROWS_PER_TILE).ok_or_else(|| {
521                    VisionError::InvalidDimensions("fused resize row block is too large".to_owned())
522                })?;
523                output.as_mut_slice().par_chunks_mut(block_stride).enumerate().for_each(
524                    |(block, rows)| {
525                        for (row, target) in rows.chunks_mut(row_stride).enumerate() {
526                            run_row(block * ROWS_PER_TILE + row, target);
527                        }
528                    },
529                );
530            } else {
531                output
532                    .as_mut_slice()
533                    .par_chunks_mut(row_stride)
534                    .enumerate()
535                    .for_each(|(y, row)| run_row(y, row));
536            }
537        } else {
538            output
539                .as_mut_slice()
540                .chunks_mut(row_stride)
541                .enumerate()
542                .for_each(|(y, row)| run_row(y, row));
543        }
544        Ok(())
545    }
546
547    /// Fuses bilinear RGB resize, per-channel normalization, and CHW packing.
548    ///
549    /// The result is bit-exact with this plan's RGB [`Self::resize`] followed by
550    /// SpatialRust's planar normalization for the same parameters, while avoiding
551    /// the intermediate resized RGB image.
552    pub fn resize_rgb_to_chw(
553        &self,
554        input: ImageView<'_, u8, 3>,
555        scale: f32,
556        mean: [f32; 3],
557        std: [f32; 3],
558    ) -> VisionResult<PlanarImage<f32, 3>> {
559        let plane_len = self.output_width.checked_mul(self.output_height).ok_or_else(|| {
560            VisionError::InvalidDimensions("fused resize-to-CHW plane is too large".to_owned())
561        })?;
562        let len = plane_len.checked_mul(3).ok_or_else(|| {
563            VisionError::InvalidDimensions("fused resize-to-CHW output is too large".to_owned())
564        })?;
565        let mut output = vec![0.0_f32; len];
566        self.resize_rgb_to_chw_into(input, scale, mean, std, &mut output)?;
567        Ok(PlanarImage::try_new_with_metadata(
568            self.output_width,
569            self.output_height,
570            output,
571            input.metadata(),
572        )?)
573    }
574
575    /// Fuses bilinear RGB resize, normalization, and CHW packing into a reusable slice.
576    pub fn resize_rgb_to_chw_into(
577        &self,
578        input: ImageView<'_, u8, 3>,
579        scale: f32,
580        mean: [f32; 3],
581        std: [f32; 3],
582        output: &mut [f32],
583    ) -> VisionResult<()> {
584        validate_rgb_chw_normalization(scale, std)?;
585        if (input.width(), input.height()) != self.input_dimensions() {
586            return Err(VisionError::InvalidDimensions(format!(
587                "resize plan expects input {}x{}, found {}x{}",
588                self.input_width,
589                self.input_height,
590                input.width(),
591                input.height()
592            )));
593        }
594        let plane_len = self.output_width.checked_mul(self.output_height).ok_or_else(|| {
595            VisionError::InvalidDimensions("fused resize-to-CHW plane is too large".to_owned())
596        })?;
597        let required = plane_len.checked_mul(3).ok_or_else(|| {
598            VisionError::InvalidDimensions("fused resize-to-CHW output is too large".to_owned())
599        })?;
600        if output.len() != required {
601            return Err(VisionError::ShapeMismatch(format!(
602                "CHW output needs {required} elements, found {}",
603                output.len()
604            )));
605        }
606        if plane_len == 0 {
607            return Ok(());
608        }
609
610        let half_scale = self.input_width == self.output_width.saturating_mul(2)
611            && self.input_height == self.output_height.saturating_mul(2);
612        let run_row = |plane_row: usize, target: &mut [f32]| {
613            let channel = plane_row / self.output_height;
614            let y = plane_row % self.output_height;
615            if half_scale {
616                resize_half_rgb_to_chw_row(
617                    input,
618                    target,
619                    y,
620                    channel,
621                    scale,
622                    mean[channel],
623                    std[channel],
624                );
625            } else {
626                self.resize_bilinear_rgb_to_chw_row(
627                    input,
628                    target,
629                    y,
630                    channel,
631                    scale,
632                    mean[channel],
633                    std[channel],
634                );
635            }
636        };
637        if should_parallelize(required, self.output_height * 3, LIGHT_PARALLEL_COMPONENTS) {
638            output
639                .par_chunks_mut(self.output_width)
640                .enumerate()
641                .for_each(|(plane_row, target)| run_row(plane_row, target));
642        } else {
643            output
644                .chunks_mut(self.output_width)
645                .enumerate()
646                .for_each(|(plane_row, target)| run_row(plane_row, target));
647        }
648        Ok(())
649    }
650
651    fn resize_bilinear_row<const CHANNELS: usize>(
652        &self,
653        input: ImageView<'_, u8, CHANNELS>,
654        output: &mut [u8],
655        y: usize,
656    ) {
657        let y_sample = self.y_samples[y];
658        let top = input.row(y_sample.lower).expect("planned source row");
659        let bottom = input.row(y_sample.upper).expect("planned source row");
660        let wy = u32::from(y_sample.upper_weight);
661        let inv_wy = BILINEAR_WEIGHT_SCALE - wy;
662        let round = 1 << (BILINEAR_WEIGHT_BITS * 2 - 1);
663        for (x, x_sample) in self.x_samples.iter().copied().enumerate() {
664            let wx = u32::from(x_sample.upper_weight);
665            let inv_wx = BILINEAR_WEIGHT_SCALE - wx;
666            let lower = x_sample.lower * CHANNELS;
667            let upper = x_sample.upper * CHANNELS;
668            let destination = x * CHANNELS;
669            for channel in 0..CHANNELS {
670                let horizontal_top =
671                    u32::from(top[lower + channel]) * inv_wx + u32::from(top[upper + channel]) * wx;
672                let horizontal_bottom = u32::from(bottom[lower + channel]) * inv_wx
673                    + u32::from(bottom[upper + channel]) * wx;
674                output[destination + channel] =
675                    ((horizontal_top * inv_wy + horizontal_bottom * wy + round)
676                        >> (BILINEAR_WEIGHT_BITS * 2)) as u8;
677            }
678        }
679    }
680
681    fn resize_bilinear_rgb_to_gray_row(
682        &self,
683        input: ImageView<'_, u8, 3>,
684        output: &mut [u8],
685        y: usize,
686    ) {
687        let y_sample = self.y_samples[y];
688        let top = input.row(y_sample.lower).expect("planned source row");
689        let bottom = input.row(y_sample.upper).expect("planned source row");
690        let wy = u32::from(y_sample.upper_weight);
691        let inv_wy = BILINEAR_WEIGHT_SCALE - wy;
692        for (x, x_sample) in self.x_samples.iter().copied().enumerate() {
693            let wx = u32::from(x_sample.upper_weight);
694            let inv_wx = BILINEAR_WEIGHT_SCALE - wx;
695            let lower = x_sample.lower * 3;
696            let upper = x_sample.upper * 3;
697            let pixel = std::array::from_fn(|channel| {
698                bilinear_u8_component(
699                    top[lower + channel],
700                    top[upper + channel],
701                    bottom[lower + channel],
702                    bottom[upper + channel],
703                    inv_wx,
704                    wx,
705                    inv_wy,
706                    wy,
707                )
708            });
709            output[x] = rgb_luma_q14(pixel);
710        }
711    }
712
713    #[allow(clippy::too_many_arguments)]
714    fn resize_bilinear_rgb_to_chw_row(
715        &self,
716        input: ImageView<'_, u8, 3>,
717        output: &mut [f32],
718        y: usize,
719        channel: usize,
720        scale: f32,
721        mean: f32,
722        std: f32,
723    ) {
724        let y_sample = self.y_samples[y];
725        let top = input.row(y_sample.lower).expect("planned source row");
726        let bottom = input.row(y_sample.upper).expect("planned source row");
727        let wy = u32::from(y_sample.upper_weight);
728        let inv_wy = BILINEAR_WEIGHT_SCALE - wy;
729        for (x, x_sample) in self.x_samples.iter().copied().enumerate() {
730            let wx = u32::from(x_sample.upper_weight);
731            let inv_wx = BILINEAR_WEIGHT_SCALE - wx;
732            let lower = x_sample.lower * 3 + channel;
733            let upper = x_sample.upper * 3 + channel;
734            let value = bilinear_u8_component(
735                top[lower],
736                top[upper],
737                bottom[lower],
738                bottom[upper],
739                inv_wx,
740                wx,
741                inv_wy,
742                wy,
743            );
744            output[x] = (f32::from(value) * scale - mean) / std;
745        }
746    }
747}
748
749fn validate_resize_dimensions(
750    input_width: usize,
751    input_height: usize,
752    output_width: usize,
753    output_height: usize,
754) -> VisionResult<()> {
755    if output_width != 0 && output_height != 0 && (input_width == 0 || input_height == 0) {
756        return Err(VisionError::InvalidDimensions(
757            "cannot resize an empty input to a non-empty output".to_owned(),
758        ));
759    }
760    Ok(())
761}
762
763fn resize_output_len<const CHANNELS: usize>(width: usize, height: usize) -> VisionResult<usize> {
764    width
765        .checked_mul(height)
766        .and_then(|pixels| pixels.checked_mul(CHANNELS))
767        .ok_or_else(|| VisionError::InvalidDimensions("resize output is too large".to_owned()))
768}
769
770fn validate_plan_views<const CHANNELS: usize>(
771    input_dimensions: (usize, usize),
772    output_dimensions: (usize, usize),
773    input: ImageView<'_, u8, CHANNELS>,
774    output: &ImageViewMut<'_, u8, CHANNELS>,
775) -> VisionResult<()> {
776    if (input.width(), input.height()) != input_dimensions {
777        return Err(VisionError::InvalidDimensions(format!(
778            "resize plan expects input {}x{}, found {}x{}",
779            input_dimensions.0,
780            input_dimensions.1,
781            input.width(),
782            input.height()
783        )));
784    }
785    if (output.width(), output.height()) != output_dimensions {
786        return Err(VisionError::InvalidDimensions(format!(
787            "resize plan expects output {}x{}, found {}x{}",
788            output_dimensions.0,
789            output_dimensions.1,
790            output.width(),
791            output.height()
792        )));
793    }
794    Ok(())
795}
796
797fn nearest_axis_indices(input_len: usize, output_len: usize) -> Vec<usize> {
798    if input_len == 0 || output_len == 0 {
799        return Vec::new();
800    }
801    (0..output_len)
802        .map(|output| {
803            clamped_index(
804                half_pixel_coordinate(output, input_len, output_len).round() as isize,
805                input_len,
806            )
807        })
808        .collect()
809}
810
811fn area_axis_spans(input_len: usize, output_len: usize) -> Vec<AreaAxisSpan> {
812    if input_len == 0 || output_len == 0 {
813        return Vec::new();
814    }
815    let scale = input_len as f64 / output_len as f64;
816    (0..output_len)
817        .map(|output| {
818            let start_coordinate = output as f64 * scale;
819            let end_coordinate = (output + 1) as f64 * scale;
820            let start = start_coordinate.floor() as usize;
821            let end = end_coordinate.ceil().min(input_len as f64) as usize;
822            let weights = (start..end)
823                .map(|source| {
824                    (end_coordinate.min(source as f64 + 1.0) - start_coordinate.max(source as f64))
825                        .max(0.0)
826                })
827                .collect();
828            AreaAxisSpan { start, weights }
829        })
830        .collect()
831}
832
833fn resize_integer_area_row<const CHANNELS: usize>(
834    input: ImageView<'_, u8, CHANNELS>,
835    output: &mut [u8],
836    y: usize,
837    output_width: usize,
838    factor: usize,
839) {
840    let divisor = (factor * factor) as u32;
841    for x in 0..output_width {
842        let destination = x * CHANNELS;
843        for channel in 0..CHANNELS {
844            let mut sum = 0_u32;
845            for source_y in y * factor..(y + 1) * factor {
846                let row = input.row(source_y).expect("integer area source row");
847                for source_x in x * factor..(x + 1) * factor {
848                    sum += u32::from(row[source_x * CHANNELS + channel]);
849                }
850            }
851            output[destination + channel] = ((sum + divisor / 2) / divisor) as u8;
852        }
853    }
854}
855
856fn validate_rgb_chw_normalization(scale: f32, std: [f32; 3]) -> VisionResult<()> {
857    if !scale.is_finite() || std.iter().any(|value| !value.is_finite() || *value == 0.0) {
858        return Err(VisionError::InvalidParameter(
859            "normalization scale/std must be finite and std non-zero".to_owned(),
860        ));
861    }
862    Ok(())
863}
864
865#[inline(always)]
866fn bilinear_u8_component(
867    top_left: u8,
868    top_right: u8,
869    bottom_left: u8,
870    bottom_right: u8,
871    inv_wx: u32,
872    wx: u32,
873    inv_wy: u32,
874    wy: u32,
875) -> u8 {
876    let top = u32::from(top_left) * inv_wx + u32::from(top_right) * wx;
877    let bottom = u32::from(bottom_left) * inv_wx + u32::from(bottom_right) * wx;
878    let round = 1 << (BILINEAR_WEIGHT_BITS * 2 - 1);
879    ((top * inv_wy + bottom * wy + round) >> (BILINEAR_WEIGHT_BITS * 2)) as u8
880}
881
882#[inline(always)]
883fn rgb_luma_q14(pixel: [u8; 3]) -> u8 {
884    ((4_899_u32 * u32::from(pixel[0])
885        + 9_617_u32 * u32::from(pixel[1])
886        + 1_868_u32 * u32::from(pixel[2])
887        + 8_192)
888        >> 14) as u8
889}
890
891fn bilinear_axis_samples(input_len: usize, output_len: usize) -> Vec<BilinearAxisSample> {
892    if input_len == 0 {
893        return Vec::new();
894    }
895    (0..output_len)
896        .map(|output| {
897            let coordinate = half_pixel_coordinate(output, input_len, output_len);
898            let base = coordinate.floor() as isize;
899            BilinearAxisSample {
900                lower: clamped_index(base, input_len),
901                upper: clamped_index(base + 1, input_len),
902                upper_weight: ((coordinate - coordinate.floor()) * f64::from(BILINEAR_WEIGHT_SCALE))
903                    .round()
904                    .clamp(0.0, f64::from(BILINEAR_WEIGHT_SCALE))
905                    as u16,
906            }
907        })
908        .collect()
909}
910
911fn resize_half_row<const CHANNELS: usize>(
912    input: ImageView<'_, u8, CHANNELS>,
913    output: &mut [u8],
914    y: usize,
915    output_width: usize,
916) {
917    let top = input.row(y * 2).expect("half-scale source row");
918    let bottom = input.row(y * 2 + 1).expect("half-scale source row");
919    for x in 0..output_width {
920        let source = x * 2 * CHANNELS;
921        let destination = x * CHANNELS;
922        for channel in 0..CHANNELS {
923            let sum = u16::from(top[source + channel])
924                + u16::from(top[source + CHANNELS + channel])
925                + u16::from(bottom[source + channel])
926                + u16::from(bottom[source + CHANNELS + channel]);
927            output[destination + channel] = ((sum + 2) >> 2) as u8;
928        }
929    }
930}
931
932fn resize_half_rgb_to_gray_row(
933    input: ImageView<'_, u8, 3>,
934    output: &mut [u8],
935    y: usize,
936    output_width: usize,
937) {
938    let top = input.row(y * 2).expect("half-scale source row");
939    let bottom = input.row(y * 2 + 1).expect("half-scale source row");
940    for (x, target) in output.iter_mut().take(output_width).enumerate() {
941        let source = x * 6;
942        let pixel = std::array::from_fn(|channel| {
943            let sum = u16::from(top[source + channel])
944                + u16::from(top[source + 3 + channel])
945                + u16::from(bottom[source + channel])
946                + u16::from(bottom[source + 3 + channel]);
947            ((sum + 2) >> 2) as u8
948        });
949        *target = rgb_luma_q14(pixel);
950    }
951}
952
953#[allow(clippy::too_many_arguments)]
954fn resize_half_rgb_to_chw_row(
955    input: ImageView<'_, u8, 3>,
956    output: &mut [f32],
957    y: usize,
958    channel: usize,
959    scale: f32,
960    mean: f32,
961    std: f32,
962) {
963    let top = input.row(y * 2).expect("half-scale source row");
964    let bottom = input.row(y * 2 + 1).expect("half-scale source row");
965    for (x, target) in output.iter_mut().enumerate() {
966        let source = x * 6 + channel;
967        let sum = u16::from(top[source])
968            + u16::from(top[source + 3])
969            + u16::from(bottom[source])
970            + u16::from(bottom[source + 3]);
971        let value = ((sum + 2) >> 2) as u8;
972        *target = (f32::from(value) * scale - mean) / std;
973    }
974}
975
976/// Resizes an interleaved image while preserving semantic metadata.
977pub fn resize<T: PixelComponent, const CHANNELS: usize>(
978    input: ImageView<'_, T, CHANNELS>,
979    output_width: usize,
980    output_height: usize,
981    interpolation: Interpolation,
982) -> VisionResult<Image<T, CHANNELS>> {
983    if output_width == 0 || output_height == 0 {
984        return Image::try_new_with_metadata(
985            output_width,
986            output_height,
987            Vec::new(),
988            input.metadata(),
989        )
990        .map_err(Into::into);
991    }
992    if input.width() == 0 || input.height() == 0 {
993        return Err(VisionError::InvalidDimensions(
994            "cannot resize an empty input to a non-empty output".to_owned(),
995        ));
996    }
997
998    let mut output = Vec::with_capacity(output_width * output_height * CHANNELS);
999    let area_downsample = interpolation == Interpolation::Area
1000        && (output_width < input.width() || output_height < input.height());
1001    for y in 0..output_height {
1002        for x in 0..output_width {
1003            let pixel = resized_pixel(
1004                input,
1005                x,
1006                y,
1007                output_width,
1008                output_height,
1009                interpolation,
1010                area_downsample,
1011            );
1012            output.extend_from_slice(&pixel);
1013        }
1014    }
1015    Ok(Image::try_new_with_metadata(output_width, output_height, output, input.metadata())?)
1016}
1017
1018/// Resizes into caller-owned storage without allocating.
1019///
1020/// The output dimensions select the requested size. Packed and strided output
1021/// views are accepted, and semantic metadata is replaced with the input
1022/// metadata after channel validation.
1023pub fn resize_into<T: PixelComponent, const CHANNELS: usize>(
1024    input: ImageView<'_, T, CHANNELS>,
1025    mut output: ImageViewMut<'_, T, CHANNELS>,
1026    interpolation: Interpolation,
1027) -> VisionResult<()> {
1028    let output_width = output.width();
1029    let output_height = output.height();
1030    output.set_metadata(input.metadata())?;
1031    if output_width == 0 || output_height == 0 {
1032        return Ok(());
1033    }
1034    if input.width() == 0 || input.height() == 0 {
1035        return Err(VisionError::InvalidDimensions(
1036            "cannot resize an empty input to a non-empty output".to_owned(),
1037        ));
1038    }
1039    let area_downsample = interpolation == Interpolation::Area
1040        && (output_width < input.width() || output_height < input.height());
1041    for y in 0..output_height {
1042        let row = output.row_mut(y).expect("output row in bounds");
1043        for x in 0..output_width {
1044            let pixel = resized_pixel(
1045                input,
1046                x,
1047                y,
1048                output_width,
1049                output_height,
1050                interpolation,
1051                area_downsample,
1052            );
1053            row[x * CHANNELS..(x + 1) * CHANNELS].copy_from_slice(&pixel);
1054        }
1055    }
1056    Ok(())
1057}
1058
1059fn resized_pixel<T: PixelComponent, const CHANNELS: usize>(
1060    input: ImageView<'_, T, CHANNELS>,
1061    x: usize,
1062    y: usize,
1063    output_width: usize,
1064    output_height: usize,
1065    interpolation: Interpolation,
1066    area_downsample: bool,
1067) -> [T; CHANNELS] {
1068    if area_downsample {
1069        sample_area(input, x, y, output_width, output_height)
1070    } else {
1071        let sx = half_pixel_coordinate(x, input.width(), output_width);
1072        let sy = half_pixel_coordinate(y, input.height(), output_height);
1073        match interpolation {
1074            Interpolation::Nearest => sample_nearest(input, sx, sy),
1075            Interpolation::Bilinear | Interpolation::Area => sample_bilinear(input, sx, sy),
1076            Interpolation::Bicubic => sample_bicubic(input, sx, sy),
1077        }
1078    }
1079}
1080
1081fn half_pixel_coordinate(output: usize, input_len: usize, output_len: usize) -> f64 {
1082    (output as f64 + 0.5) * input_len as f64 / output_len as f64 - 0.5
1083}
1084
1085fn clamped_index(value: isize, length: usize) -> usize {
1086    value.clamp(0, length.saturating_sub(1) as isize) as usize
1087}
1088
1089pub(crate) fn sample_nearest<T: PixelComponent, const CHANNELS: usize>(
1090    input: ImageView<'_, T, CHANNELS>,
1091    x: f64,
1092    y: f64,
1093) -> [T; CHANNELS] {
1094    let ix = clamped_index(x.round() as isize, input.width());
1095    let iy = clamped_index(y.round() as isize, input.height());
1096    *input.get(ix, iy).expect("clamped resize coordinate")
1097}
1098
1099pub(crate) fn sample_bilinear<T: PixelComponent, const CHANNELS: usize>(
1100    input: ImageView<'_, T, CHANNELS>,
1101    x: f64,
1102    y: f64,
1103) -> [T; CHANNELS] {
1104    let x0_raw = x.floor() as isize;
1105    let y0_raw = y.floor() as isize;
1106    let x1_raw = x0_raw + 1;
1107    let y1_raw = y0_raw + 1;
1108    let wx = x - x.floor();
1109    let wy = y - y.floor();
1110    let x0 = clamped_index(x0_raw, input.width());
1111    let x1 = clamped_index(x1_raw, input.width());
1112    let y0 = clamped_index(y0_raw, input.height());
1113    let y1 = clamped_index(y1_raw, input.height());
1114    let p00 = input.get(x0, y0).expect("clamped resize coordinate");
1115    let p10 = input.get(x1, y0).expect("clamped resize coordinate");
1116    let p01 = input.get(x0, y1).expect("clamped resize coordinate");
1117    let p11 = input.get(x1, y1).expect("clamped resize coordinate");
1118    std::array::from_fn(|channel| {
1119        let top = p00[channel].to_f64().mul_add(1.0 - wx, p10[channel].to_f64() * wx);
1120        let bottom = p01[channel].to_f64().mul_add(1.0 - wx, p11[channel].to_f64() * wx);
1121        T::from_f64(top.mul_add(1.0 - wy, bottom * wy))
1122    })
1123}
1124
1125fn cubic_weight(distance: f64) -> f64 {
1126    let x = distance.abs();
1127    const A: f64 = -0.75;
1128    if x <= 1.0 {
1129        (A + 2.0) * x * x * x - (A + 3.0) * x * x + 1.0
1130    } else if x < 2.0 {
1131        A * x * x * x - 5.0 * A * x * x + 8.0 * A * x - 4.0 * A
1132    } else {
1133        0.0
1134    }
1135}
1136
1137fn sample_bicubic<T: PixelComponent, const CHANNELS: usize>(
1138    input: ImageView<'_, T, CHANNELS>,
1139    x: f64,
1140    y: f64,
1141) -> [T; CHANNELS] {
1142    let base_x = x.floor() as isize;
1143    let base_y = y.floor() as isize;
1144    let mut sums = [0.0_f64; CHANNELS];
1145    let mut total_weight = 0.0;
1146    for dy in -1..=2 {
1147        let wy = cubic_weight(y - (base_y + dy) as f64);
1148        let iy = clamped_index(base_y + dy, input.height());
1149        for dx in -1..=2 {
1150            let weight = wy * cubic_weight(x - (base_x + dx) as f64);
1151            let ix = clamped_index(base_x + dx, input.width());
1152            let pixel = input.get(ix, iy).expect("clamped resize coordinate");
1153            for channel in 0..CHANNELS {
1154                sums[channel] += pixel[channel].to_f64() * weight;
1155            }
1156            total_weight += weight;
1157        }
1158    }
1159    std::array::from_fn(|channel| T::from_f64(sums[channel] / total_weight))
1160}
1161
1162fn sample_area<T: PixelComponent, const CHANNELS: usize>(
1163    input: ImageView<'_, T, CHANNELS>,
1164    output_x: usize,
1165    output_y: usize,
1166    output_width: usize,
1167    output_height: usize,
1168) -> [T; CHANNELS] {
1169    let scale_x = input.width() as f64 / output_width as f64;
1170    let scale_y = input.height() as f64 / output_height as f64;
1171    let start_x = output_x as f64 * scale_x;
1172    let end_x = (output_x + 1) as f64 * scale_x;
1173    let start_y = output_y as f64 * scale_y;
1174    let end_y = (output_y + 1) as f64 * scale_y;
1175    let mut sums = [0.0_f64; CHANNELS];
1176    let mut total_weight = 0.0;
1177    for iy in start_y.floor() as usize..end_y.ceil().min(input.height() as f64) as usize {
1178        let overlap_y = (end_y.min(iy as f64 + 1.0) - start_y.max(iy as f64)).max(0.0);
1179        for ix in start_x.floor() as usize..end_x.ceil().min(input.width() as f64) as usize {
1180            let overlap_x = (end_x.min(ix as f64 + 1.0) - start_x.max(ix as f64)).max(0.0);
1181            let weight = overlap_x * overlap_y;
1182            let pixel = input.get(ix, iy).expect("area sample within image");
1183            for channel in 0..CHANNELS {
1184                sums[channel] += pixel[channel].to_f64() * weight;
1185            }
1186            total_weight += weight;
1187        }
1188    }
1189    std::array::from_fn(|channel| T::from_f64(sums[channel] / total_weight))
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::{
1195        resize, resize_into, AreaResizeU8Plan, BilinearResizeU8Plan, Interpolation,
1196        NearestResizeU8Plan,
1197    };
1198    use proptest::prelude::*;
1199    use spatialrust_image::{Image, ImageView, ImageViewMut};
1200
1201    #[test]
1202    fn nearest_repeats_pixels() {
1203        let input = Image::<u8, 1>::try_new(2, 1, vec![10, 20]).unwrap();
1204        let output = resize(input.view(), 4, 1, Interpolation::Nearest).unwrap();
1205        assert_eq!(output.as_slice(), &[10, 10, 20, 20]);
1206    }
1207
1208    #[test]
1209    fn bilinear_interpolates_center() {
1210        let input = Image::<f32, 1>::try_new(2, 2, vec![0.0, 10.0, 20.0, 30.0]).unwrap();
1211        let output = resize(input.view(), 3, 3, Interpolation::Bilinear).unwrap();
1212        assert!((output[(1, 1)][0] - 15.0).abs() < 1e-6);
1213    }
1214
1215    #[test]
1216    fn area_computes_block_average() {
1217        let input = Image::<u8, 1>::try_new(2, 2, vec![0, 10, 20, 30]).unwrap();
1218        let output = resize(input.view(), 1, 1, Interpolation::Area).unwrap();
1219        assert_eq!(output.as_slice(), &[15]);
1220    }
1221
1222    #[test]
1223    fn identity_resize_is_exact_for_all_filters() {
1224        let input = Image::<u8, 3>::try_new(2, 2, (0..12).collect()).unwrap();
1225        for filter in [
1226            Interpolation::Nearest,
1227            Interpolation::Bilinear,
1228            Interpolation::Bicubic,
1229            Interpolation::Area,
1230        ] {
1231            assert_eq!(resize(input.view(), 2, 2, filter).unwrap(), input);
1232        }
1233    }
1234
1235    #[test]
1236    fn resize_into_reuses_strided_output_and_preserves_padding() {
1237        let input = Image::<u8, 1>::try_new(2, 2, vec![0, 10, 20, 30]).unwrap();
1238        let mut storage = vec![99_u8; 15];
1239        let output = ImageViewMut::<u8, 1>::new(3, 3, 6, &mut storage).unwrap();
1240        resize_into(input.view(), output, Interpolation::Bilinear).unwrap();
1241        let expected = resize(input.view(), 3, 3, Interpolation::Bilinear).unwrap();
1242        for y in 0..3 {
1243            assert_eq!(&storage[y * 6..y * 6 + 3], expected.view().row(y).unwrap());
1244            if y < 2 {
1245                assert_eq!(&storage[y * 6 + 3..(y + 1) * 6], &[99; 3]);
1246            }
1247        }
1248    }
1249
1250    #[test]
1251    fn bilinear_u8_plan_matches_generic_with_bounded_rounding() {
1252        let input =
1253            Image::<u8, 3>::try_new(7, 5, (0..105).map(|value| (value * 37 % 256) as u8).collect())
1254                .unwrap();
1255        let plan = BilinearResizeU8Plan::new(7, 5, 11, 8).unwrap();
1256        let actual = plan.resize(input.view()).unwrap();
1257        let expected = resize(input.view(), 11, 8, Interpolation::Bilinear).unwrap();
1258        assert!(actual
1259            .as_slice()
1260            .iter()
1261            .zip(expected.as_slice())
1262            .all(|(&left, &right)| left.abs_diff(right) <= 1));
1263    }
1264
1265    #[test]
1266    fn bilinear_u8_plan_half_scale_matches_generic_exactly() {
1267        let input =
1268            Image::<u8, 3>::try_new(8, 6, (0..144).map(|value| (value * 53 % 256) as u8).collect())
1269                .unwrap();
1270        let plan = BilinearResizeU8Plan::new(8, 6, 4, 3).unwrap();
1271        let actual = plan.resize(input.view()).unwrap();
1272        let expected = resize(input.view(), 4, 3, Interpolation::Bilinear).unwrap();
1273        assert_eq!(actual, expected);
1274    }
1275
1276    #[test]
1277    fn bilinear_u8_plan_preserves_strided_padding_and_validates_shape() {
1278        let input = Image::<u8, 1>::try_new(4, 4, (0..16).collect()).unwrap();
1279        let plan = BilinearResizeU8Plan::new(4, 4, 2, 2).unwrap();
1280        let mut storage = vec![99_u8; 7];
1281        let output = ImageViewMut::<u8, 1>::new(2, 2, 5, &mut storage).unwrap();
1282        plan.resize_into(input.view(), output).unwrap();
1283        assert_eq!(&storage, &[3, 5, 99, 99, 99, 11, 13]);
1284
1285        let wrong = BilinearResizeU8Plan::new(5, 4, 2, 2).unwrap();
1286        let mut output = Image::<u8, 1>::try_new(2, 2, vec![0; 4]).unwrap();
1287        assert!(wrong.resize_into(input.view(), output.view_mut()).is_err());
1288    }
1289
1290    #[test]
1291    fn bilinear_u8_plan_accepts_strided_rgb_input() {
1292        let mut storage = vec![231_u8; 54];
1293        for y in 0..4 {
1294            for x in 0..12 {
1295                storage[y * 14 + x] = (y * 41 + x * 13) as u8;
1296            }
1297        }
1298        let input = ImageView::<u8, 3>::new(4, 4, 14, &storage).unwrap();
1299        let plan = BilinearResizeU8Plan::new(4, 4, 2, 2).unwrap();
1300        let actual = plan.resize(input).unwrap();
1301        let expected = resize(input, 2, 2, Interpolation::Bilinear).unwrap();
1302        assert_eq!(actual, expected);
1303    }
1304
1305    proptest! {
1306        #![proptest_config(ProptestConfig::with_cases(300))]
1307
1308        #[test]
1309        fn packed_nearest_and_area_plans_are_bit_exact(
1310            input_width in 1_usize..25,
1311            input_height in 1_usize..25,
1312            output_width in 1_usize..25,
1313            output_height in 1_usize..25,
1314            seed in any::<u64>(),
1315        ) {
1316            let mut state = seed;
1317            let mut next_u8 = || {
1318                state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1319                (state >> 32) as u8
1320            };
1321            let rgb_data = (0..input_width * input_height * 3)
1322                .map(|_| next_u8())
1323                .collect::<Vec<_>>();
1324            let gray_data = (0..input_width * input_height)
1325                .map(|_| next_u8())
1326                .collect::<Vec<_>>();
1327            let rgb = Image::<u8, 3>::try_new(input_width, input_height, rgb_data).unwrap();
1328            let gray = Image::<u8, 1>::try_new(input_width, input_height, gray_data).unwrap();
1329
1330            let nearest = NearestResizeU8Plan::new(
1331                input_width,
1332                input_height,
1333                output_width,
1334                output_height,
1335            )
1336            .unwrap();
1337            prop_assert_eq!(
1338                nearest.resize(rgb.view()).unwrap(),
1339                resize(rgb.view(), output_width, output_height, Interpolation::Nearest).unwrap(),
1340            );
1341            prop_assert_eq!(
1342                nearest.resize(gray.view()).unwrap(),
1343                resize(gray.view(), output_width, output_height, Interpolation::Nearest).unwrap(),
1344            );
1345
1346            let area = AreaResizeU8Plan::new(
1347                input_width,
1348                input_height,
1349                output_width,
1350                output_height,
1351            )
1352            .unwrap();
1353            prop_assert_eq!(
1354                area.resize(rgb.view()).unwrap(),
1355                resize(rgb.view(), output_width, output_height, Interpolation::Area).unwrap(),
1356            );
1357            prop_assert_eq!(
1358                area.resize(gray.view()).unwrap(),
1359                resize(gray.view(), output_width, output_height, Interpolation::Area).unwrap(),
1360            );
1361        }
1362    }
1363
1364    #[test]
1365    fn nearest_and_area_plans_fall_back_for_strides_and_preserve_padding() {
1366        let mut input_storage = vec![211_u8; 47];
1367        for y in 0..4 {
1368            for x in 0..9 {
1369                input_storage[y * 11 + x] = (y * 67 + x * 19) as u8;
1370            }
1371        }
1372        let input = ImageView::<u8, 3>::new(3, 4, 11, &input_storage).unwrap();
1373        for (interpolation, nearest) in
1374            [(Interpolation::Nearest, true), (Interpolation::Area, false)]
1375        {
1376            let mut storage = vec![199_u8; 29];
1377            let output = ImageViewMut::<u8, 3>::new(2, 3, 10, &mut storage).unwrap();
1378            if nearest {
1379                NearestResizeU8Plan::new(3, 4, 2, 3).unwrap().resize_into(input, output).unwrap();
1380            } else {
1381                AreaResizeU8Plan::new(3, 4, 2, 3).unwrap().resize_into(input, output).unwrap();
1382            }
1383            let expected = resize(input, 2, 3, interpolation).unwrap();
1384            for y in 0..3 {
1385                assert_eq!(&storage[y * 10..y * 10 + 6], expected.view().row(y).unwrap());
1386                if y < 2 {
1387                    assert_eq!(&storage[y * 10 + 6..(y + 1) * 10], &[199; 4]);
1388                }
1389            }
1390        }
1391    }
1392
1393    #[test]
1394    fn nearest_and_area_plans_reject_wrong_dimensions() {
1395        let input = Image::<u8, 1>::try_new(4, 4, (0..16).collect()).unwrap();
1396        let mut output = Image::<u8, 1>::try_new(2, 2, vec![0; 4]).unwrap();
1397        assert!(NearestResizeU8Plan::new(5, 4, 2, 2)
1398            .unwrap()
1399            .resize_into(input.view(), output.view_mut())
1400            .is_err());
1401        assert!(AreaResizeU8Plan::new(4, 4, 3, 2)
1402            .unwrap()
1403            .resize_into(input.view(), output.view_mut())
1404            .is_err());
1405    }
1406}