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