Files
photocraft/crates/codecs/tests/it/roundtrip.rs
T
aiongg a6428d3dfb Build each crate's integration tests as one binary
Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs; helper modules under tests/common move with them.

These keep their own binary (own process):
- engine panic_hunt: a process-wide panic hook counts every panic.
- engine text_gamma: changes the process-wide text gamma.
- engine smart_filter_stages, mask_cache_purge: assert on global caches.
- codecs pnm_peak_memory: reads the process's peak RSS.
- photocraft mac_menu_appkit: harness = false.
2026-10-22 16:55:34 +00:00

332 lines
14 KiB
Rust
Raw 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.
//! Round-trip every writable format × every sample type × every layout.
//! Lossless formats must reproduce the (planned) conversion of the input
//! exactly; lossy formats must stay above a PSNR threshold.
use crate::common;
use common::*;
use photocraft_codecs::*;
/// Minimum PSNR (dB) for lossy formats at default options.
fn psnr_threshold(format: Format) -> f64 {
match format {
Format::Jpeg => 30.0,
Format::Gif => 20.0,
Format::Hdr => 40.0,
Format::Avif => 28.0,
_ => f64::INFINITY,
}
}
fn check_roundtrip(format: Format, layout: ChannelLayout, sample: SampleType) {
let c = caps(format);
if !c.write {
return;
}
// GIF quantizes; keep its input smooth so the threshold is meaningful.
let img = if format == Format::Gif {
synth(37, 23, layout, sample, 7, 0.0)
} else if c.lossy {
synth(37, 23, layout, sample, 7, 0.02)
} else {
test_image(layout, sample)
};
let opts = EncodeOptions::default();
let bytes = encode(&img, format, &opts).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?}: {e}"));
assert_eq!(detect(&bytes), Some(format), "detect after encode");
if !c.read {
return;
}
let back = decode(&bytes).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?} decode: {e}"));
assert_eq!(back.dimensions(), img.dimensions());
// Native storage must be preserved when the format declares support.
let float_pnm_special = format == Format::Pnm && sample.is_float();
if c.layouts.contains(&layout) && c.depths.contains(&sample) && !float_pnm_special {
assert_eq!(back.layout(), layout, "{format:?} must keep layout");
assert_eq!(back.sample_type(), sample, "{format:?} must keep sample type");
}
if c.depths.contains(&sample) && !float_pnm_special {
assert_eq!(back.sample_type(), sample, "{format:?} must keep supported depth");
}
// Integer sources never lose precision in formats offering that depth.
if sample == SampleType::U16 && c.depths.contains(&SampleType::U16) {
assert!(matches!(back.sample_type(), SampleType::U16 | SampleType::F32));
}
let expected = img.convert(back.layout(), back.sample_type());
let mut expected_cmp = expected.clone();
if format == Format::Gif && layout.has_alpha() {
// GIF alpha is binary: compare colour only on opaque pixels.
let ch = back.layout().channels();
let mut e = expected.to_normalized();
let b = back.to_normalized();
for (pe, pb) in e.chunks_mut(ch).zip(b.chunks(ch)) {
if pb[ch - 1] == 0.0 {
pe.copy_from_slice(pb);
}
pe[ch - 1] = pb[ch - 1];
}
expected_cmp = Image::from_normalized(img.width(), img.height(), back.layout(), back.sample_type(), &e).unwrap();
}
if c.lossy {
let p = psnr(&expected_cmp, &back);
assert!(p >= psnr_threshold(format), "{format:?} {layout:?} {sample:?}: PSNR {p:.2} dB");
} else {
assert_eq!(
expected_cmp.data(),
back.data(),
"{format:?} {layout:?} {sample:?}: lossless round-trip mismatch (max diff {})",
max_abs_diff(&expected_cmp, &back)
);
}
}
macro_rules! cases {
($fmt:expr) => {
use super::*;
cases!(@ $fmt;
gray_u8: Gray, U8; gray_u16: Gray, U16; gray_f16: Gray, F16; gray_f32: Gray, F32;
graya_u8: GrayA, U8; graya_u16: GrayA, U16; graya_f16: GrayA, F16; graya_f32: GrayA, F32;
rgb_u8: Rgb, U8; rgb_u16: Rgb, U16; rgb_f16: Rgb, F16; rgb_f32: Rgb, F32;
rgba_u8: Rgba, U8; rgba_u16: Rgba, U16; rgba_f16: Rgba, F16; rgba_f32: Rgba, F32;
cmyk_u8: Cmyk, U8; cmyk_u16: Cmyk, U16; cmyk_f16: Cmyk, F16; cmyk_f32: Cmyk, F32;
cmyka_u8: CmykA, U8; cmyka_u16: CmykA, U16; cmyka_f16: CmykA, F16; cmyka_f32: CmykA, F32
);
};
(@ $fmt:expr; $($name:ident : $l:ident, $s:ident);*) => {
$(
#[test]
fn $name() {
check_roundtrip($fmt, ChannelLayout::$l, SampleType::$s);
}
)*
};
}
mod png {
cases!(Format::Png);
}
mod jpeg {
cases!(Format::Jpeg);
}
mod tiff {
cases!(Format::Tiff);
}
mod webp {
cases!(Format::WebP);
}
mod gif {
cases!(Format::Gif);
}
mod bmp {
cases!(Format::Bmp);
}
mod tga {
cases!(Format::Tga);
}
mod ico {
cases!(Format::Ico);
}
mod pnm {
cases!(Format::Pnm);
}
mod fits {
cases!(Format::Fits);
}
mod dpx {
cases!(Format::Dpx);
}
mod dds {
cases!(Format::Dds);
}
mod qoi {
cases!(Format::Qoi);
}
mod exr {
cases!(Format::OpenExr);
}
mod hdr {
cases!(Format::Hdr);
}
// ---------------------------------------------------------------------------
// Targeted round-trips beyond the matrix
// ---------------------------------------------------------------------------
/// Float formats must keep HDR values (> 1, and negative where allowed).
#[test]
fn hdr_values_survive_float_formats() {
let vals: Vec<f32> = (0..16 * 8 * 3).map(|i| (i as f32 - 40.0) * 0.37).collect();
let img = Image::from_f32(16, 8, ChannelLayout::Rgb, &vals).unwrap();
for f in [Format::Tiff, Format::OpenExr, Format::Pnm] {
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.to_f32_samples().unwrap(), vals, "{f:?}");
}
}
#[test]
fn hdr_format_keeps_bright_values_approximately() {
let vals: Vec<f32> = (0..16 * 8 * 3).map(|i| 0.01 + i as f32 * 0.25).collect();
let img = Image::from_f32(16, 8, ChannelLayout::Rgb, &vals).unwrap();
let back = decode(&encode(&img, Format::Hdr, &EncodeOptions::default()).unwrap()).unwrap();
// RGBE shares one exponent per pixel: error is relative to the brightest channel.
let got = back.to_f32_samples().unwrap();
for (pa, pb) in vals.chunks(3).zip(got.chunks(3)) {
let m = pa.iter().copied().fold(0.0, f32::max);
for (a, b) in pa.iter().zip(pb) {
assert!((a - b).abs() <= m * 0.01, "{a} vs {b}");
}
}
}
#[test]
fn f16_exr_is_bit_exact() {
let vals: Vec<f16> = (0..20 * 10 * 4).map(|i| f16::from_f32(i as f32 * 0.013 - 1.0)).collect();
let img = Image::from_f16(20, 10, ChannelLayout::Rgba, &vals).unwrap();
for comp in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] {
let opts = EncodeOptions { exr_compression: comp, ..Default::default() };
let back = decode(&encode(&img, Format::OpenExr, &opts).unwrap()).unwrap();
assert_eq!(back.to_f16_samples().unwrap(), vals, "{comp:?}");
}
}
#[test]
fn tiff_all_compressions_lossless() {
for layout in ChannelLayout::ALL {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let img = test_image(layout, sample);
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
let opts = EncodeOptions { tiff_compression: comp, ..Default::default() };
let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap();
if layout.is_lab() && sample != SampleType::U8 {
// CIELab holds a* and b* as 256·a at 16 bits and has no float form: 16-bit and
// float Lab come back within one 16-bit step (the PSD's 16-bit Lab does the same).
let (a, b) = (img.to_normalized(), back.convert(layout, sample).to_normalized());
let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.5 / 65535.0, "{layout:?} {sample:?} {comp:?}: {worst}");
continue;
}
assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}");
}
}
}
}
#[test]
fn png_all_compressions_lossless() {
let img = test_image(ChannelLayout::Rgba, SampleType::U16);
for comp in [PngCompression::None, PngCompression::Fast, PngCompression::Default, PngCompression::Best] {
let opts = EncodeOptions { png_compression: comp, ..Default::default() };
let back = decode(&encode(&img, Format::Png, &opts).unwrap()).unwrap();
assert_eq!(back.data(), img.data(), "{comp:?}");
}
}
#[test]
fn jpeg_quality_affects_size_and_error() {
let img = test_image(ChannelLayout::Rgb, SampleType::U8);
let lo = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 20, ..Default::default() }).unwrap();
let hi = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 98, ..Default::default() }).unwrap();
assert!(lo.len() < hi.len());
let plo = psnr(&img, &decode(&lo).unwrap());
let phi = psnr(&img, &decode(&hi).unwrap());
assert!(phi > plo, "{phi} <= {plo}");
}
#[test]
fn jpeg_444_beats_420_on_chroma_detail() {
let img = synth(64, 64, ChannelLayout::Rgb, SampleType::U8, 3, 0.5);
let a = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: false, jpeg_quality: 95, ..Default::default() }).unwrap();
let b = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, jpeg_quality: 95, ..Default::default() }).unwrap();
assert!(psnr(&img, &decode(&a).unwrap()) > psnr(&img, &decode(&b).unwrap()));
}
/// JPEG Options › Format Options: each scan layout decodes back to the same image, Progressive
/// writes a progressive frame (SOF2), and Baseline Optimized fits its Huffman tables (smaller at
/// 4:4:4; at 4:2:0 it keeps the standard tables, see `JpegFormat::Optimized`).
#[test]
fn jpeg_format_options_round_trip() {
let img = synth(64, 48, ChannelLayout::Rgb, SampleType::U8, 3, 0.5);
let sof = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
for subsampled in [false, true] {
let enc = |f| {
encode(&img, Format::Jpeg, &EncodeOptions { jpeg_format: f, jpeg_chroma_subsampling: subsampled, jpeg_quality: 90, ..Default::default() }).unwrap()
};
let (base, opt, prog) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized), enc(JpegFormat::Progressive));
// The scan layout only changes how the same quantized coefficients are stored, so every
// format decodes as faithfully as Baseline.
let reference = psnr(&img, &decode(&base).unwrap());
for (name, b) in [("optimized", &opt), ("progressive", &prog)] {
let back = decode(b).unwrap();
assert_eq!(back.dimensions(), img.dimensions(), "{name}");
let p = psnr(&img, &back);
assert!((p - reference).abs() < 0.5, "{name}, subsampled {subsampled}: {p} vs baseline {reference}");
}
assert!(sof(&base, 0xC0) && !sof(&base, 0xC2));
assert!(sof(&prog, 0xC2), "progressive frame");
if subsampled {
assert_eq!(opt, base, "4:2:0 keeps the standard tables");
} else {
assert!(opt.len() < base.len(), "fitted tables are smaller: {} vs {}", opt.len(), base.len());
}
}
// Gray is 4:4:4 too, so Baseline Optimized shrinks it.
let gray = synth(64, 48, ChannelLayout::Gray, SampleType::U8, 3, 0.5);
let enc = |f| encode(&gray, Format::Jpeg, &EncodeOptions { jpeg_format: f, ..Default::default() }).unwrap();
let (base, opt) = (enc(JpegFormat::Baseline), enc(JpegFormat::Optimized));
assert!(opt.len() < base.len(), "gray: {} vs {}", opt.len(), base.len());
assert!((psnr(&gray, &decode(&opt).unwrap()) - psnr(&gray, &decode(&base).unwrap())).abs() < 0.5);
assert_eq!(JpegFormat::parse("progressive"), Some(JpegFormat::Progressive));
assert_eq!(JpegFormat::parse(JpegFormat::Optimized.name()), Some(JpegFormat::Optimized));
for bad in ["Progressive", "", "standard"] {
assert_eq!(JpegFormat::parse(bad), None, "{bad}");
}
}
#[test]
fn webp_lossy_request_is_a_lossy_file() {
let img = test_image(ChannelLayout::Rgb, SampleType::U8);
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap();
assert_eq!(&bytes[12..16], b"VP8 ", "a bare lossy frame");
let back = decode(&bytes).unwrap();
assert_eq!(back.dimensions(), img.dimensions());
assert!(
fidelity_warnings_with(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).contains(&FidelityWarning::LossyCompression)
);
}
#[test]
fn one_pixel_images_roundtrip() {
for f in rw_formats() {
let img = synth(1, 1, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.dimensions(), (1, 1), "{f:?}");
}
}
#[test]
fn ico_rejects_oversize() {
let img = Image::new(300, 10, ChannelLayout::Rgba, SampleType::U8).unwrap();
assert!(matches!(encode(&img, Format::Ico, &EncodeOptions::default()), Err(CodecError::Encode { .. })));
let img = Image::new(256, 256, ChannelLayout::Rgba, SampleType::U8).unwrap();
assert!(encode(&img, Format::Ico, &EncodeOptions::default()).is_ok());
}
#[test]
fn empty_image_rejected() {
let img = Image::new(0, 5, ChannelLayout::Rgb, SampleType::U8).unwrap();
for f in writable_formats() {
assert!(encode(&img, f, &EncodeOptions::default()).is_err(), "{f:?}");
}
}
#[test]
fn larger_image_png_tiff_exact() {
let img = synth(513, 257, ChannelLayout::Rgba, SampleType::U16, 99, 1.0);
for f in [Format::Png, Format::Tiff, Format::Pnm] {
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.data(), img.data(), "{f:?}");
}
}