Skip to main content

spatialrust_vision/
preprocess.rs

1use pulp::Arch;
2use rayon::prelude::*;
3use spatialrust_image::{
4    ColorSpace, Image, ImageMetadata, ImageRegion, ImageView, ImageViewMut, PlanarImage,
5};
6
7use crate::dispatch::{
8    should_parallelize, LIGHT_PARALLEL_COMPONENTS, ROWS_PER_TILE, ROW_PARALLEL_COMPONENTS,
9    TALL_IMAGE_ROWS,
10};
11use crate::{
12    resize, BilinearResizeU8Plan, Interpolation, PixelComponent, VisionError, VisionResult,
13};
14
15/// Padding applied around an image.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
17pub struct Padding {
18    /// Columns before the source image.
19    pub left: usize,
20    /// Columns after the source image.
21    pub right: usize,
22    /// Rows before the source image.
23    pub top: usize,
24    /// Rows after the source image.
25    pub bottom: usize,
26}
27
28/// Mapping between a source image and its letterboxed output.
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct LetterboxTransform {
31    /// Uniform source-to-output scale.
32    pub scale: f64,
33    /// Left padding in output pixels.
34    pub pad_left: usize,
35    /// Top padding in output pixels.
36    pub pad_top: usize,
37    /// Resized content width.
38    pub content_width: usize,
39    /// Resized content height.
40    pub content_height: usize,
41    /// Final output width.
42    pub output_width: usize,
43    /// Final output height.
44    pub output_height: usize,
45}
46
47impl LetterboxTransform {
48    /// Maps a source pixel coordinate into letterboxed output coordinates.
49    #[must_use]
50    pub fn map_point(self, x: f64, y: f64) -> (f64, f64) {
51        (x.mul_add(self.scale, self.pad_left as f64), y.mul_add(self.scale, self.pad_top as f64))
52    }
53
54    /// Maps an output coordinate back into the source image.
55    #[must_use]
56    pub fn unmap_point(self, x: f64, y: f64) -> (f64, f64) {
57        ((x - self.pad_left as f64) / self.scale, (y - self.pad_top as f64) / self.scale)
58    }
59}
60
61/// Copies a checked rectangular region into a packed image.
62pub fn crop<T: PixelComponent, const CHANNELS: usize>(
63    input: ImageView<'_, T, CHANNELS>,
64    region: ImageRegion,
65) -> VisionResult<Image<T, CHANNELS>> {
66    let view = input.subview(region)?;
67    let mut data = Vec::with_capacity(region.width * region.height * CHANNELS);
68    for y in 0..region.height {
69        data.extend_from_slice(view.row(y).expect("validated crop row"));
70    }
71    Ok(Image::try_new_with_metadata(region.width, region.height, data, input.metadata())?)
72}
73
74/// Pads an image with a constant pixel value.
75pub fn pad<T: PixelComponent, const CHANNELS: usize>(
76    input: ImageView<'_, T, CHANNELS>,
77    padding: Padding,
78    value: [T; CHANNELS],
79) -> VisionResult<Image<T, CHANNELS>> {
80    let width = input
81        .width()
82        .checked_add(padding.left)
83        .and_then(|value| value.checked_add(padding.right))
84        .ok_or_else(|| VisionError::InvalidDimensions("padding width overflow".to_owned()))?;
85    let height = input
86        .height()
87        .checked_add(padding.top)
88        .and_then(|value| value.checked_add(padding.bottom))
89        .ok_or_else(|| VisionError::InvalidDimensions("padding height overflow".to_owned()))?;
90    let mut output = Vec::with_capacity(width * height * CHANNELS);
91    for y in 0..height {
92        for x in 0..width {
93            if x >= padding.left
94                && x < padding.left + input.width()
95                && y >= padding.top
96                && y < padding.top + input.height()
97            {
98                output.extend_from_slice(
99                    input.get(x - padding.left, y - padding.top).expect("validated pad coordinate"),
100                );
101            } else {
102                output.extend_from_slice(&value);
103            }
104        }
105    }
106    Ok(Image::try_new_with_metadata(width, height, output, input.metadata())?)
107}
108
109/// Aspect-preserving resize followed by centered constant padding.
110pub fn letterbox<T: PixelComponent, const CHANNELS: usize>(
111    input: ImageView<'_, T, CHANNELS>,
112    output_width: usize,
113    output_height: usize,
114    interpolation: Interpolation,
115    value: [T; CHANNELS],
116) -> VisionResult<(Image<T, CHANNELS>, LetterboxTransform)> {
117    if input.width() == 0 || input.height() == 0 || output_width == 0 || output_height == 0 {
118        return Err(VisionError::InvalidDimensions(
119            "letterbox requires non-empty input and output".to_owned(),
120        ));
121    }
122    let scale = (output_width as f64 / input.width() as f64)
123        .min(output_height as f64 / input.height() as f64);
124    let content_width = ((input.width() as f64 * scale).round() as usize).clamp(1, output_width);
125    let content_height = ((input.height() as f64 * scale).round() as usize).clamp(1, output_height);
126    let resized = resize(input, content_width, content_height, interpolation)?;
127    let remaining_x = output_width - content_width;
128    let remaining_y = output_height - content_height;
129    let padding = Padding {
130        left: remaining_x / 2,
131        right: remaining_x - remaining_x / 2,
132        top: remaining_y / 2,
133        bottom: remaining_y - remaining_y / 2,
134    };
135    let transform = LetterboxTransform {
136        scale,
137        pad_left: padding.left,
138        pad_top: padding.top,
139        content_width,
140        content_height,
141        output_width,
142        output_height,
143    };
144    Ok((pad(resized.view(), padding, value)?, transform))
145}
146
147/// Converts packed scalar values to `f32`, then applies `value * scale`, mean
148/// subtraction, and per-channel standard-deviation normalization.
149pub fn normalize<T: PixelComponent, const CHANNELS: usize>(
150    input: ImageView<'_, T, CHANNELS>,
151    scale: f32,
152    mean: [f32; CHANNELS],
153    std: [f32; CHANNELS],
154) -> VisionResult<Image<f32, CHANNELS>> {
155    validate_normalization(scale, std)?;
156    let mut output = Vec::with_capacity(input.width() * input.height() * CHANNELS);
157    for y in 0..input.height() {
158        for pixel in input.row(y).expect("input row in bounds").chunks_exact(CHANNELS) {
159            for channel in 0..CHANNELS {
160                output
161                    .push((pixel[channel].to_f64() as f32 * scale - mean[channel]) / std[channel]);
162            }
163        }
164    }
165    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
166}
167
168/// Normalizes interleaved pixels into caller-owned `f32` storage.
169pub fn normalize_into<T: PixelComponent, const CHANNELS: usize>(
170    input: ImageView<'_, T, CHANNELS>,
171    mut output: ImageViewMut<'_, f32, CHANNELS>,
172    scale: f32,
173    mean: [f32; CHANNELS],
174    std: [f32; CHANNELS],
175) -> VisionResult<()> {
176    validate_normalization(scale, std)?;
177    validate_output_dimensions(input, output.width(), output.height())?;
178    output.set_metadata(input.metadata())?;
179    for y in 0..input.height() {
180        let source = input.row(y).expect("input row in bounds");
181        let target = output.row_mut(y).expect("output row in bounds");
182        for (source_pixel, target_pixel) in
183            source.chunks_exact(CHANNELS).zip(target.chunks_exact_mut(CHANNELS))
184        {
185            for channel in 0..CHANNELS {
186                target_pixel[channel] =
187                    (source_pixel[channel].to_f64() as f32 * scale - mean[channel]) / std[channel];
188            }
189        }
190    }
191    Ok(())
192}
193
194/// Packs and normalizes an interleaved image into planar CHW storage.
195pub fn pack_chw<T: PixelComponent, const CHANNELS: usize>(
196    input: ImageView<'_, T, CHANNELS>,
197    scale: f32,
198    mean: [f32; CHANNELS],
199    std: [f32; CHANNELS],
200) -> VisionResult<PlanarImage<f32, CHANNELS>> {
201    let plane_len = input.width() * input.height();
202    let mut output = vec![0.0_f32; plane_len * CHANNELS];
203    pack_chw_into(input, scale, mean, std, &mut output)?;
204    Ok(PlanarImage::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
205}
206
207/// Packs and normalizes interleaved pixels into a reusable planar CHW slice.
208///
209/// Large multi-channel inputs dispatch across channels with safe scoped
210/// threads. Smaller inputs remain scalar to avoid scheduling overhead.
211pub fn pack_chw_into<T: PixelComponent, const CHANNELS: usize>(
212    input: ImageView<'_, T, CHANNELS>,
213    scale: f32,
214    mean: [f32; CHANNELS],
215    std: [f32; CHANNELS],
216    output: &mut [f32],
217) -> VisionResult<()> {
218    validate_normalization(scale, std)?;
219    let plane_len = input.width().checked_mul(input.height()).ok_or_else(|| {
220        VisionError::InvalidDimensions("CHW plane dimensions overflow".to_owned())
221    })?;
222    let required = plane_len.checked_mul(CHANNELS).ok_or_else(|| {
223        VisionError::InvalidDimensions("CHW output dimensions overflow".to_owned())
224    })?;
225    if output.len() != required {
226        return Err(VisionError::ShapeMismatch(format!(
227            "CHW output needs {required} elements, found {}",
228            output.len()
229        )));
230    }
231    if plane_len == 0 {
232        return Ok(());
233    }
234    if CHANNELS > 1 && should_parallelize(plane_len, CHANNELS, ROW_PARALLEL_COMPONENTS) {
235        std::thread::scope(|scope| {
236            for (channel, plane) in output.chunks_exact_mut(plane_len).enumerate() {
237                scope.spawn(move || {
238                    fill_chw_plane(input, channel, scale, mean[channel], std[channel], plane)
239                });
240            }
241        });
242    } else {
243        for (channel, plane) in output.chunks_exact_mut(plane_len).enumerate() {
244            fill_chw_plane(input, channel, scale, mean[channel], std[channel], plane);
245        }
246    }
247    Ok(())
248}
249
250/// Fuses bilinear RGB resize, normalization, and CHW packing without an intermediate image.
251pub fn resize_pack_chw(
252    input: ImageView<'_, u8, 3>,
253    output_width: usize,
254    output_height: usize,
255    scale: f32,
256    mean: [f32; 3],
257    std: [f32; 3],
258) -> VisionResult<PlanarImage<f32, 3>> {
259    BilinearResizeU8Plan::new(input.width(), input.height(), output_width, output_height)?
260        .resize_rgb_to_chw(input, scale, mean, std)
261}
262
263/// Fuses bilinear RGB resize, normalization, and CHW packing into caller-owned storage.
264#[allow(clippy::too_many_arguments)]
265pub fn resize_pack_chw_into(
266    input: ImageView<'_, u8, 3>,
267    output_width: usize,
268    output_height: usize,
269    scale: f32,
270    mean: [f32; 3],
271    std: [f32; 3],
272    output: &mut [f32],
273) -> VisionResult<()> {
274    BilinearResizeU8Plan::new(input.width(), input.height(), output_width, output_height)?
275        .resize_rgb_to_chw_into(input, scale, mean, std, output)
276}
277
278fn fill_chw_plane<T: PixelComponent, const CHANNELS: usize>(
279    input: ImageView<'_, T, CHANNELS>,
280    channel: usize,
281    scale: f32,
282    mean: f32,
283    std: f32,
284    output: &mut [f32],
285) {
286    for y in 0..input.height() {
287        let row = input.row(y).expect("input row in bounds");
288        for (x, pixel) in row.chunks_exact(CHANNELS).enumerate() {
289            output[y * input.width() + x] = (pixel[channel].to_f64() as f32 * scale - mean) / std;
290        }
291    }
292}
293
294fn validate_normalization<const CHANNELS: usize>(
295    scale: f32,
296    std: [f32; CHANNELS],
297) -> VisionResult<()> {
298    if !scale.is_finite() || std.iter().any(|value| !value.is_finite() || *value == 0.0) {
299        return Err(VisionError::InvalidParameter(
300            "normalization scale/std must be finite and std non-zero".to_owned(),
301        ));
302    }
303    Ok(())
304}
305
306fn validate_output_dimensions<T: PixelComponent, const CHANNELS: usize>(
307    input: ImageView<'_, T, CHANNELS>,
308    output_width: usize,
309    output_height: usize,
310) -> VisionResult<()> {
311    if (input.width(), input.height()) != (output_width, output_height) {
312        return Err(VisionError::ShapeMismatch(format!(
313            "output dimensions {output_width}x{output_height} do not match input {}x{}",
314            input.width(),
315            input.height()
316        )));
317    }
318    Ok(())
319}
320
321/// Swaps the red and blue channels of a three-channel image.
322pub fn swap_red_blue<T: PixelComponent>(input: ImageView<'_, T, 3>) -> VisionResult<Image<T, 3>> {
323    let mut data = Vec::with_capacity(input.width() * input.height() * 3);
324    for y in 0..input.height() {
325        for x in 0..input.width() {
326            let pixel = input.get(x, y).expect("image coordinate in bounds");
327            data.extend_from_slice(&[pixel[2], pixel[1], pixel[0]]);
328        }
329    }
330    let mut metadata = input.metadata();
331    metadata.color_space = match metadata.color_space {
332        ColorSpace::Rgb => ColorSpace::Bgr,
333        ColorSpace::Bgr => ColorSpace::Rgb,
334        other => other,
335    };
336    Ok(Image::try_new_with_metadata(input.width(), input.height(), data, metadata)?)
337}
338
339/// Converts RGB `u8` pixels to BT.601 luma using fixed-point coefficients.
340pub fn rgb_to_gray(input: ImageView<'_, u8, 3>) -> VisionResult<Image<u8, 1>> {
341    let metadata = ImageMetadata { color_space: ColorSpace::Gray, ..input.metadata() };
342    let len = input
343        .width()
344        .checked_mul(input.height())
345        .ok_or_else(|| VisionError::InvalidDimensions("gray output is too large".to_owned()))?;
346    let mut output =
347        Image::try_new_with_metadata(input.width(), input.height(), vec![0; len], metadata)?;
348    rgb_to_gray_into(input, output.view_mut())?;
349    Ok(output)
350}
351
352/// Converts RGB `u8` pixels into caller-owned gray storage without allocating.
353pub fn rgb_to_gray_into(
354    input: ImageView<'_, u8, 3>,
355    mut output: ImageViewMut<'_, u8, 1>,
356) -> VisionResult<()> {
357    validate_output_dimensions(input, output.width(), output.height())?;
358    output.set_metadata(ImageMetadata { color_space: ColorSpace::Gray, ..input.metadata() })?;
359    let output_stride = output.row_stride();
360    let pixels = input
361        .width()
362        .checked_mul(input.height())
363        .ok_or_else(|| VisionError::InvalidDimensions("gray output is too large".to_owned()))?;
364    let arch = Arch::new();
365    if should_parallelize(pixels, input.height(), LIGHT_PARALLEL_COMPONENTS) {
366        if input.height() >= TALL_IMAGE_ROWS {
367            let block_stride = output_stride.checked_mul(ROWS_PER_TILE).ok_or_else(|| {
368                VisionError::InvalidDimensions("gray row block is too large".to_owned())
369            })?;
370            output.as_mut_slice().par_chunks_mut(block_stride).enumerate().for_each(
371                |(block, rows)| {
372                    arch.dispatch(|| {
373                        for (row, target) in rows.chunks_mut(output_stride).enumerate() {
374                            rgb_to_gray_row(input, block * ROWS_PER_TILE + row, target);
375                        }
376                    });
377                },
378            );
379        } else {
380            output
381                .as_mut_slice()
382                .par_chunks_mut(output_stride)
383                .enumerate()
384                .for_each(|(y, target)| arch.dispatch(|| rgb_to_gray_row(input, y, target)));
385        }
386    } else {
387        output
388            .as_mut_slice()
389            .chunks_mut(output_stride)
390            .enumerate()
391            .for_each(|(y, target)| arch.dispatch(|| rgb_to_gray_row(input, y, target)));
392    }
393    Ok(())
394}
395
396/// Fuses bilinear RGB resize and BT.601 grayscale conversion without an intermediate RGB image.
397pub fn resize_rgb_to_gray(
398    input: ImageView<'_, u8, 3>,
399    output_width: usize,
400    output_height: usize,
401) -> VisionResult<Image<u8, 1>> {
402    BilinearResizeU8Plan::new(input.width(), input.height(), output_width, output_height)?
403        .resize_rgb_to_gray(input)
404}
405
406/// Fuses bilinear RGB resize and BT.601 grayscale conversion into caller-owned storage.
407pub fn resize_rgb_to_gray_into(
408    input: ImageView<'_, u8, 3>,
409    output: ImageViewMut<'_, u8, 1>,
410) -> VisionResult<()> {
411    BilinearResizeU8Plan::new(input.width(), input.height(), output.width(), output.height())?
412        .resize_rgb_to_gray_into(input, output)
413}
414
415fn rgb_to_gray_row(input: ImageView<'_, u8, 3>, y: usize, target: &mut [u8]) {
416    let source = input.row(y).expect("input row in bounds");
417    for (pixel, target_value) in source.chunks_exact(3).zip(target.iter_mut()) {
418        *target_value = rgb_luma(pixel);
419    }
420}
421
422#[inline(always)]
423fn rgb_luma(pixel: &[u8]) -> u8 {
424    ((4_899_u32 * u32::from(pixel[0])
425        + 9_617_u32 * u32::from(pixel[1])
426        + 1_868_u32 * u32::from(pixel[2])
427        + 8_192)
428        >> 14) as u8
429}
430
431/// Replicates a gray channel into RGB.
432pub fn gray_to_rgb<T: PixelComponent>(input: ImageView<'_, T, 1>) -> VisionResult<Image<T, 3>> {
433    let mut data = Vec::with_capacity(input.width() * input.height() * 3);
434    for y in 0..input.height() {
435        for x in 0..input.width() {
436            let value = input.get(x, y).expect("image coordinate in bounds")[0];
437            data.extend_from_slice(&[value, value, value]);
438        }
439    }
440    let metadata = ImageMetadata { color_space: ColorSpace::Rgb, ..input.metadata() };
441    Ok(Image::try_new_with_metadata(input.width(), input.height(), data, metadata)?)
442}
443
444/// Converts RGB `u8` to OpenCV-style HSV (`H` in `0..=179`, `S/V` in `0..=255`).
445pub fn rgb_to_hsv(input: ImageView<'_, u8, 3>) -> VisionResult<Image<u8, 3>> {
446    let mut data = Vec::with_capacity(input.width() * input.height() * 3);
447    for y in 0..input.height() {
448        for x in 0..input.width() {
449            let [r, g, b] = *input.get(x, y).expect("image coordinate in bounds");
450            let rf = f64::from(r) / 255.0;
451            let gf = f64::from(g) / 255.0;
452            let bf = f64::from(b) / 255.0;
453            let max = rf.max(gf).max(bf);
454            let min = rf.min(gf).min(bf);
455            let delta = max - min;
456            let mut hue = if delta == 0.0 {
457                0.0
458            } else if max == rf {
459                60.0 * ((gf - bf) / delta).rem_euclid(6.0)
460            } else if max == gf {
461                60.0 * ((bf - rf) / delta + 2.0)
462            } else {
463                60.0 * ((rf - gf) / delta + 4.0)
464            };
465            if hue >= 360.0 {
466                hue = 0.0;
467            }
468            let saturation = if max == 0.0 { 0.0 } else { delta / max };
469            data.extend_from_slice(&[
470                (hue / 2.0).round().clamp(0.0, 179.0) as u8,
471                (saturation * 255.0).round() as u8,
472                (max * 255.0).round() as u8,
473            ]);
474        }
475    }
476    let metadata = ImageMetadata { color_space: ColorSpace::Hsv, ..input.metadata() };
477    Ok(Image::try_new_with_metadata(input.width(), input.height(), data, metadata)?)
478}
479
480#[cfg(test)]
481mod tests {
482    use super::{
483        crop, gray_to_rgb, letterbox, normalize, normalize_into, pack_chw, pack_chw_into, pad,
484        resize_pack_chw, resize_pack_chw_into, resize_rgb_to_gray, resize_rgb_to_gray_into,
485        rgb_to_gray, rgb_to_gray_into, rgb_to_hsv, Padding,
486    };
487    use crate::Interpolation;
488    use spatialrust_image::{
489        ColorSpace, Image, ImageMetadata, ImageRegion, ImageView, ImageViewMut,
490    };
491
492    #[test]
493    fn crop_and_pad_roundtrip_center() {
494        let image = Image::<u8, 1>::try_new(3, 2, vec![1, 2, 3, 4, 5, 6]).unwrap();
495        let cropped = crop(image.view(), ImageRegion::new(1, 0, 2, 2)).unwrap();
496        assert_eq!(cropped.as_slice(), &[2, 3, 5, 6]);
497        let padded =
498            pad(cropped.view(), Padding { left: 1, right: 0, top: 1, bottom: 0 }, [0]).unwrap();
499        assert_eq!(padded.as_slice(), &[0, 0, 0, 0, 2, 3, 0, 5, 6]);
500    }
501
502    #[test]
503    fn letterbox_preserves_aspect_and_maps_points() {
504        let image = Image::<u8, 1>::try_new(4, 2, vec![1; 8]).unwrap();
505        let (output, transform) =
506            letterbox(image.view(), 8, 8, Interpolation::Nearest, [0]).unwrap();
507        assert_eq!((output.width(), output.height()), (8, 8));
508        assert_eq!((transform.content_width, transform.content_height), (8, 4));
509        assert_eq!(transform.pad_top, 2);
510        let mapped = transform.map_point(1.0, 1.0);
511        assert_eq!(transform.unmap_point(mapped.0, mapped.1), (1.0, 1.0));
512    }
513
514    #[test]
515    fn normalize_and_chw_have_expected_layout() {
516        let image = Image::<u8, 3>::try_new(2, 1, vec![0, 10, 20, 30, 40, 50]).unwrap();
517        let normalized = normalize(image.view(), 0.1, [0.0; 3], [1.0; 3]).unwrap();
518        assert_eq!(normalized.as_slice(), &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
519        let chw = pack_chw(image.view(), 0.1, [0.0; 3], [1.0; 3]).unwrap();
520        assert_eq!(chw.as_slice(), &[0.0, 3.0, 1.0, 4.0, 2.0, 5.0]);
521    }
522
523    #[test]
524    fn reusable_normalize_and_chw_match_owned_outputs() {
525        let image = Image::<u8, 3>::try_new(2, 1, vec![0, 10, 20, 30, 40, 50]).unwrap();
526        let mut interleaved = vec![-1.0_f32; 9];
527        let output = ImageViewMut::<f32, 3>::new(2, 1, 9, &mut interleaved).unwrap();
528        normalize_into(image.view(), output, 0.1, [0.0; 3], [1.0; 3]).unwrap();
529        assert_eq!(
530            &interleaved[..6],
531            normalize(image.view(), 0.1, [0.0; 3], [1.0; 3]).unwrap().as_slice()
532        );
533        assert_eq!(&interleaved[6..], &[-1.0; 3]);
534
535        let mut chw = vec![0.0; 6];
536        pack_chw_into(image.view(), 0.1, [0.0; 3], [1.0; 3], &mut chw).unwrap();
537        assert_eq!(chw, pack_chw(image.view(), 0.1, [0.0; 3], [1.0; 3]).unwrap().as_slice());
538        assert!(pack_chw_into(image.view(), 1.0, [0.0; 3], [1.0; 3], &mut chw[..5]).is_err());
539
540        let empty = Image::<u8, 3>::try_new(0, 0, Vec::new()).unwrap();
541        pack_chw_into(empty.view(), 1.0, [0.0; 3], [1.0; 3], &mut []).unwrap();
542    }
543
544    #[test]
545    fn large_chw_parallel_dispatch_matches_channel_layout() {
546        let image = Image::<u8, 3>::try_new(512, 512, vec![10; 512 * 512 * 3]).unwrap();
547        let mut chw = vec![0.0; 512 * 512 * 3];
548        pack_chw_into(image.view(), 0.1, [0.0, 0.5, 1.0], [1.0; 3], &mut chw).unwrap();
549        let plane = 512 * 512;
550        assert!(chw[..plane].iter().all(|&value| value == 1.0));
551        assert!(chw[plane..2 * plane].iter().all(|&value| value == 0.5));
552        assert!(chw[2 * plane..].iter().all(|&value| value == 0.0));
553    }
554
555    #[test]
556    fn fused_resize_chw_matches_unfused_general_and_preserves_metadata() {
557        let metadata = ImageMetadata {
558            color_space: ColorSpace::Rgb,
559            color_range: spatialrust_image::ColorRange::Full,
560            ..ImageMetadata::default()
561        };
562        let input = Image::<u8, 3>::try_new_with_metadata(
563            7,
564            5,
565            (0..105).map(|value| (value * 37 % 256) as u8).collect(),
566            metadata,
567        )
568        .unwrap();
569        let plan = crate::BilinearResizeU8Plan::new(7, 5, 11, 8).unwrap();
570        let resized = plan.resize(input.view()).unwrap();
571        let expected =
572            pack_chw(resized.view(), 1.0 / 255.0, [0.1, 0.2, 0.3], [0.5, 1.0, 2.0]).unwrap();
573        let actual =
574            resize_pack_chw(input.view(), 11, 8, 1.0 / 255.0, [0.1, 0.2, 0.3], [0.5, 1.0, 2.0])
575                .unwrap();
576        assert_eq!(actual, expected);
577        assert_eq!(actual.metadata(), metadata);
578    }
579
580    #[test]
581    fn fused_resize_chw_matches_unfused_half_scale_for_strided_input_and_reuse() {
582        let mut storage = vec![231_u8; 169];
583        for y in 0..6 {
584            for x in 0..24 {
585                storage[y * 29 + x] = (y * 41 + x * 13) as u8;
586            }
587        }
588        let input = ImageView::<u8, 3>::new(8, 6, 29, &storage).unwrap();
589        let plan = crate::BilinearResizeU8Plan::new(8, 6, 4, 3).unwrap();
590        let resized = plan.resize(input).unwrap();
591        let expected = pack_chw(resized.view(), 0.25, [1.0, 2.0, 3.0], [1.0; 3]).unwrap();
592        let mut output = vec![-1.0_f32; 36];
593        resize_pack_chw_into(input, 4, 3, 0.25, [1.0, 2.0, 3.0], [1.0; 3], &mut output).unwrap();
594        assert_eq!(output, expected.as_slice());
595        assert!(
596            resize_pack_chw_into(input, 4, 3, 0.25, [0.0; 3], [1.0; 3], &mut output[..35]).is_err()
597        );
598    }
599
600    #[test]
601    fn rgb_to_gray_into_accepts_strided_output() {
602        let image = Image::<u8, 3>::try_new(2, 1, vec![255, 0, 0, 0, 255, 0]).unwrap();
603        let mut storage = vec![200_u8; 4];
604        let output = ImageViewMut::<u8, 1>::new(2, 1, 4, &mut storage).unwrap();
605        rgb_to_gray_into(image.view(), output).unwrap();
606        assert_eq!(&storage[..2], rgb_to_gray(image.view()).unwrap().as_slice());
607        assert_eq!(&storage[2..], &[200, 200]);
608    }
609
610    #[test]
611    fn rgb_to_gray_accepts_strided_input_and_preserves_metadata() {
612        let storage = [255, 0, 0, 0, 255, 0, 91, 92, 0, 0, 255, 255, 255, 255];
613        let metadata = ImageMetadata { color_space: ColorSpace::Rgb, ..Default::default() };
614        let input = ImageView::<u8, 3>::new_with_metadata(2, 2, 8, &storage, metadata).unwrap();
615        let gray = rgb_to_gray(input).unwrap();
616        assert_eq!(gray.as_slice(), &[76, 150, 29, 255]);
617        assert_eq!(gray.metadata().color_space, ColorSpace::Gray);
618    }
619
620    #[test]
621    fn fused_resize_rgb_to_gray_matches_unfused_half_scale_exactly() {
622        let metadata = ImageMetadata {
623            color_space: ColorSpace::Rgb,
624            color_range: spatialrust_image::ColorRange::Full,
625            ..ImageMetadata::default()
626        };
627        let input = Image::<u8, 3>::try_new_with_metadata(
628            8,
629            6,
630            (0..144).map(|value| (value * 53 % 256) as u8).collect(),
631            metadata,
632        )
633        .unwrap();
634        let resized =
635            crate::BilinearResizeU8Plan::new(8, 6, 4, 3).unwrap().resize(input.view()).unwrap();
636        let expected = rgb_to_gray(resized.view()).unwrap();
637        let actual = resize_rgb_to_gray(input.view(), 4, 3).unwrap();
638        assert_eq!(actual, expected);
639        assert_eq!(actual.metadata().color_space, ColorSpace::Gray);
640        assert_eq!(actual.metadata().color_range, spatialrust_image::ColorRange::Full);
641    }
642
643    #[test]
644    fn fused_resize_rgb_to_gray_matches_unfused_general_and_strided_output() {
645        let mut input_storage = vec![231_u8; 83];
646        for y in 0..5 {
647            for x in 0..15 {
648                input_storage[y * 17 + x] = (y * 41 + x * 13) as u8;
649            }
650        }
651        let input = ImageView::<u8, 3>::new(5, 5, 17, &input_storage).unwrap();
652        let resized = crate::BilinearResizeU8Plan::new(5, 5, 7, 3).unwrap().resize(input).unwrap();
653        let expected = rgb_to_gray(resized.view()).unwrap();
654        let mut output_storage = vec![199_u8; 29];
655        let output = ImageViewMut::<u8, 1>::new(7, 3, 11, &mut output_storage).unwrap();
656        resize_rgb_to_gray_into(input, output).unwrap();
657        for y in 0..3 {
658            assert_eq!(&output_storage[y * 11..y * 11 + 7], expected.view().row(y).unwrap());
659            if y < 2 {
660                assert_eq!(&output_storage[y * 11 + 7..(y + 1) * 11], &[199; 4]);
661            }
662        }
663    }
664
665    #[test]
666    fn color_conversions_match_known_primaries() {
667        let metadata = ImageMetadata { color_space: ColorSpace::Rgb, ..Default::default() };
668        let image = Image::<u8, 3>::try_new_with_metadata(
669            3,
670            1,
671            vec![255, 0, 0, 0, 255, 0, 0, 0, 255],
672            metadata,
673        )
674        .unwrap();
675        assert_eq!(rgb_to_gray(image.view()).unwrap().as_slice(), &[76, 150, 29]);
676        assert_eq!(
677            rgb_to_hsv(image.view()).unwrap().as_slice(),
678            &[0, 255, 255, 60, 255, 255, 120, 255, 255]
679        );
680        let gray = Image::<u8, 1>::try_new(1, 1, vec![12]).unwrap();
681        assert_eq!(gray_to_rgb(gray.view()).unwrap().as_slice(), &[12, 12, 12]);
682    }
683}