mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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.
332 lines
14 KiB
Rust
332 lines
14 KiB
Rust
//! 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:?}");
|
||
}
|
||
}
|