Files
photocraft/crates/codecs/tests/roundtrip.rs
T
a35af4e279 Add FITS read/write (astronomy images) (#2956)
* Add FITS read/write codec

FITS (the astronomy format) opens and saves as a flat image: the primary
HDU or first IMAGE extension; gray, or RGB when NAXIS3 = 3; BITPIX 8/16/
32/64/-32/-64 with BSCALE/BZERO/BLANK; rows follow ROWORDER (bottom-up by
default). Camera U16 data stays exact; float data is normalised to 0..1.
Header cards round-trip as text metadata (HIERARCH for long names).
Tile-compressed (fpack) files are refused as unsupported.

Wired into File > Open, Save As and the batch commands as .fits/.fit/.fts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test FITS open and save through photocraft-io

Gray 16-bit, RGB 8-bit and RGB 32-bit float documents round-trip exactly,
and header cards travel as the document's text metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* FITS: hostile-header regression test (absurd axes, bad BITPIX, no END, HDU chains, overflowing sizes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:15:22 -04:00

276 lines
10 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.
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 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();
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:?}");
}
}