Skip to main content

alpha2_native/
tools.rs

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