Skip to main content

alpha2_native/
tools.rs

1//! Media tools on decoded frames: picking a representative thumbnail,
2//! making proxy-sized frames, and resizing a picture to the size it is shown
3//! at. Pure functions of the frame, with no `FFmpeg`.
4
5use alpha2_media::{DecodedVideoFrame, PixelFormat, Plane, Rect};
6
7/// Samples across and down a frame when it is scored.
8const SCORE_GRID: (u32, u32) = (128, 72);
9
10/// Mean luma, as a fraction of full scale, below which a frame is black and
11/// above which it is white: neither makes a thumbnail.
12pub const THUMBNAIL_LUMA_RANGE: (f64, f64) = (0.08, 0.92);
13
14/// How a frame would do as a thumbnail.
15#[derive(Copy, Clone, Debug, PartialEq)]
16pub struct FrameScore {
17    /// Mean luma, `0..=1` of the code range.
18    pub mean_luma: f64,
19    /// Variance of the 4-neighbour Laplacian of luma over a fixed grid: high
20    /// for detail, low for blur and flat frames.
21    pub sharpness: f64,
22}
23
24/// Scores a frame on a fixed grid of luma samples, so the score does not
25/// depend on the frame's size.
26///
27/// # Errors
28/// Returns a message when a plane is shorter than its declared layout.
29#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
30pub fn score_frame(frame: &DecodedVideoFrame) -> Result<FrameScore, String> {
31    let (columns, rows) = SCORE_GRID;
32    let crop = frame.visible_rect;
33    if crop.width == 0 || crop.height == 0 {
34        return Err("frame has no visible pixels".into());
35    }
36    let mut luma = Vec::with_capacity((columns * rows) as usize);
37    for row in 0..rows {
38        for column in 0..columns {
39            let x =
40                crop.x + (u64::from(column) * u64::from(crop.width) / u64::from(columns)) as u32;
41            let y = crop.y + (u64::from(row) * u64::from(crop.height) / u64::from(rows)) as u32;
42            luma.push(luma_at(frame, x, y)?);
43        }
44    }
45    let mean = luma.iter().sum::<f64>() / luma.len() as f64;
46    let at = |column: u32, row: u32| luma[(row * columns + column) as usize];
47    let mut laplacians = Vec::with_capacity(luma.len());
48    for row in 1..rows - 1 {
49        for column in 1..columns - 1 {
50            laplacians.push(
51                4.0 * at(column, row)
52                    - at(column - 1, row)
53                    - at(column + 1, row)
54                    - at(column, row - 1)
55                    - at(column, row + 1),
56            );
57        }
58    }
59    let average = laplacians.iter().sum::<f64>() / laplacians.len() as f64;
60    let sharpness = laplacians
61        .iter()
62        .map(|value| (value - average).powi(2))
63        .sum::<f64>()
64        / laplacians.len() as f64;
65    Ok(FrameScore {
66        mean_luma: mean,
67        sharpness,
68    })
69}
70
71/// The best thumbnail among scored frames: the sharpest that is neither
72/// black nor white, or the sharpest of all when every frame is. `None` for
73/// no frames.
74#[must_use]
75pub fn choose_thumbnail(scores: &[FrameScore]) -> Option<usize> {
76    let (dark, bright) = THUMBNAIL_LUMA_RANGE;
77    let sharpest = |acceptable: &dyn Fn(&FrameScore) -> bool| {
78        scores
79            .iter()
80            .enumerate()
81            .filter(|(_, score)| acceptable(score))
82            .max_by(|(_, a), (_, b)| a.sharpness.total_cmp(&b.sharpness))
83            .map(|(index, _)| index)
84    };
85    sharpest(&|score| (dark..=bright).contains(&score.mean_luma)).or_else(|| sharpest(&|_| true))
86}
87
88/// Luma at a pixel, `0..=1` of the code range: the first plane of a YCbCr
89/// frame, or BT.709 weights on the RGB codes of an RGBA one.
90fn luma_at(frame: &DecodedVideoFrame, x: u32, y: u32) -> Result<f64, String> {
91    let plane = frame.planes.first().ok_or("frame has no planes")?;
92    match frame.pixel_format {
93        PixelFormat::Yuv(format) => {
94            let wide = format.bit_depth > 8;
95            let value = f64::from(sample(plane, x, y, if wide { 2 } else { 1 }, 1, 0)?);
96            // An MSB-aligned sample (P010) fills the whole `u16`.
97            let full_scale = if format.msb_aligned {
98                65_535.0
99            } else {
100                f64::from((1_u32 << format.bit_depth) - 1)
101            };
102            Ok(value / full_scale)
103        }
104        PixelFormat::Rgba8 => {
105            let channel = |index| sample(plane, x, y, 1, 4, index).map(f64::from);
106            Ok((0.2126 * channel(0)? + 0.7152 * channel(1)? + 0.0722 * channel(2)?) / 255.0)
107        }
108        other => Err(format!("cannot read luma from {other:?}")),
109    }
110}
111
112/// Component `component` of the sample at `(x, y)` of a plane with
113/// `components` components per position, each `bytes` wide (a
114/// little-endian `u16` when two).
115fn sample(
116    plane: &Plane,
117    x: u32,
118    y: u32,
119    bytes: usize,
120    components: usize,
121    component: usize,
122) -> Result<u16, String> {
123    let offset = y as usize * plane.stride as usize + (x as usize * components + component) * bytes;
124    let slice = plane
125        .bytes
126        .get(offset..offset + bytes)
127        .ok_or("plane is shorter than its layout")?;
128    Ok(if bytes == 2 {
129        u16::from_le_bytes([slice[0], slice[1]])
130    } else {
131        u16::from(slice[0])
132    })
133}
134
135/// The frame shrunk by `factor` (2 for half size, 4 for quarter), each output
136/// sample the mean of the `factor x factor` block it covers, the visible
137/// size rounded down to even numbers so 4:2:0 encoders take it.
138///
139/// # Errors
140/// Returns a message for a factor below 2, a frame too small to shrink, or a
141/// plane shorter than its layout.
142pub fn downscale_frame(
143    frame: &DecodedVideoFrame,
144    factor: u32,
145) -> Result<DecodedVideoFrame, String> {
146    if factor < 2 {
147        return Err("a proxy factor is 2 or more".into());
148    }
149    let crop = frame.visible_rect;
150    let (width, height) = ((crop.width / factor) & !1, (crop.height / factor) & !1);
151    if width == 0 || height == 0 {
152        return Err(format!(
153            "a {}x{} frame is too small to shrink by {factor}",
154            crop.width, crop.height
155        ));
156    }
157    let planes = match frame.pixel_format {
158        PixelFormat::Rgba8 => vec![shrink_plane(
159            plane(frame, 0)?,
160            (crop.x, crop.y),
161            (width, height),
162            factor,
163            1,
164            4,
165        )?],
166        PixelFormat::Yuv(format) => {
167            let bytes = if format.bit_depth > 8 { 2 } else { 1 };
168            let (shift_x, shift_y) = (
169                u32::from(format.chroma_shift_x),
170                u32::from(format.chroma_shift_y),
171            );
172            let chroma_size = (width.div_ceil(1 << shift_x), height.div_ceil(1 << shift_y));
173            let chroma_origin = (crop.x >> shift_x, crop.y >> shift_y);
174            let mut planes = vec![shrink_plane(
175                plane(frame, 0)?,
176                (crop.x, crop.y),
177                (width, height),
178                factor,
179                bytes,
180                1,
181            )?];
182            if format.interleaved_chroma {
183                planes.push(shrink_plane(
184                    plane(frame, 1)?,
185                    chroma_origin,
186                    chroma_size,
187                    factor,
188                    bytes,
189                    2,
190                )?);
191            } else {
192                for index in 1..frame.planes.len() {
193                    planes.push(shrink_plane(
194                        plane(frame, index)?,
195                        chroma_origin,
196                        chroma_size,
197                        factor,
198                        bytes,
199                        1,
200                    )?);
201                }
202            }
203            planes
204        }
205        other => return Err(format!("cannot shrink {other:?} frames")),
206    };
207    Ok(DecodedVideoFrame {
208        visible_rect: Rect {
209            x: 0,
210            y: 0,
211            width,
212            height,
213        },
214        planes,
215        ..frame.clone()
216    })
217}
218
219/// Straight-alpha RGBA `pixels`, `width` x `height`, resized to
220/// `new_width` x `new_height`: each axis resampled by a tent filter as wide
221/// as a source pixel or an output pixel, whichever is wider, so an axis that
222/// shrinks averages what each output pixel covers (as a proxy averages its
223/// blocks) and one that grows interpolates. Colour is weighted by alpha, so a
224/// transparent pixel's colour does not bleed into its neighbours. An axis
225/// that keeps its size is left exactly as it is.
226///
227/// # Errors
228/// Returns a message for an empty size or a buffer that is not
229/// `width` x `height` pixels.
230pub fn resize_rgba(
231    pixels: &[u8],
232    (width, height): (u32, u32),
233    (new_width, new_height): (u32, u32),
234) -> Result<Vec<u8>, String> {
235    let (columns, rows) = (width as usize, height as usize);
236    if width == 0 || height == 0 || new_width == 0 || new_height == 0 {
237        return Err("cannot resize an empty picture".to_owned());
238    }
239    if columns
240        .checked_mul(rows)
241        .and_then(|count| count.checked_mul(4))
242        != Some(pixels.len())
243    {
244        return Err("RGBA buffer does not match its size".to_owned());
245    }
246    // Alpha-weighted colour and alpha, in 0..=1.
247    let mut picture = pixels
248        .as_chunks::<4>()
249        .0
250        .iter()
251        .flat_map(|&[r, g, b, a]| {
252            let alpha = f32::from(a) / 255.0;
253            [
254                f32::from(r) / 255.0 * alpha,
255                f32::from(g) / 255.0 * alpha,
256                f32::from(b) / 255.0 * alpha,
257                alpha,
258            ]
259        })
260        .collect::<Vec<_>>();
261    let (mut current_columns, mut current_rows) = (columns, rows);
262    if new_width != width {
263        let taps = tent_taps(columns, new_width as usize);
264        let mut resized = vec![0.0_f32; new_width as usize * rows * 4];
265        for y in 0..rows {
266            for (x, row_taps) in taps.iter().enumerate() {
267                let out = &mut resized[(y * new_width as usize + x) * 4..][..4];
268                for &(source, weight) in row_taps {
269                    for (channel, value) in out.iter_mut().enumerate() {
270                        *value += weight * picture[(y * columns + source) * 4 + channel];
271                    }
272                }
273            }
274        }
275        picture = resized;
276        current_columns = new_width as usize;
277    }
278    if new_height != height {
279        let taps = tent_taps(rows, new_height as usize);
280        let mut resized = vec![0.0_f32; current_columns * new_height as usize * 4];
281        for (y, column_taps) in taps.iter().enumerate() {
282            for x in 0..current_columns {
283                let out = &mut resized[(y * current_columns + x) * 4..][..4];
284                for &(source, weight) in column_taps {
285                    for (channel, value) in out.iter_mut().enumerate() {
286                        *value += weight * picture[(source * current_columns + x) * 4 + channel];
287                    }
288                }
289            }
290        }
291        picture = resized;
292        current_rows = new_height as usize;
293    }
294    debug_assert_eq!(picture.len(), current_columns * current_rows * 4);
295    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
296    let code = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
297    Ok(picture
298        .as_chunks::<4>()
299        .0
300        .iter()
301        .flat_map(|&[r, g, b, a]| {
302            let colour = |value: f32| if a > 0.0 { code(value / a) } else { 0 };
303            [colour(r), colour(g), colour(b), code(a)]
304        })
305        .collect())
306}
307
308/// For each of `outputs` samples along an axis of `inputs`, the input
309/// samples it weighs and their weights, which sum to one: a tent centred
310/// where the output sample's centre falls, reaching the larger of the two
311/// sample spacings to each side, its samples clamped to the edge.
312#[allow(
313    clippy::cast_precision_loss,
314    clippy::cast_possible_truncation,
315    clippy::cast_possible_wrap,
316    clippy::cast_sign_loss
317)]
318fn tent_taps(inputs: usize, outputs: usize) -> Vec<Vec<(usize, f32)>> {
319    let scale = inputs as f64 / outputs as f64;
320    let radius = scale.max(1.0);
321    (0..outputs)
322        .map(|output| {
323            let centre = (output as f64 + 0.5) * scale - 0.5;
324            let first = (centre - radius).floor() as i64;
325            let last = (centre + radius).ceil() as i64;
326            let mut taps = Vec::new();
327            for input in first..=last {
328                let weight = 1.0 - (input as f64 - centre).abs() / radius;
329                if weight <= 0.0 {
330                    continue;
331                }
332                let clamped = input.clamp(0, inputs as i64 - 1) as usize;
333                match taps.iter_mut().find(|(index, _)| *index == clamped) {
334                    Some((_, total)) => *total += weight,
335                    None => taps.push((clamped, weight)),
336                }
337            }
338            let sum: f64 = taps.iter().map(|(_, weight)| weight).sum();
339            taps.into_iter()
340                .map(|(index, weight)| (index, (weight / sum) as f32))
341                .collect()
342        })
343        .collect()
344}
345
346fn plane(frame: &DecodedVideoFrame, index: usize) -> Result<&Plane, String> {
347    frame
348        .planes
349        .get(index)
350        .ok_or_else(|| format!("frame has no plane {index}"))
351}
352
353/// A `size` plane whose samples average `factor x factor` blocks of `plane`
354/// from `origin` on, clamped to the source plane's edge.
355///
356/// The plane's length is checked once; each output row then sums whole
357/// source rows, `factor` of them, so a sample is read by indexing a row.
358fn shrink_plane(
359    source: &Plane,
360    origin: (u32, u32),
361    size: (u32, u32),
362    factor: u32,
363    bytes: usize,
364    components: usize,
365) -> Result<Plane, String> {
366    let (width, height) = size;
367    let stride = width as usize * components * bytes;
368    let (source_width, source_height) = (source.width as usize, source.height as usize);
369    let source_stride = source.stride as usize;
370    let row_bytes = source_width * components * bytes;
371    if source_width == 0
372        || source_height == 0
373        || source_stride < row_bytes
374        || source.bytes.len() < (source_height - 1) * source_stride + row_bytes
375    {
376        return Err("plane is shorter than its layout".to_owned());
377    }
378    // The source column each output column's `dx`-th sample comes from, clamped to the edge.
379    let columns = (0..width as usize)
380        .flat_map(|x| {
381            (0..factor as usize)
382                .map(move |dx| (origin.0 as usize + x * factor as usize + dx).min(source_width - 1))
383        })
384        .collect::<Vec<_>>();
385    let count = u64::from(factor * factor);
386    let mut sums = vec![0_u64; width as usize * components];
387    let mut output = Vec::with_capacity(stride * height as usize);
388    for y in 0..height as usize {
389        sums.fill(0);
390        for dy in 0..factor as usize {
391            let sy = (origin.1 as usize + y * factor as usize + dy).min(source_height - 1);
392            let row = &source.bytes[sy * source_stride..sy * source_stride + row_bytes];
393            if bytes == 2 {
394                accumulate(&mut sums, &columns, factor as usize, components, |at| {
395                    u64::from(u16::from_le_bytes([row[2 * at], row[2 * at + 1]]))
396                });
397            } else {
398                accumulate(&mut sums, &columns, factor as usize, components, |at| {
399                    u64::from(row[at])
400                });
401            }
402        }
403        for sum in &sums {
404            let mean = (sum + count / 2) / count;
405            if bytes == 2 {
406                output.extend_from_slice(&u16::try_from(mean).unwrap_or(u16::MAX).to_le_bytes());
407            } else {
408                output.push(u8::try_from(mean).unwrap_or(u8::MAX));
409            }
410        }
411    }
412    Ok(Plane {
413        bytes: output.into(),
414        stride: u32::try_from(stride).map_err(|_| "plane is too wide")?,
415        width,
416        height,
417    })
418}
419
420/// Adds one source row's samples into `sums`, one per output sample: each
421/// output column reads the `factor` source columns `columns` lists for it,
422/// and `read` gives the sample at a sample index of the row.
423fn accumulate(
424    sums: &mut [u64],
425    columns: &[usize],
426    factor: usize,
427    components: usize,
428    read: impl Fn(usize) -> u64,
429) {
430    for (x, sources) in columns.chunks_exact(factor).enumerate() {
431        for component in 0..components {
432            sums[x * components + component] += sources
433                .iter()
434                .map(|column| read(column * components + component))
435                .sum::<u64>();
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use alpha2_media::{ChromaLocation, Orientation, SampleAspectRatio, YuvFormat};
444    use alpha2_time::MediaTime;
445
446    /// An I420 frame whose luma is `luma(x, y)` and chroma mid-grey.
447    fn frame(width: u32, height: u32, luma: impl Fn(u32, u32) -> u8) -> DecodedVideoFrame {
448        let y_plane = Plane {
449            bytes: (0..height)
450                .flat_map(|y| (0..width).map(move |x| (x, y)))
451                .map(|(x, y)| luma(x, y))
452                .collect::<Vec<_>>()
453                .into(),
454            stride: width,
455            width,
456            height,
457        };
458        let chroma = |value| Plane {
459            bytes: vec![value; (width.div_ceil(2) * height.div_ceil(2)) as usize].into(),
460            stride: width.div_ceil(2),
461            width: width.div_ceil(2),
462            height: height.div_ceil(2),
463        };
464        DecodedVideoFrame {
465            pts: MediaTime::ZERO,
466            dts: None,
467            duration: None,
468            visible_rect: Rect {
469                x: 0,
470                y: 0,
471                width,
472                height,
473            },
474            sample_aspect_ratio: SampleAspectRatio::default(),
475            orientation: Orientation::UPRIGHT,
476            pixel_format: PixelFormat::Yuv(YuvFormat::I420),
477            chroma_location: ChromaLocation::default(),
478            color: None,
479            planes: vec![y_plane, chroma(128), chroma(128)],
480        }
481    }
482
483    #[test]
484    fn the_thumbnail_is_the_sharpest_frame_neither_black_nor_white() {
485        let black = frame(128, 72, |_, _| 5);
486        // White with sparse black dots: the sharpest frame, but white.
487        let white = frame(
488            128,
489            72,
490            |x, y| {
491                if x % 4 == 0 && y % 4 == 0 { 0 } else { 255 }
492            },
493        );
494        let soft = frame(128, 72, |x, _| {
495            u8::try_from(40 + x * 150 / 128).expect("small")
496        });
497        let detailed = frame(
498            128,
499            72,
500            |x, y| {
501                if (x / 4 + y / 4) % 2 == 0 { 40 } else { 200 }
502            },
503        );
504        let scores =
505            [&black, &white, &soft, &detailed].map(|frame| score_frame(frame).expect("score"));
506        assert!(scores[0].mean_luma < THUMBNAIL_LUMA_RANGE.0);
507        assert!(scores[1].mean_luma > THUMBNAIL_LUMA_RANGE.1);
508        assert!(scores[3].sharpness > scores[2].sharpness);
509        assert!(scores[1].sharpness > scores[3].sharpness);
510        assert_eq!(choose_thumbnail(&scores), Some(3));
511        // All black or white: the sharpest anyway.
512        assert_eq!(choose_thumbnail(&scores[..2]), Some(1));
513        assert_eq!(choose_thumbnail(&[]), None);
514    }
515
516    #[test]
517    fn a_proxy_averages_blocks_and_keeps_an_even_size() {
518        // A 10x6 frame with a horizontal ramp: half size is 4x2 after rounding 5x3 down to even,
519        // each sample the mean of its 2x2 block.
520        let source = frame(10, 6, |x, _| u8::try_from(x * 20).expect("small"));
521        let half = downscale_frame(&source, 2).expect("proxy");
522        assert_eq!((half.visible_rect.width, half.visible_rect.height), (4, 2));
523        assert_eq!(&half.planes[0].bytes[..4], &[10, 50, 90, 130]);
524        assert_eq!((half.planes[1].width, half.planes[1].height), (2, 1));
525        assert!(half.planes[1].bytes.iter().all(|value| *value == 128));
526        assert!(downscale_frame(&source, 1).is_err());
527        assert!(downscale_frame(&frame(3, 3, |_, _| 0), 4).is_err());
528    }
529
530    #[test]
531    fn a_resize_keeps_flat_colour_weighs_alpha_and_leaves_a_kept_axis_alone() {
532        let rgba = |pixels: &[[u8; 4]]| pixels.iter().flatten().copied().collect::<Vec<_>>();
533        let flat = rgba(&[[200, 100, 50, 255]; 60 * 4]);
534        for size in [(64, 4), (32, 4), (60, 7), (17, 3)] {
535            let resized = resize_rgba(&flat, (60, 4), size).expect("resize");
536            assert!(
537                resized.chunks(4).all(|pixel| pixel == [200, 100, 50, 255]),
538                "{size:?}"
539            );
540        }
541        // A transparent pixel's colour is no one's: halving red and a clear blue gives half-clear
542        // red.
543        let half = resize_rgba(&rgba(&[[255, 0, 0, 255], [0, 0, 255, 0]]), (2, 1), (1, 1))
544            .expect("resize");
545        assert_eq!(half, [255, 0, 0, 128]);
546        // A 2:1 stretch interpolates between the stored samples and keeps the rows untouched.
547        let ramp = rgba(&[[0, 0, 0, 255], [200, 200, 200, 255]]);
548        let stretched = resize_rgba(&ramp, (2, 1), (4, 1)).expect("resize");
549        assert_eq!(
550            stretched
551                .chunks(4)
552                .map(|pixel| pixel[0])
553                .collect::<Vec<_>>(),
554            [0, 50, 150, 200]
555        );
556        assert!(resize_rgba(&ramp, (2, 1), (0, 1)).is_err());
557        assert!(resize_rgba(&ramp, (3, 1), (4, 1)).is_err());
558    }
559
560    /// A `size` plane whose samples average `factor x factor` blocks of
561    /// `plane` from `origin` on, clamped to the source plane's edge, read
562    /// sample by sample through a bounds-checked call: the reference the
563    /// row-at-a-time implementation must match byte for byte.
564    fn reference_shrink_plane(
565        source: &Plane,
566        origin: (u32, u32),
567        size: (u32, u32),
568        factor: u32,
569        bytes: usize,
570        components: usize,
571    ) -> Result<Plane, String> {
572        let (width, height) = size;
573        let stride = width as usize * components * bytes;
574        let mut output = Vec::with_capacity(stride * height as usize);
575        for y in 0..height {
576            for x in 0..width {
577                for component in 0..components {
578                    let mut sum = 0_u64;
579                    let mut count = 0_u64;
580                    for dy in 0..factor {
581                        for dx in 0..factor {
582                            let sx =
583                                (origin.0 + x * factor + dx).min(source.width.saturating_sub(1));
584                            let sy =
585                                (origin.1 + y * factor + dy).min(source.height.saturating_sub(1));
586                            sum += u64::from(sample(source, sx, sy, bytes, components, component)?);
587                            count += 1;
588                        }
589                    }
590                    let mean = (sum + count / 2) / count;
591                    if bytes == 2 {
592                        output.extend_from_slice(
593                            &u16::try_from(mean).unwrap_or(u16::MAX).to_le_bytes(),
594                        );
595                    } else {
596                        output.push(u8::try_from(mean).unwrap_or(u8::MAX));
597                    }
598                }
599            }
600        }
601        Ok(Plane {
602            bytes: output.into(),
603            stride: u32::try_from(stride).map_err(|_| "plane is too wide")?,
604            width,
605            height,
606        })
607    }
608
609    /// Shrinking a proxy row by row gives the same bytes as reading every
610    /// sample through a bounds-checked call, edges and offsets included.
611    #[test]
612    fn shrinking_by_rows_gives_the_samples_sample_by_sample_did() {
613        let plane = |width: u32, height: u32, bytes: usize, components: usize| {
614            let stride = width as usize * components * bytes + 3;
615            Plane {
616                bytes: (0..stride * height as usize)
617                    .map(|index| u8::try_from((index * 7919 + index / 13) % 251).expect("byte"))
618                    .collect::<Vec<_>>()
619                    .into(),
620                stride: u32::try_from(stride).expect("stride"),
621                width,
622                height,
623            }
624        };
625        for (width, height) in [(17, 9), (64, 48), (1, 1)] {
626            for (bytes, components) in [(1, 1), (2, 1), (1, 2), (2, 2)] {
627                let source = plane(width, height, bytes, components);
628                for factor in [2, 4] {
629                    for origin in [(0, 0), (3, 1)] {
630                        let size = ((width / factor).max(1), (height / factor).max(1));
631                        let expected = reference_shrink_plane(
632                            &source, origin, size, factor, bytes, components,
633                        )
634                        .expect("reference");
635                        let found = shrink_plane(&source, origin, size, factor, bytes, components)
636                            .expect("rows");
637                        assert_eq!(
638                            found.bytes, expected.bytes,
639                            "{width}x{height} {bytes}B x{components} /{factor} at {origin:?}"
640                        );
641                    }
642                }
643            }
644        }
645    }
646}