Files
photocraft/crates/codecs/tests/common/mod.rs
ec7302f4b3 TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8, part of #215) (#859)
* TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8)

Large and multi-page TIFFs now open the way Photoshop opens them, quickly and without a second
full-size buffer, and any directory of the file can be opened on request.

* photocraft-codecs `tiff_ifd`: a bounds-checked directory walker for classic TIFF and BigTIFF
  (LONG8 / IFD8, both byte orders). It follows the whole IFD chain and the SubIFD trees (tag
  330) with a seen-set, a depth limit and a 10 000-directory cap, and checks every value range
  against the file. `tiff_info` lists the pages (size, kind: page / reduced-resolution / mask,
  parent, storage); `default_page` is the first full-resolution page of the main chain, so a
  leading thumbnail is skipped. `decode_tiff_page`, `tiff_orientation`, `tiff_page_orientation`.
* Banded decode: the `tiff` crate reads the chosen directory through a patched byte view (header
  -> that IFD, its next pointer -> 0, WhiteIsZero / palette -> BlackIsZero), so SubIFDs and
  cyclic chains decode. The image is allocated once (fallibly, zeroed in parallel) and each
  strip or row of tiles, across every plane, is decoded straight into it, in parallel on native
  targets, with one capped chunk-sized scratch buffer per worker. New: planar configuration 2
  (was an error), palette images (as RGB), 64-bit integers, associated alpha made straight,
  non-alpha extra samples dropped. Image data cut off by the end of the file decodes with a
  `Truncated` warning. Before the crate sees a file: structural tag values must lie inside it,
  the chunk tables must match the geometry (checked in 64 bits; the crate overflowed and
  panicked on `(h / rows + 1) * planes`), and a decompression-bomb guard refuses files far too
  small for their declared size.
* WhiteIsZero and BigTIFF Orientation keep upstream #746's behaviour (#691, #693) through the
  new decode path; regression tests cover both.
* Encode: `EncodeOptions::tiff_bigtiff` writes BigTIFF; without it, BigTIFF is chosen when the
  file could pass 4 GiB (and a classic write that overflows 32-bit offsets is redone as BigTIFF).
* photocraft-io: `import_tiff_page(name, bytes, page)`; the TIFF branch of import goes through it.
* Tests: codecs `tiff_pages.rs` on hand-built files (`tests/common/tiffgen.rs`): BigTIFF LE/BE at
  8/16/32f, padded tiles and planar strips/tiles, pages and SubIFDs, cycles, the IFD cap,
  truncated and past-EOF strips, huge dimensions / counts / tiles, WhiteIsZero, palette, wide
  formats, BigTIFF write, and a cut / bit-flip / random-mutation zoo (an ignored long run did
  200 000 mutations without a panic); io `tiff_pages.rs`. The `decode_tiff` fuzz target walks
  every page. `tiff_bench.rs` (ignored) times a 30 MP decode against the previous path.
* 30 MP RGB (release, 16 threads, same run): Deflate 344 -> 32 ms, LZW 428 -> 43 ms, 16-bit
  Deflate 497 -> 46 ms, uncompressed 15 -> 10 ms, 256 px tiles 36 -> 10 ms, planar error -> 12 ms.
* Scorecard FILE-215-8 done; README and roadmap updated.

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

* rustfmt the TIFF reader and tests

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 22:37:51 -04:00

123 lines
4.3 KiB
Rust

//! Shared helpers for integration tests: deterministic PRNG, synthetic
//! image generators, comparison metrics.
#![allow(dead_code)]
pub mod tiffgen;
use photocraft_codecs::*;
/// SplitMix64 — tiny deterministic PRNG.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Rng(seed)
}
pub fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform in [0, 1).
pub fn f32(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
}
pub fn bytes(&mut self, n: usize) -> Vec<u8> {
(0..n).map(|_| self.next_u64() as u8).collect()
}
}
/// Gradient + noise image with values in [0, 1]. `noise` is the amplitude
/// of uniform noise added to the gradient. Alpha is a separate gradient
/// that includes fully transparent and fully opaque regions.
pub fn synth(w: u32, h: u32, layout: ChannelLayout, sample: SampleType, seed: u64, noise: f32) -> Image {
let mut rng = Rng::new(seed);
let nc = layout.channels();
let mut v = Vec::with_capacity((w * h) as usize * nc);
for y in 0..h {
for x in 0..w {
let fx = x as f32 / (w.max(2) - 1) as f32;
let fy = y as f32 / (h.max(2) - 1) as f32;
for c in 0..nc {
let is_alpha = layout.has_alpha() && c == nc - 1;
let base = if is_alpha {
((fx + fy) * 0.75 - 0.25).clamp(0.0, 1.0)
} else {
match c % 4 {
0 => fx,
1 => fy,
2 => 1.0 - fx * 0.5 - fy * 0.5,
_ => (fx * fy).sqrt(),
}
};
let n = (rng.f32() - 0.5) * noise;
v.push((base + n).clamp(0.0, 1.0));
}
}
}
Image::from_normalized(w, h, layout, sample, &v).unwrap()
}
/// Default test image: 37x23 with moderate noise.
pub fn test_image(layout: ChannelLayout, sample: SampleType) -> Image {
synth(37, 23, layout, sample, 0xC0DEC, 0.2)
}
/// PSNR in dB over normalized samples (clamped to [0,1]).
pub fn psnr(a: &Image, b: &Image) -> f64 {
assert_eq!(a.dimensions(), b.dimensions());
assert_eq!(a.layout(), b.layout());
let (av, bv) = (a.to_normalized(), b.to_normalized());
let mse: f64 = av
.iter()
.zip(&bv)
.map(|(x, y)| {
let d = (x.clamp(0.0, 1.0) - y.clamp(0.0, 1.0)) as f64;
d * d
})
.sum::<f64>()
/ av.len() as f64;
if mse == 0.0 { f64::INFINITY } else { 10.0 * (1.0 / mse).log10() }
}
pub fn max_abs_diff(a: &Image, b: &Image) -> f32 {
a.to_normalized().iter().zip(b.to_normalized()).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
}
/// Formats that can be written in this build.
pub fn writable_formats() -> Vec<Format> {
Format::ALL.into_iter().filter(|f| caps(*f).write).collect()
}
/// Formats that can be written *and* read in this build.
pub fn rw_formats() -> Vec<Format> {
Format::ALL.into_iter().filter(|f| caps(*f).write && caps(*f).read).collect()
}
/// A minimal valid little-endian TIFF-structured EXIF blob (one IFD with
/// an Orientation entry).
pub fn sample_exif() -> Vec<u8> {
let mut v = b"II*\0\x08\0\0\0".to_vec();
v.extend_from_slice(&1u16.to_le_bytes()); // 1 entry
v.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation
v.extend_from_slice(&3u16.to_le_bytes()); // SHORT
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes()); // next IFD
v
}
/// Fake ICC profile of `len` bytes (header-ish prefix + deterministic noise).
pub fn sample_icc(len: usize) -> Vec<u8> {
let mut v = Rng::new(len as u64).bytes(len);
v[..4].copy_from_slice(&(len as u32).to_be_bytes());
if len >= 40 {
v[36..40].copy_from_slice(b"acsp");
}
v
}
pub const SAMPLE_XMP: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"/></x:xmpmeta><?xpacket end=\"w\"?>";