Skip to main content

otf_pixels_ops/
resize.rs

1//! [`Resize`] — separable resampling to a target size.
2//!
3//! # Two passes, one intermediate
4//!
5//! A separable filter applied in two one-dimensional passes costs
6//! `O(w · h · (support_x + support_y))` instead of the product, which for
7//! Lanczos3 is the difference between 6 taps per pixel and 36. The cost is one
8//! intermediate buffer, `output_width × input_height`, held only for the
9//! duration of a tile.
10//!
11//! Horizontal runs first, deliberately: when downscaling it narrows the rows
12//! before the vertical pass ever touches them, so the intermediate is the
13//! smaller of the two possible orderings.
14//!
15//! # Demand
16//!
17//! Resize is the first op whose `input_regions` is not a translation. An
18//! output tile needs the union of the filter footprints of its rows and
19//! columns, which [`Weights::footprint`] computes from the same tables the
20//! kernel uses — so what is requested and what is read cannot drift apart.
21
22use std::sync::OnceLock;
23
24use otf_pixels_core::{
25    AccessPattern, ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result,
26    SampleKind, Tile, TileBuf, TileMut,
27};
28
29use crate::filter::{Filter, Weights};
30use crate::resample::{column_f32, column_u8, column_u16, row_f32, row_u8, row_u16};
31
32/// How a resize reconciles the requested box with the source aspect ratio,
33/// with sharp's meanings.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35#[non_exhaustive]
36pub enum Fit {
37    /// Stretch to exactly the box, ignoring aspect ratio.
38    #[default]
39    Fill,
40    /// Preserve aspect, scaled so the image fits inside the box; the output
41    /// is the scaled image, at most the box.
42    Inside,
43    /// Preserve aspect, scaled so the image covers the box; the output is
44    /// the scaled image, at least the box.
45    Outside,
46    /// Preserve aspect and fill the box exactly: scale to cover it, then
47    /// crop the overflow, centred.
48    Cover,
49    /// Preserve aspect and fill the box exactly: scale to fit inside it,
50    /// then pad the rest with [`ResizeOptions::background`], centred.
51    Contain,
52}
53
54/// Options for [`Resize`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56#[non_exhaustive]
57pub struct ResizeOptions {
58    /// The resampling filter. Defaults to [`Filter::Lanczos3`].
59    pub filter: Filter,
60    /// How the target box is interpreted.
61    pub fit: Fit,
62    /// Never scale *up*: an image already smaller than the box is left alone.
63    pub without_enlargement: bool,
64    /// The RGBA colour [`Fit::Contain`] pads with: opaque black by default,
65    /// as in sharp. Grey formats take its luma; formats without alpha
66    /// ignore its alpha.
67    pub background: [u8; 4],
68}
69
70impl Default for ResizeOptions {
71    fn default() -> Self {
72        Self {
73            filter: Filter::default(),
74            fit: Fit::default(),
75            without_enlargement: false,
76            background: [0, 0, 0, 255],
77        }
78    }
79}
80
81impl ResizeOptions {
82    /// Options with an explicit filter.
83    #[must_use]
84    pub const fn with_filter(mut self, filter: Filter) -> Self {
85        self.filter = filter;
86        self
87    }
88
89    /// Options with an explicit fit mode.
90    #[must_use]
91    pub const fn with_fit(mut self, fit: Fit) -> Self {
92        self.fit = fit;
93        self
94    }
95
96    /// Options with an explicit padding colour for [`Fit::Contain`].
97    #[must_use]
98    pub const fn with_background(mut self, rgba: [u8; 4]) -> Self {
99        self.background = rgba;
100        self
101    }
102
103    /// Options that refuse to enlarge.
104    #[must_use]
105    pub const fn without_enlargement(mut self, refuse: bool) -> Self {
106        self.without_enlargement = refuse;
107        self
108    }
109}
110
111/// The weight tables this op was bound to when it was chained onto an image.
112///
113/// Tables are built **per image, not per tile**. Building them per tile would
114/// make an output pixel depend on which tile it landed in, because a tile's
115/// local scale is not the image's scale — and SPEC §Guarantees 2 requires the
116/// output to be independent of tiling. So they are resolved once, at
117/// graph-build time, and indexed by the tile's position within the image.
118#[derive(Debug)]
119struct Binding {
120    input: ImageDescriptor,
121    /// Tables for the visible part of the scaled image only.
122    horizontal: Weights,
123    vertical: Weights,
124    layout: Layout,
125}
126
127/// Where the scaled image lands in the output.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129struct Layout {
130    /// The whole output.
131    output: (u32, u32),
132    /// The image's part of it, and where that part sits: everything outside
133    /// is padding ([`Fit::Contain`]).
134    placed: Region,
135}
136
137/// Resample an image to a new size.
138#[derive(Debug)]
139pub struct Resize {
140    width: u32,
141    height: u32,
142    options: ResizeOptions,
143    /// Filled by `output_descriptor` at graph-build time.
144    bound: OnceLock<Binding>,
145}
146
147impl Clone for Resize {
148    fn clone(&self) -> Self {
149        // The binding is deliberately not cloned: a clone may be chained onto
150        // a differently shaped image, and inheriting the original's tables
151        // would silently resample against the wrong scale.
152        Self {
153            width: self.width,
154            height: self.height,
155            options: self.options,
156            bound: OnceLock::new(),
157        }
158    }
159}
160
161impl Resize {
162    /// Resize to `width` by `height` with `options`.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`PixelsError::InvalidArgument`] if either dimension is zero.
167    pub fn new(width: u32, height: u32, options: ResizeOptions) -> Result<Self> {
168        if width == 0 || height == 0 {
169            return Err(PixelsError::invalid_argument(
170                "size",
171                format!("resize target {width}x{height} has no pixels"),
172            ));
173        }
174        Ok(Self {
175            width,
176            height,
177            options,
178            bound: OnceLock::new(),
179        })
180    }
181
182    /// Resize to `width` by `height` with the default filter and fit.
183    ///
184    /// # Errors
185    ///
186    /// As [`Resize::new`].
187    pub fn to(width: u32, height: u32) -> Result<Self> {
188        Self::new(width, height, ResizeOptions::default())
189    }
190
191    /// The options this op was built with.
192    #[must_use]
193    pub const fn options(&self) -> ResizeOptions {
194        self.options
195    }
196
197    /// The output size for a given input, after fit and enlargement rules.
198    ///
199    /// Computed rather than stored, because the requested box is a *constraint*
200    /// and the answer depends on the input the op is eventually chained onto.
201    #[must_use]
202    pub fn target(&self, input: &ImageDescriptor) -> (u32, u32) {
203        self.layout(input).1.output
204    }
205
206    /// The scaled size, the crop window within it, and the output layout.
207    fn layout(&self, input: &ImageDescriptor) -> ((u32, u32), Layout, (u32, u32)) {
208        // Scale by whichever axis binds, in f64 so a 30000-pixel input does
209        // not lose precision on the ratio.
210        let by_width = f64::from(self.width) / f64::from(input.width);
211        let by_height = f64::from(self.height) / f64::from(input.height);
212        let preserving = |scale: f64| {
213            (
214                ((f64::from(input.width) * scale).round() as u32).max(1),
215                ((f64::from(input.height) * scale).round() as u32).max(1),
216            )
217        };
218        let (mut width, mut height) = match self.options.fit {
219            Fit::Fill => (self.width, self.height),
220            Fit::Inside | Fit::Contain => preserving(by_width.min(by_height)),
221            Fit::Outside | Fit::Cover => preserving(by_width.max(by_height)),
222        };
223        if self.options.without_enlargement {
224            width = width.min(input.width);
225            height = height.min(input.height);
226        }
227        let scaled = (width.max(1), height.max(1));
228        let (sw, sh) = scaled;
229        match self.options.fit {
230            Fit::Cover => {
231                let (ow, oh) = (sw.min(self.width), sh.min(self.height));
232                let window = ((sw - ow) / 2, (sh - oh) / 2);
233                (
234                    scaled,
235                    Layout {
236                        output: (ow, oh),
237                        placed: Region::new(0, 0, ow, oh),
238                    },
239                    window,
240                )
241            }
242            Fit::Contain => {
243                let (ow, oh) = (self.width.max(sw), self.height.max(sh));
244                let placed = Region::new((ow - sw) / 2, (oh - sh) / 2, sw, sh);
245                (
246                    scaled,
247                    Layout {
248                        output: (ow, oh),
249                        placed,
250                    },
251                    (0, 0),
252                )
253            }
254            _ => (
255                scaled,
256                Layout {
257                    output: scaled,
258                    placed: Region::new(0, 0, sw, sh),
259                },
260                (0, 0),
261            ),
262        }
263    }
264
265    /// The weight tables for resampling `input`, resolved once and reused.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`PixelsError::graph`] if this op was already bound to a
270    /// differently shaped input. An op instance belongs to one graph edge;
271    /// reusing it across two shapes would resample the second against the
272    /// first one's scale, which is a silent wrong answer rather than a loud
273    /// one.
274    fn binding(&self, input: &ImageDescriptor) -> Result<&Binding> {
275        if let Some(bound) = self.bound.get() {
276            return check_binding(bound, input);
277        }
278        let ((width, height), layout, (wx, wy)) = self.layout(input);
279        let (visible_w, visible_h) = (layout.placed.width, layout.placed.height);
280        let candidate = Binding {
281            input: *input,
282            horizontal: Weights::build(self.options.filter, input.width, width)?
283                .window(wx, visible_w),
284            vertical: Weights::build(self.options.filter, input.height, height)?
285                .window(wy, visible_h),
286            layout,
287        };
288        let bound = self.bound.get_or_init(|| candidate);
289        check_binding(bound, input)
290    }
291
292    /// The tables resolved at graph-build time, for use during `compute`.
293    fn bound(&self) -> Result<&Binding> {
294        self.bound.get().ok_or_else(|| {
295            PixelsError::graph("`resize` computed before its output shape was resolved")
296        })
297    }
298}
299
300/// Confirm a cached binding matches the input it is about to be used for.
301fn check_binding<'a>(bound: &'a Binding, input: &ImageDescriptor) -> Result<&'a Binding> {
302    if bound.input.width != input.width || bound.input.height != input.height {
303        return Err(PixelsError::graph(format!(
304            "`resize` is bound to a {}x{} input but was given {}x{}; \
305             build a new Resize per image",
306            bound.input.width, bound.input.height, input.width, input.height
307        )));
308    }
309    Ok(bound)
310}
311
312impl Op for Resize {
313    /// Resize pins its own output size, so a smaller input reaches the same
314    /// answer with less work. This is the op shrink-on-load exists for.
315    ///
316    /// The clone drops the filter tables, which are bound to the input shape
317    /// they were first built for — reusing them against a reduced source is
318    /// exactly the resample-at-the-wrong-scale this guards against.
319    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
320        Some(std::sync::Arc::new(self.clone()))
321    }
322    fn name(&self) -> &'static str {
323        "resize"
324    }
325
326    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
327        let input = inputs
328            .first()
329            .ok_or_else(|| PixelsError::graph("`resize` takes one input, got none"))?;
330        // Resolving the binding here is what makes the tables per-image:
331        // `output_descriptor` runs exactly once per graph edge, at build time.
332        self.binding(input)?;
333        let (width, height) = self.target(input);
334        input.resized(width, height)
335    }
336
337    fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
338        let input = inputs
339            .first()
340            .ok_or_else(|| PixelsError::graph("`resize` takes one input, got none"))?;
341        let bound = self.binding(input)?;
342        // Only the part of the tile the image covers reads anything; a tile
343        // of pure padding still names a valid (1x1) input region, as
344        // `composite` does for an overlay it misses.
345        let Some(image) = bound.image_part(output) else {
346            return Ok(vec![Region::new(0, 0, 1, 1)]);
347        };
348        // The footprint of exactly the output rows and columns requested,
349        // taken from the same tables the kernel will use — so what is asked
350        // for and what is read cannot drift apart.
351        let (x, width) = bound.horizontal.footprint(image.x, image.width);
352        let (y, height) = bound.vertical.footprint(image.y, image.height);
353        Ok(vec![Region::new(x, y, width, height)])
354    }
355
356    fn access_pattern(&self) -> AccessPattern {
357        // Resize reads a two-dimensional neighbourhood: every output row draws
358        // on a band of input rows, so square tiles keep that band small.
359        AccessPattern::Spatial
360    }
361
362    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
363        let input = inputs
364            .first()
365            .ok_or_else(|| PixelsError::graph("`resize` takes one input tile, got none"))?;
366
367        let format = output.pixel();
368        if input.pixel() != format {
369            return Err(PixelsError::graph(format!(
370                "`resize` input is {} but output is {format}",
371                input.pixel()
372            )));
373        }
374        let channels = format.channels();
375        let bound = self.bound()?;
376        let region = output.region();
377        if bound.layout.placed.contains(region)
378            && bound.layout.placed.x == 0
379            && bound.layout.placed.y == 0
380        {
381            return resample_tile(bound, input, output, channels);
382        }
383        // Padding: background everywhere, then the image's part resampled in
384        // the scaled image's own coordinates and copied into place.
385        let fill = background_pixel(format, self.options.background);
386        for row in output.rows_mut() {
387            for (slot, &byte) in row.iter_mut().zip(fill.iter().cycle()) {
388                *slot = byte;
389            }
390        }
391        let Some(image) = bound.image_part(region) else {
392            return Ok(());
393        };
394        let mut part = TileBuf::zeroed(image, format)?;
395        resample_tile(bound, input, &mut part.as_tile_mut()?, channels)?;
396        let (px, py) = (bound.layout.placed.x, bound.layout.placed.y);
397        let bytes = format.bytes_per_pixel();
398        let row_len = image.width as usize * bytes;
399        for (i, from) in part.bytes().chunks_exact(row_len.max(1)).enumerate() {
400            let Some(row) = output.row_mut(image.y + py + i as u32) else {
401                continue;
402            };
403            let at = (image.x + px - region.x) as usize * bytes;
404            if let Some(to) = row.get_mut(at..at + row_len) {
405                to.copy_from_slice(from);
406            }
407        }
408        Ok(())
409    }
410}
411
412impl Binding {
413    /// The part of output `region` the image covers, in the scaled image's
414    /// coordinates; `None` for a tile of pure padding.
415    fn image_part(&self, region: Region) -> Option<Region> {
416        let placed = self.layout.placed;
417        let overlap = region.intersect(placed);
418        (overlap.width > 0 && overlap.height > 0).then(|| {
419            Region::new(
420                overlap.x - placed.x,
421                overlap.y - placed.y,
422                overlap.width,
423                overlap.height,
424            )
425        })
426    }
427}
428
429/// One pixel of `rgba` in `format`'s bytes.
430fn background_pixel(format: PixelFormat, rgba: [u8; 4]) -> Vec<u8> {
431    let [r, g, b, a] = rgba;
432    let luma = ((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114 + 500) / 1000) as u8;
433    let samples: Vec<u8> = match format.layout() {
434        ChannelLayout::Gray => vec![luma],
435        ChannelLayout::GrayAlpha => vec![luma, a],
436        ChannelLayout::Rgb => vec![r, g, b],
437        ChannelLayout::Rgba => vec![r, g, b, a],
438    };
439    match format.sample_kind() {
440        SampleKind::U8 => samples,
441        SampleKind::U16 => samples
442            .iter()
443            .flat_map(|&v| (u16::from(v) * 257).to_ne_bytes())
444            .collect(),
445        SampleKind::F32 => samples
446            .iter()
447            .flat_map(|&v| (f32::from(v) / 255.0).to_ne_bytes())
448            .collect(),
449    }
450}
451
452/// Run both passes for one output tile.
453///
454/// The tables are the image's, not the tile's. Each output row and column of
455/// the tile looks up its own whole-image run, and reads it out of the input
456/// tile at an offset — which is why the answer is the same however the image
457/// is cut up.
458fn resample_tile(
459    bound: &Binding,
460    input: &Tile<'_>,
461    output: &mut TileMut<'_>,
462    channels: usize,
463) -> Result<()> {
464    let region = output.region();
465    let source = input.region();
466    let horizontal = bound
467        .horizontal
468        .for_tile(region.x, region.width, source.x)?;
469    let vertical = bound.vertical.for_tile(region.y, region.height, source.y)?;
470
471    let format = output.pixel();
472    let intermediate_row = region.width as usize * channels;
473
474    match format.sample_kind() {
475        SampleKind::U8 => {
476            // Horizontal pass into an intermediate of output width by input
477            // height, then vertical down each column of that.
478            let mut intermediate = vec![0_u8; intermediate_row * source.height as usize];
479            for y in 0..source.height {
480                let Some(row) = input.row(source.y + y) else {
481                    continue;
482                };
483                let at = y as usize * intermediate_row;
484                let Some(target) = intermediate.get_mut(at..at + intermediate_row) else {
485                    continue;
486                };
487                row_u8(row, target, channels, &horizontal);
488            }
489
490            // The vertical pass accumulates a whole output row at a time,
491            // reading input rows sequentially. See the note in `resample`.
492            let mut accumulator = vec![0_i32; intermediate_row];
493            let mut resampled = vec![0_u8; intermediate_row];
494            for (index, run) in vertical.runs().iter().enumerate() {
495                column_u8(
496                    &intermediate,
497                    intermediate_row,
498                    run.start as usize,
499                    vertical.quantized(run),
500                    &mut accumulator,
501                    &mut resampled,
502                );
503                write_row(output, region.y + index as u32, &resampled);
504            }
505        }
506        SampleKind::U16 => {
507            let mut intermediate = vec![0_u16; intermediate_row * source.height as usize];
508            for y in 0..source.height {
509                let Some(row) = input.row(source.y + y) else {
510                    continue;
511                };
512                let wide = to_u16(row);
513                let at = y as usize * intermediate_row;
514                let Some(target) = intermediate.get_mut(at..at + intermediate_row) else {
515                    continue;
516                };
517                row_u16(&wide, target, channels, &horizontal);
518            }
519            let mut accumulator = vec![0.0_f32; intermediate_row];
520            let mut resampled = vec![0_u16; intermediate_row];
521            for (index, run) in vertical.runs().iter().enumerate() {
522                column_u16(
523                    &intermediate,
524                    intermediate_row,
525                    run.start as usize,
526                    vertical.exact(run),
527                    &mut accumulator,
528                    &mut resampled,
529                );
530                let bytes: Vec<u8> = resampled.iter().flat_map(|v| v.to_ne_bytes()).collect();
531                write_row(output, region.y + index as u32, &bytes);
532            }
533        }
534        SampleKind::F32 => {
535            let mut intermediate = vec![0.0_f32; intermediate_row * source.height as usize];
536            for y in 0..source.height {
537                let Some(row) = input.row(source.y + y) else {
538                    continue;
539                };
540                let floats = to_f32(row);
541                let at = y as usize * intermediate_row;
542                let Some(target) = intermediate.get_mut(at..at + intermediate_row) else {
543                    continue;
544                };
545                row_f32(&floats, target, channels, &horizontal);
546            }
547            let mut accumulator = vec![0.0_f32; intermediate_row];
548            let mut resampled = vec![0.0_f32; intermediate_row];
549            for (index, run) in vertical.runs().iter().enumerate() {
550                column_f32(
551                    &intermediate,
552                    intermediate_row,
553                    run.start as usize,
554                    vertical.exact(run),
555                    &mut accumulator,
556                    &mut resampled,
557                );
558                let bytes: Vec<u8> = resampled.iter().flat_map(|v| v.to_ne_bytes()).collect();
559                write_row(output, region.y + index as u32, &bytes);
560            }
561        }
562    }
563    Ok(())
564}
565
566/// Reinterpret a native-endian byte row as 16-bit samples.
567fn to_u16(row: &[u8]) -> Vec<u16> {
568    row.chunks_exact(2)
569        .map(|pair| {
570            u16::from_ne_bytes([
571                pair.first().copied().unwrap_or(0),
572                pair.get(1).copied().unwrap_or(0),
573            ])
574        })
575        .collect()
576}
577
578/// Reinterpret a native-endian byte row as float samples.
579fn to_f32(row: &[u8]) -> Vec<f32> {
580    row.chunks_exact(4)
581        .map(|quad| {
582            let mut bytes = [0_u8; 4];
583            for (slot, &byte) in bytes.iter_mut().zip(quad) {
584                *slot = byte;
585            }
586            f32::from_ne_bytes(bytes)
587        })
588        .collect()
589}
590
591/// Write a resampled row into the output tile.
592fn write_row(output: &mut TileMut<'_>, y: u32, bytes: &[u8]) {
593    let Some(row) = output.row_mut(y) else { return };
594    let len = row.len().min(bytes.len());
595    if let (Some(to), Some(from)) = (row.get_mut(..len), bytes.get(..len)) {
596        to.copy_from_slice(from);
597    }
598}
599
600#[cfg(test)]
601#[allow(
602    clippy::unwrap_used,
603    clippy::expect_used,
604    clippy::indexing_slicing,
605    clippy::panic,
606    reason = "tests operate on known-good values and assert shapes directly"
607)]
608mod tests {
609    use super::*;
610    use otf_pixels_core::{PixelFormat, TileBuf};
611
612    /// Resize a whole image in one tile, the simple case.
613    fn resize_whole(
614        op: &Resize,
615        input: &ImageDescriptor,
616        bytes: &[u8],
617    ) -> Result<(ImageDescriptor, Vec<u8>)> {
618        let out_desc = op.output_descriptor(std::slice::from_ref(input))?;
619        let source = TileBuf::from_vec(input.region(), input.pixel, bytes.to_vec())?;
620        let mut target = TileBuf::for_image(&out_desc)?;
621        let regions = op.input_regions(out_desc.region(), std::slice::from_ref(input))?;
622        assert_eq!(
623            regions[0],
624            input.region(),
625            "whole-image demand should be the whole input"
626        );
627        op.compute(&[source.as_tile()?], &mut target.as_tile_mut()?)?;
628        Ok((out_desc, target.into_bytes()))
629    }
630
631    fn ramp(width: u32, height: u32, format: PixelFormat) -> (ImageDescriptor, Vec<u8>) {
632        let descriptor = ImageDescriptor::new(width, height, format).unwrap();
633        let len = descriptor.byte_len().unwrap();
634        let bytes = (0..len).map(|i| ((i * 37) % 251) as u8).collect();
635        (descriptor, bytes)
636    }
637
638    #[test]
639    fn the_output_is_independent_of_how_the_image_is_tiled() {
640        // The property that makes per-image weight tables necessary rather
641        // than merely tidy. Resizing in one tile and in many must give the
642        // same bytes, or SPEC §Guarantees 2 is false.
643        let (input, bytes) = ramp(97, 71, PixelFormat::Rgb8);
644        let op = Resize::new(41, 33, ResizeOptions::default()).unwrap();
645        let (out_desc, whole) = resize_whole(&op, &input, &bytes).unwrap();
646
647        for (tile_w, tile_h) in [(8_u32, 8_u32), (16, 4), (41, 1), (1, 33), (7, 13)] {
648            // A fresh op per run: a binding belongs to one graph edge.
649            let op = Resize::new(41, 33, ResizeOptions::default()).unwrap();
650            op.output_descriptor(std::slice::from_ref(&input)).unwrap();
651            let source = TileBuf::from_vec(input.region(), input.pixel, bytes.clone()).unwrap();
652            let mut target = TileBuf::for_image(&out_desc).unwrap();
653
654            let mut y = 0;
655            while y < out_desc.height {
656                let h = tile_h.min(out_desc.height - y);
657                let mut x = 0;
658                while x < out_desc.width {
659                    let w = tile_w.min(out_desc.width - x);
660                    let region = Region::new(x, y, w, h);
661                    let demand = op
662                        .input_regions(region, std::slice::from_ref(&input))
663                        .unwrap();
664                    let window = source.as_tile().unwrap();
665                    let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
666
667                    // Hand the op exactly the footprint it asked for.
668                    let mut cut = TileBuf::zeroed(demand[0], input.pixel).unwrap();
669                    otf_pixels_core::copy_region(
670                        &window,
671                        &mut cut.as_tile_mut().unwrap(),
672                        demand[0],
673                    )
674                    .unwrap();
675
676                    op.compute(&[cut.as_tile().unwrap()], &mut sub.as_tile_mut().unwrap())
677                        .unwrap();
678                    otf_pixels_core::copy_region(
679                        &sub.as_tile().unwrap(),
680                        &mut target.as_tile_mut().unwrap(),
681                        region,
682                    )
683                    .unwrap();
684                    x += w;
685                }
686                y += h;
687            }
688            assert_eq!(
689                target.into_bytes(),
690                whole,
691                "tiling at {tile_w}x{tile_h} changed the pixels"
692            );
693        }
694    }
695
696    /// Run `op` over the whole output in `tile`-sized pieces, each handed
697    /// exactly the input it demands.
698    fn resize_tiled(
699        op: &Resize,
700        input: &ImageDescriptor,
701        bytes: &[u8],
702        tile: (u32, u32),
703    ) -> (ImageDescriptor, Vec<u8>) {
704        let out_desc = op.output_descriptor(std::slice::from_ref(input)).unwrap();
705        let source = TileBuf::from_vec(input.region(), input.pixel, bytes.to_vec()).unwrap();
706        let mut target = TileBuf::for_image(&out_desc).unwrap();
707        let mut y = 0;
708        while y < out_desc.height {
709            let h = tile.1.min(out_desc.height - y);
710            let mut x = 0;
711            while x < out_desc.width {
712                let w = tile.0.min(out_desc.width - x);
713                let region = Region::new(x, y, w, h);
714                let demand = op
715                    .input_regions(region, std::slice::from_ref(input))
716                    .unwrap();
717                let mut cut = TileBuf::zeroed(demand[0], input.pixel).unwrap();
718                otf_pixels_core::copy_region(
719                    &source.as_tile().unwrap(),
720                    &mut cut.as_tile_mut().unwrap(),
721                    demand[0],
722                )
723                .unwrap();
724                let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
725                op.compute(&[cut.as_tile().unwrap()], &mut sub.as_tile_mut().unwrap())
726                    .unwrap();
727                otf_pixels_core::copy_region(
728                    &sub.as_tile().unwrap(),
729                    &mut target.as_tile_mut().unwrap(),
730                    region,
731                )
732                .unwrap();
733                x += w;
734            }
735            y += h;
736        }
737        (out_desc, target.into_bytes())
738    }
739
740    fn with_fit(width: u32, height: u32, fit: Fit) -> Resize {
741        Resize::new(
742            width,
743            height,
744            ResizeOptions::default()
745                .with_fit(fit)
746                .with_background([10, 20, 30, 255]),
747        )
748        .unwrap()
749    }
750
751    #[test]
752    fn outside_and_inside_scale_by_the_other_axis() {
753        let (input, _) = ramp(200, 100, PixelFormat::Rgb8);
754        assert_eq!(with_fit(50, 50, Fit::Inside).target(&input), (50, 25));
755        assert_eq!(with_fit(50, 50, Fit::Outside).target(&input), (100, 50));
756        assert_eq!(with_fit(50, 50, Fit::Cover).target(&input), (50, 50));
757        assert_eq!(with_fit(50, 50, Fit::Contain).target(&input), (50, 50));
758        assert_eq!(with_fit(50, 50, Fit::Fill).target(&input), (50, 50));
759    }
760
761    #[test]
762    fn cover_is_outside_then_a_centred_crop() {
763        for (w, h, format) in [
764            (200, 100, PixelFormat::Rgb8),
765            (61, 97, PixelFormat::Rgba16),
766            (90, 90, PixelFormat::Gray8),
767        ] {
768            let (input, bytes) = ramp(w, h, format);
769            let (outside_desc, outside) = resize_tiled(
770                &with_fit(40, 30, Fit::Outside),
771                &input,
772                &bytes,
773                (1000, 1000),
774            );
775            let bpp = format.bytes_per_pixel();
776            for tile in [(1000, 1000), (7, 5), (40, 1)] {
777                let (desc, cover) =
778                    resize_tiled(&with_fit(40, 30, Fit::Cover), &input, &bytes, tile);
779                assert_eq!(
780                    (desc.width, desc.height),
781                    (40.min(outside_desc.width), 30.min(outside_desc.height))
782                );
783                let (dx, dy) = (
784                    (outside_desc.width - desc.width) / 2,
785                    (outside_desc.height - desc.height) / 2,
786                );
787                for y in 0..desc.height as usize {
788                    let from =
789                        ((y + dy as usize) * outside_desc.width as usize + dx as usize) * bpp;
790                    let row = desc.width as usize * bpp;
791                    assert_eq!(
792                        &cover[y * row..(y + 1) * row],
793                        &outside[from..from + row],
794                        "{w}x{h} {format} row {y} tile {tile:?}"
795                    );
796                }
797            }
798        }
799    }
800
801    #[test]
802    fn contain_is_inside_centred_on_the_background() {
803        for (w, h, format) in [
804            (200, 100, PixelFormat::Rgb8),
805            (61, 97, PixelFormat::Rgba8),
806            (30, 90, PixelFormat::Gray16),
807        ] {
808            let (input, bytes) = ramp(w, h, format);
809            let (inside_desc, inside) =
810                resize_tiled(&with_fit(40, 30, Fit::Inside), &input, &bytes, (1000, 1000));
811            let bpp = format.bytes_per_pixel();
812            let fill = background_pixel(format, [10, 20, 30, 255]);
813            for tile in [(1000, 1000), (7, 5), (3, 30), (40, 2)] {
814                let (desc, contain) =
815                    resize_tiled(&with_fit(40, 30, Fit::Contain), &input, &bytes, tile);
816                assert_eq!((desc.width, desc.height), (40, 30));
817                let (px, py) = ((40 - inside_desc.width) / 2, (30 - inside_desc.height) / 2);
818                for y in 0..30_u32 {
819                    for x in 0..40_u32 {
820                        let at = ((y * 40 + x) as usize) * bpp;
821                        let got = &contain[at..at + bpp];
822                        let inside_x = x.checked_sub(px).filter(|&v| v < inside_desc.width);
823                        let inside_y = y.checked_sub(py).filter(|&v| v < inside_desc.height);
824                        let want = match (inside_x, inside_y) {
825                            (Some(ix), Some(iy)) => {
826                                let from = ((iy * inside_desc.width + ix) as usize) * bpp;
827                                &inside[from..from + bpp]
828                            }
829                            _ => &fill[..],
830                        };
831                        assert_eq!(got, want, "{w}x{h} {format} at ({x}, {y}) tile {tile:?}");
832                    }
833                }
834            }
835        }
836    }
837
838    #[test]
839    fn the_background_follows_the_pixel_format() {
840        assert_eq!(
841            background_pixel(PixelFormat::Rgba8, [1, 2, 3, 4]),
842            vec![1, 2, 3, 4]
843        );
844        assert_eq!(
845            background_pixel(PixelFormat::Rgb8, [1, 2, 3, 4]),
846            vec![1, 2, 3]
847        );
848        assert_eq!(
849            background_pixel(PixelFormat::Gray8, [255, 255, 255, 0]),
850            vec![255]
851        );
852        assert_eq!(
853            background_pixel(PixelFormat::GrayA8, [0, 0, 0, 9]),
854            vec![0, 9]
855        );
856        assert_eq!(
857            background_pixel(PixelFormat::Gray16, [255, 0, 0, 255]),
858            (76_u16 * 257).to_ne_bytes().to_vec()
859        );
860    }
861
862    #[test]
863    fn a_one_to_one_resize_returns_the_image_unchanged() {
864        for format in [
865            PixelFormat::Gray8,
866            PixelFormat::Rgb8,
867            PixelFormat::Rgba8,
868            PixelFormat::Gray16,
869            PixelFormat::Rgb16,
870        ] {
871            let (input, bytes) = ramp(31, 23, format);
872            let op = Resize::new(31, 23, ResizeOptions::default()).unwrap();
873            let (out_desc, out) = resize_whole(&op, &input, &bytes).unwrap();
874            assert_eq!(out_desc.width, 31);
875            assert_eq!(out, bytes, "{format} changed at 1:1");
876        }
877    }
878
879    #[test]
880    fn a_flat_image_stays_flat_at_every_scale_and_filter() {
881        for filter in [
882            Filter::Nearest,
883            Filter::Box,
884            Filter::Bilinear,
885            Filter::CatmullRom,
886            Filter::Mitchell,
887            Filter::Lanczos2,
888            Filter::Lanczos3,
889        ] {
890            for (w, h) in [(10_u32, 10_u32), (200, 150), (37, 91)] {
891                let input = ImageDescriptor::new(64, 64, PixelFormat::Rgb8).unwrap();
892                let bytes = vec![137_u8; input.byte_len().unwrap()];
893                let op = Resize::new(w, h, ResizeOptions::default().with_filter(filter)).unwrap();
894                let (_, out) = resize_whole(&op, &input, &bytes).unwrap();
895                assert!(
896                    out.iter().all(|&v| v == 137),
897                    "{} to {w}x{h} did not stay flat",
898                    filter.as_str()
899                );
900            }
901        }
902    }
903
904    #[test]
905    fn fit_inside_preserves_the_aspect_ratio() {
906        let input = ImageDescriptor::new(1000, 500, PixelFormat::Rgb8).unwrap();
907        let op = Resize::new(100, 100, ResizeOptions::default().with_fit(Fit::Inside)).unwrap();
908        assert_eq!(
909            op.target(&input),
910            (100, 50),
911            "wide image should bind on width"
912        );
913
914        let tall = ImageDescriptor::new(500, 1000, PixelFormat::Rgb8).unwrap();
915        assert_eq!(
916            op.target(&tall),
917            (50, 100),
918            "tall image should bind on height"
919        );
920    }
921
922    #[test]
923    fn fit_fill_ignores_the_aspect_ratio() {
924        let input = ImageDescriptor::new(1000, 500, PixelFormat::Rgb8).unwrap();
925        let op = Resize::new(100, 100, ResizeOptions::default()).unwrap();
926        assert_eq!(op.target(&input), (100, 100));
927    }
928
929    #[test]
930    fn without_enlargement_leaves_a_small_image_alone() {
931        let input = ImageDescriptor::new(40, 30, PixelFormat::Rgb8).unwrap();
932        let options = ResizeOptions::default()
933            .with_fit(Fit::Inside)
934            .without_enlargement(true);
935        let op = Resize::new(1000, 1000, options).unwrap();
936        assert_eq!(op.target(&input), (40, 30));
937
938        // The same options must still shrink something larger than the box.
939        let big = ImageDescriptor::new(4000, 3000, PixelFormat::Rgb8).unwrap();
940        assert_eq!(op.target(&big), (1000, 750));
941    }
942
943    #[test]
944    fn a_zero_target_is_an_error() {
945        assert!(Resize::new(0, 10, ResizeOptions::default()).is_err());
946        assert!(Resize::new(10, 0, ResizeOptions::default()).is_err());
947    }
948
949    #[test]
950    fn a_target_never_collapses_to_zero() {
951        // Fit::Inside on an extreme aspect ratio would round a dimension to
952        // zero, which is not a representable image.
953        let input = ImageDescriptor::new(10_000, 3, PixelFormat::Gray8).unwrap();
954        let op = Resize::new(50, 50, ResizeOptions::default().with_fit(Fit::Inside)).unwrap();
955        let (w, h) = op.target(&input);
956        assert!(w >= 1 && h >= 1, "target collapsed to {w}x{h}");
957    }
958
959    #[test]
960    fn reusing_an_op_across_two_shapes_is_an_error_not_a_wrong_answer() {
961        // A binding belongs to one graph edge. Silently resampling the second
962        // image against the first one's scale would be a wrong answer with no
963        // symptom, so it is refused instead.
964        let first = ImageDescriptor::new(100, 100, PixelFormat::Gray8).unwrap();
965        let second = ImageDescriptor::new(200, 200, PixelFormat::Gray8).unwrap();
966        let op = Resize::to(50, 50).unwrap();
967        op.output_descriptor(std::slice::from_ref(&first)).unwrap();
968        let error = op
969            .output_descriptor(std::slice::from_ref(&second))
970            .unwrap_err();
971        assert!(error.to_string().contains("bound to"), "{error}");
972    }
973
974    #[test]
975    fn a_clone_can_be_bound_to_a_different_shape() {
976        // The counterpart: cloning is how a caller reuses a configured op, so
977        // a clone must start unbound rather than inherit the original tables.
978        let first = ImageDescriptor::new(100, 100, PixelFormat::Gray8).unwrap();
979        let second = ImageDescriptor::new(200, 200, PixelFormat::Gray8).unwrap();
980        let op = Resize::to(50, 50).unwrap();
981        op.output_descriptor(std::slice::from_ref(&first)).unwrap();
982        let fresh = op.clone();
983        assert!(
984            fresh
985                .output_descriptor(std::slice::from_ref(&second))
986                .is_ok()
987        );
988    }
989
990    #[test]
991    fn demand_never_reaches_outside_the_input() {
992        // Op::input_regions requires clamped regions; an op reading past its
993        // input is a defect, and Lanczos3 near an edge is where it would.
994        let input = ImageDescriptor::new(50, 40, PixelFormat::Rgb8).unwrap();
995        let op = Resize::new(200, 160, ResizeOptions::default()).unwrap();
996        let out = op.output_descriptor(std::slice::from_ref(&input)).unwrap();
997        for y in 0..out.height {
998            for x in 0..out.width {
999                let region = Region::new(x, y, 1, 1);
1000                let demand = op
1001                    .input_regions(region, std::slice::from_ref(&input))
1002                    .unwrap();
1003                let r = demand[0];
1004                assert!(
1005                    r.x + r.width <= input.width && r.y + r.height <= input.height,
1006                    "demand {r} for output {region} leaves a {}x{} input",
1007                    input.width,
1008                    input.height
1009                );
1010            }
1011        }
1012    }
1013
1014    #[test]
1015    fn resize_is_deterministic() {
1016        let (input, bytes) = ramp(123, 87, PixelFormat::Rgba8);
1017        let op = Resize::to(61, 43).unwrap();
1018        let (_, first) = resize_whole(&op, &input, &bytes).unwrap();
1019        for _ in 0..5 {
1020            let op = Resize::to(61, 43).unwrap();
1021            let (_, again) = resize_whole(&op, &input, &bytes).unwrap();
1022            assert_eq!(again, first, "resize is not deterministic");
1023        }
1024    }
1025}