Files
photocraft/crates/codecs/tests/webp_lossy.rs
735bb09a98 Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648) (#880)
* Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648)

crates/codecs gains codecs/vp8: a clean-room VP8 key-frame encoder from RFC 6386
(boolean coder, 16x16 and 4x4 intra prediction with per-macroblock mode decision,
forward DCT/WHT paired with the bit-exact inverse transforms, dc/ac quantiser tables,
token coding with per-frame probability updates, skipped macroblocks). webp.rs wraps
the frame in the RIFF container (bare VP8, or VP8X with ALPH alpha and ICCP/EXIF/XMP).
EncodeOptions: webp_lossless now picks the encoder instead of erroring, webp_quality
(0-100) sets the quantiser; a lossy WebP reports a LossyCompression fidelity warning.

Export As: the WebP entry gets Quality and a Lossless switch (new tl! string in all
catalogs). CLI --quality, MCP/RPC doc_save quality and the engine's JPEG-style
quality turn a WebP lossy; without a quality WebP stays lossless as before.

Tests: tests/webp_lossy.rs decodes every file with image-webp (PSNR by quality,
sizes fall with quality and are >= 4x below lossless on noisy photos, flat frames
under 1.2 KB, alpha exact, metadata round trip, odd/tiny sizes, 16-bit input, the
16383 px limit as an error, text edges), plus unit tests for the coder, transforms,
prediction and header.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* i18n(it): Lossless (Export As WebP switch) in the Italian catalog that landed on main

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:12:38 -04:00

141 lines
6.5 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Lossy WebP (our VP8 encoder) checked against an independent decoder (`image-webp`): the
//! files decode, look like the input (PSNR by quality), get smaller as quality drops and are far
//! smaller than lossless on photographic content; alpha and metadata travel in the container;
//! odd sizes and the limits behave.
mod common;
use common::*;
use photocraft_codecs::*;
fn lossy(q: u8) -> EncodeOptions {
EncodeOptions { webp_lossless: false, webp_quality: q, ..Default::default() }
}
/// A smooth photograph-like image: gradients with a little grain.
fn photo(w: u32, h: u32, seed: u64) -> Image {
synth(w, h, ChannelLayout::Rgb, SampleType::U8, seed, 0.03)
}
#[test]
fn decodes_with_the_right_size_and_looks_like_the_input() {
let img = photo(97, 61, 3);
for (q, min_psnr) in [(100u8, 40.0), (90, 36.0), (75, 33.0), (50, 30.0), (20, 26.0)] {
let bytes = encode(&img, Format::WebP, &lossy(q)).unwrap();
let back = decode(&bytes).unwrap_or_else(|e| panic!("q{q}: {e}"));
assert_eq!(back.dimensions(), (97, 61));
assert_eq!(back.layout(), ChannelLayout::Rgb);
let p = psnr(&img, &back);
assert!(p >= min_psnr, "quality {q}: PSNR {p:.1} dB below {min_psnr}");
}
}
#[test]
fn smaller_at_lower_quality_and_much_smaller_than_lossless_on_photos() {
let img = synth(256, 192, ChannelLayout::Rgb, SampleType::U8, 9, 0.08);
let lossless = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap().len();
let sizes: Vec<usize> = [95u8, 80, 60, 40, 20].iter().map(|&q| encode(&img, Format::WebP, &lossy(q)).unwrap().len()).collect();
for w in sizes.windows(2) {
assert!(w[1] <= w[0], "sizes must not grow as quality drops: {sizes:?}");
}
assert!(sizes[1] * 4 < lossless, "q80 {} vs lossless {lossless}", sizes[1]);
assert!(sizes[4] * 10 < lossless, "q20 {} vs lossless {lossless}", sizes[4]);
}
#[test]
fn flat_areas_cost_almost_nothing() {
// A single colour: every macroblock is skipped; the file is dominated by the header.
let img = Image::from_u8(640, 480, ChannelLayout::Rgb, vec![200u8; 640 * 480 * 3]).unwrap();
let bytes = encode(&img, Format::WebP, &lossy(80)).unwrap();
assert!(bytes.len() < 1200, "{} bytes", bytes.len());
let back = decode(&bytes).unwrap();
assert!(max_abs_diff(&img, &back) <= 3.0 / 255.0, "flat colour reproduced within rounding");
}
#[test]
fn alpha_is_kept_exactly_and_metadata_travels() {
let mut img = synth(40, 30, ChannelLayout::Rgba, SampleType::U8, 5, 0.05);
img.icc = Some(sample_icc(400));
img.meta.exif = Some(sample_exif());
img.meta.xmp = Some(SAMPLE_XMP.to_owned());
let bytes = encode(&img, Format::WebP, &lossy(85)).unwrap();
assert_eq!(&bytes[12..16], b"VP8X", "extended container");
let back = decode(&bytes).unwrap();
assert_eq!(back.layout(), ChannelLayout::Rgba);
for (a, b) in img.data().as_chunks::<4>().0.iter().zip(back.data().as_chunks::<4>().0) {
assert_eq!(a[3], b[3], "alpha is stored losslessly");
}
assert!(psnr(&img, &back) > 30.0);
assert_eq!(back.icc, img.icc);
assert!(back.meta.xmp.is_some());
assert!(back.meta.exif.is_some());
// Fully opaque alpha is dropped rather than stored.
let mut data = synth(40, 30, ChannelLayout::Rgba, SampleType::U8, 5, 0.0).data().to_vec();
for px in data.as_chunks_mut::<4>().0 {
px[3] = 255;
}
let opaque = Image::from_u8(40, 30, ChannelLayout::Rgba, data).unwrap();
let bytes = encode(&opaque, Format::WebP, &EncodeOptions { embed_icc: false, embed_metadata: false, ..lossy(85) }).unwrap();
assert_eq!(&bytes[12..16], b"VP8 ");
assert_eq!(decode(&bytes).unwrap().layout(), ChannelLayout::Rgb);
}
#[test]
fn gray_and_odd_sizes_and_tiny_images() {
for (w, h) in [(1, 1), (2, 3), (17, 9), (33, 1), (1, 47), (16, 16), (31, 15)] {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
// Little noise: at these sizes the 2 × 2 chroma average alone would otherwise dominate.
let img = synth(w, h, layout, SampleType::U8, u64::from(w * 100 + h), 0.02);
let bytes = encode(&img, Format::WebP, &lossy(80)).unwrap_or_else(|e| panic!("{w}x{h} {layout:?}: {e}"));
let back = decode(&bytes).unwrap_or_else(|e| panic!("{w}x{h} {layout:?}: {e}"));
assert_eq!(back.dimensions(), (w, h));
// Below 8 px the 2 × 2 chroma average of random saturated pixels is the whole error.
if w >= 8 && h >= 8 {
let p = psnr(&img.convert(back.layout(), SampleType::U8), &back);
assert!(p > 24.0, "{w}x{h} {layout:?}: {p:.1} dB");
}
}
}
}
#[test]
fn sixteen_bit_input_is_quantised_to_eight() {
let img = synth(24, 24, ChannelLayout::Rgb, SampleType::U16, 2, 0.02);
let bytes = encode(&img, Format::WebP, &lossy(90)).unwrap();
let back = decode(&bytes).unwrap();
assert!(psnr(&img.convert(ChannelLayout::Rgb, SampleType::U8), &back) > 34.0);
}
#[test]
fn dimension_limit_is_an_error_not_a_panic() {
let img = Image::from_u8(16384, 1, ChannelLayout::Rgb, vec![0u8; 16384 * 3]).unwrap();
let e = encode(&img, Format::WebP, &lossy(50)).unwrap_err();
assert!(matches!(e, CodecError::Encode { .. }), "{e}");
let img = Image::from_u8(16383, 1, ChannelLayout::Rgb, vec![0u8; 16383 * 3]).unwrap();
let bytes = encode(&img, Format::WebP, &lossy(50)).unwrap();
assert_eq!(decode(&bytes).unwrap().dimensions(), (16383, 1));
}
#[test]
fn hard_edges_and_text_like_content_keep_their_structure() {
// Black text-like strokes on white: B_PRED sub-block modes keep edges sharp enough that the
// thresholded result matches the source almost everywhere.
let (w, h) = (128u32, 64u32);
let mut data = vec![255u8; (w * h * 3) as usize];
for y in 0..h {
for x in 0..w {
let stroke = (x % 11 < 2 && y > 8 && y < 56) || (y % 13 < 2 && x > 4 && x < 124);
if stroke {
let i = ((y * w + x) * 3) as usize;
data[i..i + 3].fill(0);
}
}
}
let img = Image::from_u8(w, h, ChannelLayout::Rgb, data).unwrap();
let bytes = encode(&img, Format::WebP, &lossy(90)).unwrap();
let back = decode(&bytes).unwrap();
let wrong = img.data().as_chunks::<3>().0.iter().zip(back.data().as_chunks::<3>().0).filter(|(a, b)| (a[0] < 128) != (b[0] < 128)).count();
let total = (w * h) as usize;
assert!(wrong * 100 < total * 2, "{wrong} of {total} pixels flipped side of mid-gray");
assert!(psnr(&img, &back) > 30.0, "{:.1}", psnr(&img, &back));
}