Files
photocraft/crates/codecs/tests/roundtrip.rs
Guillem Hermida 872748fb5b Codecs: save DDS textures, uncompressed or BC1–BC3, with optional mipmaps (#1327) (#5993)
DDS textures can now be saved, not just opened: uncompressed (BGRA, BGRX, L8, A8L8; lossless) or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, with an optional box-filtered mip chain, in the legacy header. Save As and Save a Copy ask with DDS Options; scripts pass `ddsCompression`/`ddsMipmaps` (file commands, control `app.save`, RPC `doc.save`, MCP `doc_save`/`doc_export`), and the CLI takes `--dds-compression`/`--dds-mipmaps`. Targa and DDS answers travel together as `FormatChoices` through the Save As flow.

Review follow-ups:
- File › Save behind DDS or Targa Options records a plain Save with its answer, and replaying `file.save` applies it (#3531).
- The block encoder finds the principal axis from every channel, so a channel that varies on its own can't hide the others' variation.
- DDS joins the shared round-trip suite; the header is built before the output buffer is allocated.
- Enter on a focused Cancel in DDS, Targa or TIFF Options cancels instead of saving.
2026-10-20 11:02:39 -06:00

290 lines
11 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.
//! 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.
mod 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()));
}
#[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:?}");
}
}