Files
photocraft/crates/codecs/tests/tiff_bench.rs
T
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

108 lines
4.5 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.
//! TIFF decode timings on a synthetic ~30 MP image (6336×4752). Ignored by default; run in
//! release:
//!
//! ```sh
//! cargo test --release -p photocraft-codecs --test tiff_bench -- --ignored --nocapture
//! ```
mod common;
use common::tiffgen::*;
use photocraft_codecs::*;
use std::time::Instant;
const W: u32 = 6336;
const H: u32 = 4752;
fn pixels(spp: usize) -> Vec<u8> {
// A smooth gradient with a little texture: compresses like a photo, not like noise.
let mut v = Vec::with_capacity(W as usize * H as usize * spp);
let mut s = 0x1234_5678u32;
for y in 0..H {
for x in 0..W {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
for c in 0..spp {
let g = (x * (c as u32 + 1) / 25 + y / 19) as u8;
v.push(g.wrapping_add((s >> (8 * c)) as u8 & 3));
}
}
}
v
}
/// The decoder before FILE-215-8, for a same-run comparison: the `tiff` crate decodes the whole
/// image into one buffer (`read_image`, first plane only), which is then copied into an `Image`.
fn legacy(bytes: &[u8]) -> Result<Image, String> {
use tiff::decoder::{Decoder, DecodingResult, Limits as TLimits};
let mut l = TLimits::default();
l.decoding_buffer_size = 8 << 30;
l.intermediate_buffer_size = 1 << 30;
let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| e.to_string())?.with_limits(l);
let (w, h) = dec.dimensions().map_err(|e| e.to_string())?;
let layout = match dec.colortype().map_err(|e| e.to_string())? {
tiff::ColorType::RGB(_) => ChannelLayout::Rgb,
tiff::ColorType::Gray(_) => ChannelLayout::Gray,
c => return Err(format!("{c:?}")),
};
match dec.read_image().map_err(|e| e.to_string())? {
DecodingResult::U8(v) => Image::from_u8(w, h, layout, v),
DecodingResult::U16(v) => Image::from_u16(w, h, layout, &v),
_ => return Err("sample type".into()),
}
.map_err(|e| e.to_string())
}
fn best_of(f: impl Fn() -> Result<Image, String>) -> Result<f64, String> {
let mut best = f64::MAX;
for _ in 0..7 {
let t = Instant::now();
f()?;
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
}
Ok(best)
}
fn time(name: &str, bytes: &[u8]) {
let new = best_of(|| decode(bytes).map_err(|e| e.to_string()));
let old = best_of(|| legacy(bytes));
let show = |r: &Result<f64, String>| match r {
Ok(ms) => format!("{ms:>7.1} ms"),
Err(e) => format!("error ({})", e.chars().take(48).collect::<String>()),
};
println!("{name:<34} new {} before {} ({:.1} MB file)", show(&new), show(&old), bytes.len() as f64 / 1e6);
}
#[test]
#[ignore = "benchmark: run in release with --ignored --nocapture"]
fn decode_30mp() {
let rgb = pixels(3);
let img = Image::from_u8(W, H, ChannelLayout::Rgb, rgb.clone()).unwrap();
for (name, c) in
[("strips, none", TiffCompression::None), ("strips, LZW + predictor", TiffCompression::Lzw), ("strips, Deflate + predictor", TiffCompression::Deflate)]
{
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: c, ..Default::default() }).unwrap();
time(&format!("RGB 8 {name}"), &bytes);
}
let img16 = img.convert(ChannelLayout::Rgb, SampleType::U16);
let bytes = encode(&img16, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::Deflate, ..Default::default() }).unwrap();
time("RGB 16 strips, Deflate + predictor", &bytes);
let bytes = encode(&img16, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap();
time("RGB 16 strips, none", &bytes);
// Hand-built layouts the encoder does not write: one big strip, tiles, planar.
let le = cfg!(target_endian = "little");
let one = build(le, false, &[strips(W, H, 8, 3, 2, H, &rgb)], &[0]);
time("RGB 8 one strip, none", &one.bytes);
let tiles = tile_split(&rgb, W as usize, H as usize, 3, 1, 256, 256, false);
let mut d = Dir { entries: base(W, H, 8, 3, 2), chunks: tiles, tiled: true };
d = d.tag(322, LONG, &[256]).tag(323, LONG, &[256]);
time("RGB 8 tiles 256, none", &build(le, false, &[d], &[0]).bytes);
let planes = planar_strips(&rgb, W as usize, H as usize, 3, 1, 64);
let mut d = Dir { entries: base(W, H, 8, 3, 2), chunks: planes, tiled: false };
d = d.tag(278, LONG, &[64]).tag(284, SHORT, &[2]);
time("RGB 8 planar strips, none", &build(le, false, &[d], &[0]).bytes);
let d = strips(W, H, 8, 3, 2, 64, &rgb);
time("RGB 8 BigTIFF strips, none", &build(le, true, &[d], &[0]).bytes);
}