codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured (#642)

* codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured

Deep EXR files errored with "not supported: deep data not supported yet"
(the `exr` crate rejects deep headers, and its chunk framing for them is an
unfinished guess), so the chunk stream is walked directly from the bytes, per
the OpenEXR file-layout specification:

- offset tables after the headers; deep chunks of [y | tile coords, packed
  table size, packed data size, unpacked data size, compressed cumulative
  sample-count table, compressed sample data],
- NONE, RLE and ZIPS (zlib, the +128 difference predictor and the half-split
  interleave - applied to the count table and the sample data alike; a stored
  payload of uncompressed length is raw). ZIP-16 and mip/rip-map deep tiles
  decline with `unsupported`,
- samples are channel-grouped, per pixel; u32 counts cap the allocation and
  go through `Limits` before anything is held in memory.

`decode` composites to the flat image (samples sorted by Z, front-to-back
`over` with premultiplied colour, un-premultiplied to straight alpha) and
reports a new DecodeWarning::DeepFlattened; `decode_deep_exr` returns the
structured DeepImage (per-channel samples plus cumulative counts) for callers
that composite deep data themselves. exr::Error::NotSupported now maps to
CodecError::unsupported instead of malformed, like the TIFF codec.

Validated against the OpenEXR repository's deep test images (11/42/64/objectid
.deep.exr: 256x256, up to 8 samples/pixel, plausible z and alpha), and covered
by a synthetic generator (NONE/RLE/ZIPS x scanline/tile) with hand-computed
compositing oracles, truncation sweeps and hostile offsets/sizes (13 tests).

* Deep EXR: match the OpenEXR layout (RLE tokens and predictor, per-row tile tables and data), keep HDR

Checked against deep files written by the OpenEXR 3.5 library (scan line and tiled,
NONE/RLE/ZIPS): RLE tokens were inverted and skipped the predictor and byte split, the
count table of a tile restarts at every row, a tile's sample data is grouped per row, edge
tiles may store a full-size table, and the flattened colour was clamped to 1.0. The
synthetic writer in the tests now follows the same layout; new HDR / edge-tile test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-07 16:45:02 -04:00
1 parent 3f637410cb
commit 107ffe9127
6 files changed
+1286 -3

No files matched your search

+568
View File
@@ -0,0 +1,568 @@
//! Deep OpenEXR (`deepscanline` / `deeptile`): a variable-length list of samples per pixel,
//! each with its own colour, alpha and depth. The `exr` crate rejects deep headers, so the
//! chunk stream is walked directly from the bytes, per the OpenEXR file-layout specification:
//! a deep chunk carries its coordinates, the packed size of the sample-count table, the
//! packed and unpacked sizes of the sample data, then the compressed count table (cumulative
//! within each row of the block) and the compressed sample data (row by row, each row channel
//! by channel). The layout was checked against files written by the OpenEXR library. Deep data permits only NONE, RLE, ZIPS and ZIP
//! compression; we support the first three (one scan line per block, which keeps the count
//! table unambiguous) and decline ZIP with `unsupported`.
//!
//! [`decode_deep`] returns the structured samples ([`DeepImage`]); [`flatten`] composites
//! them into a flat `Image` — samples sorted by Z, front-to-back `over` with premultiplied
//! colour, un-premultiplied into straight alpha (HDR values are kept, not clamped) — because the flat document model has no
//! per-pixel depth.
use std::io::{Cursor, Read, Seek, SeekFrom};
use exr::meta::attribute::{LevelMode, SampleType as ExrSample};
use exr::meta::{BlockDescription, MetaData};
use exr::prelude::Compression;
use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
use crate::options::Limits;
const F: Format = Format::OpenExr;
fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
fn unsup(reason: impl Into<String>) -> CodecError {
CodecError::unsupported(F, reason)
}
// --- checked little-endian reads -------------------------------------------------------------
// Every length and offset comes from the file, so every read is bounds-checked and a short
// read is a malformed file, never a panic.
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut v = vec![0u8; n];
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
Ok(v)
}
fn i32(cur: &mut Cursor<&[u8]>) -> Result<i32, CodecError> {
let b = read_exact(cur, 4, "an i32")?;
Ok(i32::from_le_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)]))
}
fn u64(cur: &mut Cursor<&[u8]>) -> Result<u64, CodecError> {
let b = read_exact(cur, 8, "a u64")?;
let a: [u8; 8] = b.try_into().unwrap_or([0; 8]);
Ok(u64::from_le_bytes(a))
}
fn seek_to(cur: &mut Cursor<&[u8]>, pos: u64) -> Result<(), CodecError> {
if pos > cur.get_ref().len() as u64 {
return Err(err(format!("chunk offset {pos} points past the end of the file")));
}
cur.seek(SeekFrom::Start(pos)).map_err(err)?;
Ok(())
}
// --- decompression ----------------------------------------------------------------------------
/// Undo the ZIP predictor: each byte was stored as its difference to the previous byte plus
/// 128 (so a zero difference is 0x80), modulo 256. Applies to the sample-count table and the
/// sample data alike.
fn differences_to_samples(buf: &mut [u8]) {
if let Some(&first) = buf.first() {
let mut prev = i16::from(first);
for b in buf.iter_mut().skip(1) {
let s = (prev + i16::from(*b) - 128) as u8;
prev = i16::from(s);
*b = s;
}
}
}
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
/// holding every second byte of the original; weave them back together.
fn interleave_byte_blocks(buf: &mut [u8]) {
let half = buf.len().div_ceil(2);
let mut out = vec![0u8; buf.len()];
let mut k = 0usize;
for i in 0..buf.len() / 2 {
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
if let (Some(a), Some(b), Some(pair)) = (a, b, out.get_mut(k..k + 2)) {
if let Some(byte) = pair.first_mut() {
*byte = a;
}
if let Some(byte) = pair.get_mut(1) {
*byte = b;
}
}
k += 2;
}
if buf.len() % 2 == 1 && half > 0 {
let last = buf.get(half - 1).copied();
if let (Some(last), Some(slot)) = (last, out.get_mut(k)) {
*slot = last;
}
}
buf.copy_from_slice(&out);
}
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut out = Vec::with_capacity(expected.min(1 << 20));
let mut i = 0;
while i < src.len() {
let count = src[i] as i8;
i += 1;
let n = if count < 0 { (-(count as i32)) as usize } else { count as usize + 1 };
if out.len().saturating_add(n) > expected {
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
}
if count < 0 {
let end = i.saturating_add(n);
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
if run.len() != n {
return Err(err(format!("{what}: RLE run points past the compressed data")));
}
out.extend_from_slice(run);
i = end;
} else {
let b = src.get(i).copied().ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
i += 1;
out.resize(out.len() + n, b);
}
}
if out.len() != expected {
return Err(err(format!("{what}: RLE data expands to {} bytes, expected {expected}", out.len())));
}
Ok(out)
}
/// Decompress `what` to exactly `expected` bytes. Like flat blocks, a payload that already
/// has the uncompressed length is stored raw whatever the declared compression.
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
if packed.len() == expected {
return Ok(packed.to_vec());
}
match compression {
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
Compression::RLE => {
// Like ZIPS, RLE runs over the predicted, byte-split stream.
let mut out = rle_decode(packed, expected, what)?;
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
Ok(out)
}
Compression::ZIP1 => {
let mut out = Vec::with_capacity(expected.min(1 << 20));
flate2::read::ZlibDecoder::new(packed)
.take(expected as u64 + 1)
.read_to_end(&mut out)
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
if out.len() != expected {
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
}
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
Ok(out)
}
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
}
}
// --- the deep chunk stream ---------------------------------------------------------------------
/// One parsed chunk: where its pixels go and its cumulative sample counts, plus where to
/// find the compressed sample data in the second pass.
struct DeepChunk {
/// Global pixel indices of the block's pixels, in block order (row-major within the block).
pixels: Vec<usize>,
/// Pixels per row of the block (the data window width for a scan line, the clipped tile
/// width for a tile).
row_w: usize,
/// Cumulative sample count table of the block: `len == pixels.len() + 1`.
prefix: Vec<u32>,
/// File offset of the chunk start.
pos: u64,
/// Size of the compressed sample data in the file.
packed_data: u64,
/// The decompressed sample-data size the file declares (validated against the table).
unpacked_data: u64,
}
fn sample_bytes(t: ExrSample) -> usize {
match t {
ExrSample::U32 | ExrSample::F32 => 4,
ExrSample::F16 => 2,
}
}
fn our_sample_type(t: ExrSample) -> SampleType {
match t {
ExrSample::F16 => SampleType::F16,
ExrSample::U32 | ExrSample::F32 => SampleType::F32,
}
}
/// Read the deep samples of the first deep part. `meta` must come from an unvalidated
/// [`MetaData::read_from_buffered`] of the same bytes (deep headers fail validation).
pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
let header_index = meta.headers.iter().position(|h| h.deep).ok_or_else(|| unsup("the file has no deep part"))?;
let header = meta.headers.get(header_index).ok_or_else(|| unsup("the file has no deep part"))?;
let compression = header.compression;
if !matches!(compression, Compression::Uncompressed | Compression::RLE | Compression::ZIP1) {
return Err(unsup(format!("deep data with {compression:?} compression (NONE, RLE and ZIPS are supported)")));
}
let dw = header.data_window();
let (w, h) = (dw.size.0, dw.size.1);
if w == 0 || h == 0 {
return Err(err("the data window is empty"));
}
if w > u32::MAX as usize || h > u32::MAX as usize {
return Err(err("the data window is too large"));
}
let y0 = dw.position.1;
let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?;
limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?;
let exr_types: Vec<ExrSample> = header.channels.list.iter().map(|c| c.sample_type).collect();
let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum();
let multipart = meta.requirements.has_multiple_layers;
// The offset tables follow the headers: `chunk_count` u64 per header, in header order.
// Our part's table starts after the tables of all earlier parts.
let mut table_pos = header_end(bytes)?;
for h_ in meta.headers.iter().take(header_index) {
table_pos = table_pos.saturating_add((h_.chunk_count.min(1 << 24) as u64).saturating_mul(8));
}
let mut cur = Cursor::new(bytes);
seek_to(&mut cur, table_pos)?;
let mut offsets = Vec::with_capacity(header.chunk_count.min(1 << 20));
for _ in 0..header.chunk_count {
offsets.push(u64(&mut cur)?);
}
let tables_end = cur.position();
// Block geometry.
let (tiles_x, tile_w, tile_h, slots) = match &header.blocks {
BlockDescription::ScanLines => (0usize, w, 1usize, h), // one line per block for NONE/RLE/ZIPS
BlockDescription::Tiles(t) => {
if t.level_mode != LevelMode::Singular {
return Err(unsup("deep mip-map or rip-map tiles (single-level tiles are supported)"));
}
let (tw, th) = (t.tile_size.0.max(1), t.tile_size.1.max(1));
let tx = w.saturating_add(tw - 1) / tw;
let ty = h.saturating_add(th - 1) / th;
(tx, tw, th, tx.checked_mul(ty).ok_or_else(|| err("tile count overflows"))?)
}
};
let mut counts = vec![0u32; npixels];
let mut seen = vec![false; slots];
let mut chunks: Vec<DeepChunk> = Vec::with_capacity(offsets.len());
for &off in &offsets {
if off < tables_end || off >= bytes.len() as u64 {
return Err(err(format!("chunk offset {off} is outside the chunk area of the file")));
}
seek_to(&mut cur, off)?;
if multipart {
let part = i32(&mut cur)?;
if part < 0 || part as usize != header_index {
return Err(err(format!("chunk declares part {part}, expected {header_index}")));
}
}
// Block coordinates → the block's global pixel range and its slot.
let (block_pixels, slot, row_w): (Vec<usize>, usize, usize) = match &header.blocks {
BlockDescription::ScanLines => {
let y = i32(&mut cur)?;
let row = match y.checked_sub(y0) {
Some(d) if d >= 0 && (d as usize) < h => d as usize,
_ => return Err(err(format!("scan line block y={y} is outside the data window"))),
};
((0..w).map(|x| row * w + x).collect(), row, w)
}
BlockDescription::Tiles(_) => {
let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
if (lx, ly) != (0, 0) {
return Err(err(format!("deep tile at level ({lx},{ly}); only level 0 exists in a single-level file")));
}
if tx < 0 || ty < 0 || (tx as usize) >= tiles_x || (ty as usize) * tile_h >= h {
return Err(err(format!("tile ({tx},{ty}) is outside the data window")));
}
let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h);
let rows = (by + tile_h).min(h);
let cols = (bx + tile_w).min(w);
let mut px = Vec::with_capacity((rows - by) * (cols - bx));
for yy in by..rows {
for xx in bx..cols {
px.push(yy.saturating_mul(w).saturating_add(xx));
}
}
let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize;
(px, slot, cols - bx)
}
};
if seen.get(slot).copied().unwrap_or(true) {
return Err(err(format!("two chunks cover block {slot}")));
}
if let Some(s) = seen.get_mut(slot) {
*s = true;
}
let packed_table = u64(&mut cur)?;
let packed_data = u64(&mut cur)?;
let unpacked_data = u64(&mut cur)?;
let remaining = (bytes.len() as u64).saturating_sub(cur.position());
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
return Err(err("deep chunk sizes point past the end of the file"));
}
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
// An edge tile's table may still be stored at the full tile size (its pixels first, in
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
// The table is cumulative within each row of the block (a tile restarts at every row);
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
let mut prefix = Vec::with_capacity(block_pixels.len() + 1);
prefix.push(0u32);
let (mut row_base, mut prev) = (0u32, 0u32);
for (i, p) in block_pixels.iter().enumerate() {
let b: [u8; 4] = raw.get(i * 4..i * 4 + 4).and_then(|s| s.try_into().ok()).ok_or_else(|| err("the count table is short"))?;
let v = u32::from_le_bytes(b);
if row_w > 0 && i % row_w == 0 {
row_base = row_base.checked_add(prev).ok_or_else(|| err("the block's sample count overflows"))?;
prev = 0;
}
if v < prev {
return Err(err("the sample-count table decreases (malformed cumulative counts)"));
}
if let Some(c) = counts.get_mut(*p) {
*c = v - prev;
}
prev = v;
prefix.push(row_base.checked_add(v).ok_or_else(|| err("the block's sample count overflows"))?);
}
let total = prefix.last().copied().unwrap_or(0) as u64;
let expected_data = total.checked_mul(bytes_per_sample as u64).ok_or_else(|| err("the sample data size overflows"))?;
if expected_data != unpacked_data {
return Err(err(format!("the chunk declares {unpacked_data} bytes of sample data, the count table implies {expected_data}")));
}
chunks.push(DeepChunk { pixels: block_pixels, row_w, prefix, pos: off, packed_data, unpacked_data });
}
if seen.iter().any(|s| !s) {
let missing = seen.iter().filter(|s| !**s).count();
return Err(err(format!("{missing} of {slots} chunks are missing from the offset table")));
}
// Global cumulative counts in raster order.
let mut deep_counts = Vec::with_capacity(npixels + 1);
deep_counts.push(0u64);
let mut acc = 0u64;
for &c in &counts {
acc = acc.checked_add(c as u64).ok_or_else(|| err("the total sample count overflows"))?;
deep_counts.push(acc);
}
let total_samples = acc;
let sample_bytes_total = total_samples.checked_mul(exr_types.len() as u64 * 4).ok_or_else(|| err("the sample buffer size overflows"))?;
if sample_bytes_total > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!("deep samples need {sample_bytes_total} bytes, exceeding max_alloc {}", limits.max_alloc)));
}
let mut channels: Vec<DeepChannel> = header
.channels
.list
.iter()
.map(|c| DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: vec![0f32; total_samples as usize] })
.collect();
// Second pass: the compressed sample data, scattered per pixel (a block's channel data
// is contiguous in the file but the pixels' samples interleave with other blocks').
for chunk in &chunks {
seek_to(&mut cur, chunk.pos)?;
if multipart {
let _part = i32(&mut cur)?;
}
match &header.blocks {
BlockDescription::ScanLines => {
let _y = i32(&mut cur)?;
}
BlockDescription::Tiles(_) => {
let _coords = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
}
}
let packed_table = u64(&mut cur)?;
let _packed_data = u64(&mut cur)?;
let _unpacked_data = u64(&mut cur)?;
// The count table sits between the sizes and the sample data; skip its bytes.
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
// Row by row (a scan line block is one row), and within a row channel by channel, as
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
let mut in_off = 0usize;
let row_w = chunk.row_w.max(1);
for (r, row) in chunk.pixels.chunks(row_w).enumerate() {
let i0 = r * row_w;
let row_from = chunk.prefix.get(i0).copied().unwrap_or(0) as usize;
let row_to = chunk.prefix.get(i0 + row.len()).copied().unwrap_or(0) as usize;
let row_total = row_to.saturating_sub(row_from);
for (ci, t) in exr_types.iter().enumerate() {
let sb = sample_bytes(*t);
let ch_bytes = row_total.checked_mul(sb).ok_or_else(|| err("channel size overflows"))?;
let end = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?;
let src = raw.get(in_off..end).ok_or_else(|| err("the sample data is short"))?;
in_off = end;
let out = channels.get_mut(ci).ok_or_else(|| err("channel index out of range"))?;
for (j, p) in row.iter().enumerate() {
let from = (chunk.prefix.get(i0 + j).copied().unwrap_or(0) as usize).saturating_sub(row_from);
let n = (chunk.prefix.get(i0 + j + 1).copied().unwrap_or(0) as usize).saturating_sub(row_from).saturating_sub(from);
let to = deep_counts.get(*p).copied().unwrap_or(0) as usize;
for k in 0..n {
let base = (from + k) * sb;
let v = match t {
ExrSample::F16 => src.get(base..base + 2).map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32()),
ExrSample::F32 => src.get(base..base + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])),
ExrSample::U32 => src.get(base..base + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32),
};
let (Some(v), Some(slot)) = (v, out.samples.get_mut(to + k)) else {
return Err(err("the sample data is short"));
};
*slot = v;
}
}
}
}
}
Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts })
}
/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]
/// consumes exactly the version field and the headers, so re-reading with a fresh cursor
/// and taking its end position yields it.
fn header_end(bytes: &[u8]) -> Result<u64, CodecError> {
let mut cur = Cursor::new(bytes);
MetaData::read_from_buffered(&mut cur, false).map_err(err)?;
Ok(cur.position())
}
/// Composite the deep samples into a flat image: per pixel, samples sorted by Z (stable; the
/// file is assumed messy), front-to-back `over` with premultiplied colour, un-premultiplied
/// into straight alpha at the end. Without any alpha channel the front-most sample wins,
/// opaque. Z, ZBack and other deep channels influence the flat result only through the
/// ordering; volume samples (with ZBack) are approximated as point samples.
pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
let npx = deep.width as usize * deep.height as usize;
let has = |n: &str| deep.channel(n).is_some();
let rgb = has("R") && has("G") && has("B");
let gray = !rgb && (has("Y") || deep.channels.len() == 1);
let layout = if rgb || gray {
if has("A") || has("AR") || has("AG") || has("AB") {
if rgb { ChannelLayout::Rgba } else { ChannelLayout::GrayA }
} else if rgb {
ChannelLayout::Rgb
} else {
ChannelLayout::Gray
}
} else {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels in the deep part"));
};
let nc = layout.channels();
let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) };
let (r, g, b) = (samples("R"), samples("G"), samples("B"));
let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB"));
let (a, z) = (samples("A"), samples("Z"));
let y = samples("Y");
let lum = if y.is_empty() { deep.channels.first().map(|c| c.samples.as_slice()).unwrap_or(&[]) } else { y };
let mut out = vec![0f32; npx * nc];
let mut order: Vec<u32> = Vec::new();
let mut max_samples = 0u32;
for p in 0..npx {
let range = deep.sample_range(p);
let (s0, n) = (range.start as usize, (range.end - range.start) as usize);
max_samples = max_samples.max(u32::try_from(n).unwrap_or(u32::MAX));
if n == 0 {
continue; // no samples: transparent black
}
order.clear();
order.extend(0u32..n as u32);
if !z.is_empty() {
// Stable sort by Z; a NaN depth (invalid) sorts last, deterministically.
order.sort_by(|&i, &j| {
let zi = z.get(s0 + i as usize).copied().unwrap_or(f32::NAN);
let zj = z.get(s0 + j as usize).copied().unwrap_or(f32::NAN);
zi.total_cmp(&zj)
});
}
let at = |ch: &[f32], i: u32| ch.get(s0 + i as usize).copied().unwrap_or(0.0);
// Per-colour-channel alpha: AR/AG/AB when present, else A, else opaque.
let alpha = |i: u32| -> [f32; 3] {
let s = at(a, i);
[
if !a.is_empty() && ar.is_empty() {
s
} else if !ar.is_empty() {
at(ar, i)
} else {
1.0
},
if !a.is_empty() && ag.is_empty() {
s
} else if !ag.is_empty() {
at(ag, i)
} else {
1.0
},
if !a.is_empty() && ab.is_empty() {
s
} else if !ab.is_empty() {
at(ab, i)
} else {
1.0
},
]
};
let mut cacc = [0f32; 3]; // premultiplied colour
let mut aacc = [0f32; 3]; // accumulated alpha
for &i in &order {
let ai = alpha(i);
let src = if rgb { [at(r, i), at(g, i), at(b, i)] } else { [at(lum, i); 3] };
let mut covered = true;
for k in 0..3 {
let t = (1.0 - aacc[k]).max(0.0);
cacc[k] += src[k] * t;
aacc[k] += t * ai[k];
covered &= aacc[k] >= 1.0;
}
if covered {
break; // everything behind is hidden
}
}
let a_out = if a.is_empty() && ar.is_empty() && ag.is_empty() && ab.is_empty() { 1.0 } else { (aacc[0] + aacc[1] + aacc[2]) / 3.0 };
let a_clamped = a_out.clamp(0.0, 1.0);
// Un-premultiply into straight alpha (the deep colours are premultiplied).
let straight = |c: f32| if a_clamped > 1e-6 { c / a_clamped } else { 0.0 };
let base = p * nc;
for (k, v) in [straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), a_clamped].into_iter().enumerate() {
if let Some(slot) = out.get_mut(base + k) {
*slot = v;
}
}
if !rgb {
// Luminance: keep only channel 0, and the alpha behind it.
if let Some(slot) = out.get_mut(base) {
*slot = straight(cacc[0]);
}
}
}
let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?;
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
+24 -2
View File
@@ -21,6 +21,15 @@ fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
/// An `exr`-crate error, with its `NotSupported` kind kept distinct from malformed data
/// (the same split the TIFF codec makes), so unsupported features say so.
fn map_exr(e: exr::error::Error) -> CodecError {
match e {
exr::error::Error::NotSupported(message) => CodecError::unsupported(F, format!("not supported: {message}")),
e => err(e),
}
}
/// Strip an optional `layer.` prefix from a channel name.
fn base_name(name: &str) -> &str {
name.rsplit('.').next().unwrap_or(name)
@@ -36,8 +45,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
limits.check_bytes(w, h, bpp)?;
}
let image =
read().no_deep_data().largest_resolution_level().all_channels().first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map_err(err)?;
// Deep parts are rejected by the `exr` crate's reader; walk their chunks ourselves and
// composite the samples into the flat image (a warning says what was lost).
if meta.requirements.has_deep_data {
let deep = super::deep_exr::decode_deep(&meta, bytes, limits)?;
return super::deep_exr::flatten(&deep);
}
let image = read()
.no_deep_data()
.largest_resolution_level()
.all_channels()
.first_valid_layer()
.all_attributes()
.from_buffered(Cursor::new(bytes))
.map_err(map_exr)?;
let layer = &image.layer_data;
let (w, h) = (layer.size.0, layer.size.1);
let channels = &layer.channel_data.list;
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod deep_exr;
pub(crate) mod exr;
pub(crate) mod heif;
pub(crate) mod jpeg;
+54
View File
@@ -140,6 +140,9 @@ pub enum DecodeWarning {
/// The image data ends early (truncated or damaged file); the decoder filled in the rest
/// (a baseline JPEG's missing rows come out grey).
Truncated { format: Format },
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
/// themselves (Z and other deep channels) are not part of the flat result.
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
}
impl fmt::Display for DecodeWarning {
@@ -152,10 +155,61 @@ impl fmt::Display for DecodeWarning {
DecodeWarning::Truncated { format } => {
write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name())
}
DecodeWarning::DeepFlattened { format, max_samples_per_pixel } => write!(
f,
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
format = format.name()
),
}
}
}
/// One channel of a [`DeepImage`]: the samples of every pixel, one flat list in pixel order.
#[derive(Debug, Clone, PartialEq)]
pub struct DeepChannel {
/// The channel name as in the file (e.g. `R`, `G`, `B`, `A`, `Z`).
pub name: String,
/// The sample type as stored in the file; `samples` always holds the numeric value as
/// f32 (half and 32-bit floats exactly; 32-bit unsigned integers exactly up to 2^24).
pub sample: SampleType,
/// Every sample of the channel, in pixel order; the samples of pixel `i` are
/// `counts[i]..counts[i+1]` (see [`DeepImage::counts`]).
pub samples: Vec<f32>,
}
/// Deep image data (OpenEXR `deepscanline`/`deeptile`): a variable-length list of samples
/// per pixel, each with its own colour, alpha and depth. All channels share the same sample
/// layout, so a sample is one index into every channel.
#[derive(Debug, Clone, PartialEq)]
pub struct DeepImage {
pub width: u32,
pub height: u32,
/// The sample channels in file order.
pub channels: Vec<DeepChannel>,
/// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i`
/// holds the samples `counts[i]..counts[i+1]` of every channel. Monotonically
/// non-decreasing, starting and ending at the total sample count.
pub counts: Vec<u64>,
}
impl DeepImage {
/// A channel by its exact name.
pub fn channel(&self, name: &str) -> Option<&DeepChannel> {
self.channels.iter().find(|c| c.name == name)
}
/// The samples of one pixel, as indices into every channel's `samples`.
pub fn sample_range(&self, pixel: usize) -> std::ops::Range<u64> {
let end = self.counts.get(pixel + 1).copied().unwrap_or(0);
self.counts.get(pixel).copied().unwrap_or(end)..end
}
/// The total number of samples over all pixels.
pub fn total_samples(&self) -> u64 {
self.counts.last().copied().unwrap_or(0)
}
}
/// A single flat raster image.
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
+11 -1
View File
@@ -26,7 +26,7 @@ pub use crate::codecs::png::encode_indexed as encode_png_indexed;
pub use crate::error::CodecError;
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
pub use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
pub use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, Metadata, SampleType};
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use half::f16;
@@ -87,6 +87,16 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
img.oriented(o)
}
/// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists
/// (colour, alpha and Z per sample), for callers that composite deep data themselves —
/// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a
/// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part.
pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
let mut cur = std::io::Cursor::new(bytes);
let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?;
codecs::deep_exr::decode_deep(&meta, bytes, limits)
}
/// Encode `image` as `format`. Conversions follow the same plan that
/// [`fidelity_warnings_with`] reports; fatal conditions (unsupported
/// format, oversize image) return an error.
+628
View File
@@ -0,0 +1,628 @@
//! Deep EXR: a synthetic generator builds deep chunks per the OpenEXR file layout
//! specification (NONE, RLE and ZIPS × scanline and tile), the oracle tests check the
//! compositing math against hand-computed values, and malformed inputs must error, never
//! panic. Real files from `corpus/exr/` (copied in by hand, not pinned) are checked when
//! they exist.
use photocraft_codecs::CodecError;
use photocraft_codecs::f16;
use photocraft_codecs::{Limits, decode, decode_deep_exr};
use std::io::Write as _;
// ---------------------------------------------------------------------------------------------
// A minimal deep-EXR writer, mirroring what the reference files show on disk:
// version 2 | deep bit (and the tile bit for tiles), one header with the mandatory
// attributes in alphabetical order, a chunk offset table, then deep chunks of
// [y | tile coords][u64 packed table][u64 packed data][u64 unpacked data][table][data].
// The table holds u32 sample counts, cumulative within each row of the block; the sample
// data is grouped per row, then per channel, in pixel order (the layout the OpenEXR library
// writes). NONE stores raw; RLE and ZIPS separate even/odd bytes, apply the +128 predictor,
// then RLE packs runs and ZIPS deflates.
fn attr(out: &mut Vec<u8>, name: &str, ty: &str, payload: &[u8]) {
out.extend_from_slice(name.as_bytes());
out.push(0);
out.extend_from_slice(ty.as_bytes());
out.push(0);
out.extend_from_slice(&(payload.len() as i32).to_le_bytes());
out.extend_from_slice(payload);
}
fn samples_to_differences(buf: &mut [u8]) {
if let Some(&first) = buf.first() {
let mut prev = i16::from(first);
for b in buf.iter_mut().skip(1) {
let v = i16::from(*b);
*b = (v - prev + 128) as u8;
prev = v;
}
}
}
fn separate_bytes_fragments(buf: &mut [u8]) {
let copy = buf.to_vec();
let half = copy.len().div_ceil(2);
for i in 0..copy.len() {
buf[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = copy[i];
}
}
fn rle_bytes(src: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < src.len() {
let mut run = 1;
while i + run < src.len() && run < 128 && src[i + run] == src[i] {
run += 1;
}
// OpenEXR tokens: `n >= 0` repeats the next byte n + 1 times, `n < 0` copies -n bytes.
if run > 1 {
out.push((run - 1) as u8);
out.push(src[i]);
i += run;
} else {
let start = i;
i += 1;
while i < src.len() && (i - start) < 127 && src[i] != src[i - 1] {
i += 1;
}
let n = i - start;
out.push((-(n as i32)) as u8);
out.extend_from_slice(&src[start..start + n]);
}
}
out
}
fn compress(kind: u8, raw: &[u8]) -> Vec<u8> {
match kind {
1 => {
// Like ZIPS, RLE packs the byte-split, predicted stream.
let mut b = raw.to_vec();
separate_bytes_fragments(&mut b);
samples_to_differences(&mut b);
let c = rle_bytes(&b);
if c.len() < raw.len() { c } else { raw.to_vec() }
}
2 => {
let mut b = raw.to_vec();
separate_bytes_fragments(&mut b);
samples_to_differences(&mut b);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
// Writing to a Vec never fails; the encoder errors surface in `finish`.
#[allow(clippy::unwrap_used)]
z.write_all(&b).unwrap();
#[allow(clippy::unwrap_used)]
let c = z.finish().unwrap();
if c.len() < raw.len() { c } else { raw.to_vec() }
}
_ => raw.to_vec(),
}
}
/// One channel: a name (the caller must pass channels in alphabetical order, as chlist
/// requires) and the sample type as in the chlist attribute (0 = u32, 1 = half, 2 = float).
struct Chan {
name: &'static str,
ty: i32,
}
/// `counts[p]` samples per pixel; `sample(p, ci)` returns channel `ci`'s values for pixel
/// `p` (its length must be `counts[p]`). `tile` switches between deepscanline and deeptile.
#[allow(clippy::too_many_arguments)]
fn gen_deep(
w: usize,
h: usize,
channels: &[Chan],
compression: u8,
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
// Single-part deep files (deepscanline and deeptile alike) carry only the deep-data
// flag; the tiling lives in the `tiles` attribute and the `type` string.
out.extend_from_slice(&(2u32 | 0x800).to_le_bytes());
let mut chlist = Vec::new();
for c in channels {
chlist.extend_from_slice(c.name.as_bytes());
chlist.push(0);
chlist.extend_from_slice(&c.ty.to_le_bytes());
chlist.push(0); // pLinear
chlist.extend_from_slice(&[0u8; 3]);
chlist.extend_from_slice(&1i32.to_le_bytes());
chlist.extend_from_slice(&1i32.to_le_bytes());
}
chlist.push(0);
attr(&mut out, "channels", "chlist", &chlist);
attr(&mut out, "compression", "compression", &[compression]);
// box2i is inclusive: the data window of a w×h image at the origin ends at (w-1, h-1).
let dw: Vec<u8> = [0i32, 0, w as i32 - 1, h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect();
attr(&mut out, "dataWindow", "box2i", &dw);
attr(&mut out, "displayWindow", "box2i", &dw);
attr(&mut out, "lineOrder", "lineOrder", &[0]);
attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes());
attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat());
attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes());
if let Some((tw, th)) = tile {
let mut td = Vec::new();
td.extend_from_slice(&(tw as u32).to_le_bytes());
td.extend_from_slice(&(th as u32).to_le_bytes());
td.push(0); // one level, round down
attr(&mut out, "tiles", "tiledesc", &td);
}
attr(&mut out, "type", "string", if tile.is_some() { b"deeptile" } else { b"deepscanline" });
attr(&mut out, "version", "int", &1i32.to_le_bytes());
out.push(0); // end of the header
// The blocks: scan lines (one line each) or the level-0 tiles.
struct Block {
pixels: Vec<usize>,
/// Pixels per row: the count table restarts and the sample data is grouped per row.
row_w: usize,
}
let blocks: Vec<Block> = match tile {
None => (0..h).map(|y| Block { pixels: (y * w..y * w + w).collect(), row_w: w }).collect(),
Some((tw, th)) => {
let tx = w.div_ceil(tw);
let ty = h.div_ceil(th);
let mut v = Vec::new();
for by in 0..ty {
for bx in 0..tx {
let mut px = Vec::new();
for yy in (by * th)..((by * th + th).min(h)) {
for xx in (bx * tw)..((bx * tw + tw).min(w)) {
px.push(yy * w + xx);
}
}
v.push(Block { pixels: px, row_w: (bx * tw + tw).min(w) - bx * tw });
}
}
v
}
};
let table_len = |b: &Block| b.pixels.len() * 4;
let data_len = |b: &Block| -> usize {
let samples: u64 = b.pixels.iter().map(|&p| counts[p] as u64).sum();
channels.iter().map(|c| samples as usize * if c.ty == 1 { 2 } else { 4 }).sum()
};
// Build every chunk, remember the offsets, then write the offset table first.
let mut chunks: Vec<(usize, Vec<u8>)> = Vec::new(); // (block index, framed chunk)
for (bi, b) in blocks.iter().enumerate() {
let mut c = Vec::new();
match tile {
None => {
let y = (bi % h) as i32;
c.extend_from_slice(&y.to_le_bytes());
}
Some((tw, _)) => {
let tx = w.div_ceil(tw);
let (bx, by) = (bi % tx, bi / tx);
c.extend_from_slice(&(bx as i32).to_le_bytes());
c.extend_from_slice(&(by as i32).to_le_bytes());
c.extend_from_slice(&0i32.to_le_bytes());
c.extend_from_slice(&0i32.to_le_bytes());
}
}
let mut table = Vec::with_capacity(b.pixels.len() * 4);
for row in b.pixels.chunks(b.row_w) {
let mut acc = 0u32;
for &p in row {
acc += counts[p];
table.extend_from_slice(&acc.to_le_bytes());
}
}
let mut data = Vec::new();
for row in b.pixels.chunks(b.row_w) {
for (ci, ch) in channels.iter().enumerate() {
for &p in row {
for v in sample(p, ci) {
match ch.ty {
1 => data.extend_from_slice(&f16::from_f32(v).to_le_bytes()),
0 => data.extend_from_slice(&(v as u32).to_le_bytes()),
_ => data.extend_from_slice(&v.to_le_bytes()),
}
}
}
}
}
let ct = compress(compression, &table);
let cd = compress(compression, &data);
c.extend_from_slice(&(ct.len() as u64).to_le_bytes());
c.extend_from_slice(&(cd.len() as u64).to_le_bytes());
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
c.extend_from_slice(&ct);
c.extend_from_slice(&cd);
assert_eq!(table.len(), table_len(b));
assert_eq!(data.len(), data_len(b));
chunks.push((bi, c));
}
let mut pos = out.len() + chunks.len() * 8;
let mut table = Vec::new();
for (_, c) in &chunks {
table.extend_from_slice(&(pos as u64).to_le_bytes());
pos += c.len();
}
out.extend_from_slice(&table);
for (_, c) in chunks {
out.extend_from_slice(&c);
}
out
}
/// RGBA(Z) float channels in chlist (alphabetical) order.
const RGBAZ: [Chan; 5] =
[Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }];
fn sample_rgba(p: usize, ci: usize) -> Vec<f32> {
// One sample per pixel: a small checkerboard-ish pattern with varying depth. Deep
// colours are premultiplied, so the stored RGB values include the 0.5 alpha.
let v = [0.25f32, 0.5, 0.75, 1.0][p % 4];
match ci {
0 => vec![0.5], // A
1 => vec![0.125 * v], // B = straight 0.25v, premultiplied
2 => vec![0.25 * v], // G = straight 0.5v, premultiplied
3 => vec![0.5 * v], // R = straight v, premultiplied
_ => vec![10.0 + p as f32], // Z
}
}
const COUNTS_ONE: &[u32] = &[1; 16]; // for a 4x4 image
fn rgba_flat(bytes: &[u8]) -> Vec<f32> {
let img = decode(bytes).expect("decodes");
let s = img.to_f32_samples().expect("f32");
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba);
assert_eq!(img.sample_type(), photocraft_codecs::SampleType::F32);
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DeepFlattened { .. })));
s
}
// ---------------------------------------------------------------------------------------------
// Oracles
/// One sample per pixel: the flat image is the stored colour un-premultiplied.
#[test]
fn single_sample_matches_flat() {
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
let px = rgba_flat(&bytes);
for p in 0..16 {
let v = [0.25f32, 0.5, 0.75, 1.0][p % 4];
let (r, g, b, a) = (px[p * 4], px[p * 4 + 1], px[p * 4 + 2], px[p * 4 + 3]);
assert!((r - v).abs() < 1e-6, "r at {p}: {r}");
assert!((g - 0.5 * v).abs() < 1e-6, "g at {p}: {g}");
assert!((b - 0.25 * v).abs() < 1e-6, "b at {p}: {b}");
assert!((a - 0.5).abs() < 1e-6, "a at {p}: {a}");
}
}
/// Two samples stored back-to-front: the front (smaller Z) covers the back. Hand-computed:
/// front a=0.5, premultiplied colour (0.5, 0.25, 0); back a=0.25, premultiplied (0.25, 0.25, 0.25).
/// Over (front first): a = 0.5 + (1−0.5)·0.25 = 0.625; c = (0.5 + 0.5·0.25, 0.25 + 0.5·0.25,
/// 0 + 0.5·0.25) = (0.625, 0.375, 0.125); straight = c / a = (1.0, 0.6, 0.2).
#[test]
fn z_order_composites_front_over_back() {
// pixel 0: samples [(z=9, back), (z=1, front)] — the file order is reversed on purpose.
let counts = vec![2u32; 16];
let sample = |p: usize, ci: usize| -> Vec<f32> {
if p != 0 {
let v = sample_rgba(p, ci);
return vec![v[0], v[0]];
}
match ci {
0 => vec![0.25, 0.5], // A: back first
1 => vec![0.25, 0.0], // B
2 => vec![0.25, 0.25], // G
3 => vec![0.25, 0.5], // R
_ => vec![9.0, 1.0], // Z
}
};
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
let px = rgba_flat(&bytes);
let a = 0.5f32 + 0.5 * 0.25;
assert!((px[3] - a).abs() < 1e-6, "alpha {}", px[3]);
assert!((px[0] - 0.625 / a).abs() < 1e-6, "r {}", px[0]);
assert!((px[1] - 0.375 / a).abs() < 1e-6, "g {}", px[1]);
assert!((px[2] - 0.125 / a).abs() < 1e-6, "b {}", px[2]);
}
/// A fully opaque front sample hides everything behind it.
#[test]
fn opaque_front_hides_back() {
let counts = vec![3u32; 16];
let sample = |p: usize, ci: usize| -> Vec<f32> {
if p != 1 {
let v = sample_rgba(p, ci);
return vec![v[0], v[0], v[0]];
}
match ci {
0 => vec![1.0, 0.9, 1.0], // front opaque, middle partial, back opaque
1 => vec![0.0, 1.0, 1.0],
2 => vec![1.0, 1.0, 1.0],
3 => vec![1.0, 0.0, 0.0],
_ => vec![1.0, 5.0, 9.0],
}
};
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
let px = rgba_flat(&bytes);
let (r, g, b, a) = (px[4], px[5], px[6], px[7]);
assert!((r - 1.0).abs() < 1e-6 && (g - 1.0).abs() < 1e-6 && (b - 0.0).abs() < 1e-6, "colour ({r},{g},{b})");
assert!((a - 1.0).abs() < 1e-6, "alpha {a}");
}
/// Deep EXR is HDR: colour above 1.0 survives the composite (it used to be clamped), and a
/// tiled file whose edge tiles are narrower than the tile size decodes like the scan lines.
#[test]
fn hdr_colour_is_kept_and_edge_tiles_match_scanlines() {
let (w, h) = (7, 5);
let counts: Vec<u32> = (0..w * h).map(|p| (p as u32 * 3) % 4).collect();
let sample = |p: usize, ci: usize| -> Vec<f32> {
(0..(p as u32 * 3) % 4).map(|k| [1.0, 0.25 * k as f32, 0.5, 2.0 + p as f32 / 8.0, 10.0 - k as f32][ci]).collect()
};
for compression in [0u8, 1, 2] {
let scan = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, None, &counts, &sample));
let tiled = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, Some((3, 2)), &counts, &sample));
assert_eq!(scan, tiled, "compression {compression}");
// Pixel 1 has 3 opaque samples; the front one (k = 2, Z = 8) has R = 2.125.
assert!((scan[4] - 2.125).abs() < 1e-5, "compression {compression}: R {}", scan[4]);
}
}
/// Pixels without samples are transparent black, and the structured counts say so.
#[test]
fn empty_pixels_are_transparent() {
let mut counts = vec![0u32; 16];
counts[5] = 1;
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &|p, ci| {
if p == 5 { vec![[1.0f32, 0.5, 0.5, 0.5, 1.0][ci]] } else { Vec::new() }
});
let px = rgba_flat(&bytes);
for p in 0..16 {
let base = p * 4;
if p == 5 {
assert!((px[base + 3] - 1.0).abs() < 1e-6);
} else {
assert!(px[base..base + 4].iter().all(|v| *v == 0.0), "pixel {p} not empty");
}
}
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
assert_eq!(deep.total_samples(), 1);
assert_eq!(deep.sample_range(5).end, 1);
}
/// RLE, ZIPS and NONE decode to identical pixels and identical structured samples.
#[test]
fn rle_and_zips_match_uncompressed() {
let counts: Vec<u32> = (0..16).map(|p| if p % 3 == 0 { 2 } else { 1 }).collect();
let sample = |p: usize, ci: usize| -> Vec<f32> {
if counts[p] == 2 {
match ci {
0 => vec![0.75, 0.25],
1 => vec![0.1, 0.2],
2 => vec![0.3, 0.4],
3 => vec![0.5, 0.6],
_ => vec![7.0, 3.0],
}
} else {
sample_rgba(p, ci)
}
};
let base = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
let px = rgba_flat(&base);
let deep_base = decode_deep_exr(&base, &Limits::none()).expect("deep");
for kind in [1u8, 2] {
let bytes = gen_deep(4, 4, &RGBAZ, kind, None, &counts, &sample);
let other = rgba_flat(&bytes);
for (i, (a, b)) in px.iter().zip(other.iter()).enumerate() {
assert!((a - b).abs() < 1e-6, "compression {kind}, sample {i}: {a} vs {b}");
}
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
assert_eq!(deep.counts, deep_base.counts);
for (a, b) in deep.channels.iter().zip(deep_base.channels.iter()) {
assert_eq!(a.name, b.name);
assert_eq!(a.samples, b.samples, "channel {}", a.name);
}
}
}
/// Deep tiles decode to the same pixels as deep scanlines of the same data.
#[test]
fn tiles_match_scanlines() {
let counts = vec![1u32, 0, 2, 1, 1, 1, 0, 1, 2, 1, 1, 0, 1, 1, 1, 1];
let sample = |p: usize, ci: usize| -> Vec<f32> {
if counts[p] == 2 {
match ci {
0 => vec![0.5, 0.5],
1 => vec![0.2, 0.1],
2 => vec![0.3, 0.3],
3 => vec![0.4, 0.4],
_ => vec![2.0, 8.0],
}
} else if counts[p] > 0 {
vec![[0.5, 0.2, 0.4, 0.7, 4.0][ci]]
} else {
Vec::new()
}
};
let sl = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample);
let tiled = gen_deep(4, 4, &RGBAZ, 0, Some((2, 2)), &counts, &sample);
let a = rgba_flat(&sl);
let b = rgba_flat(&tiled);
for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
assert!((x - y).abs() < 1e-6, "tile sample {i}: {x} vs {y}");
}
}
/// Half-float channels come back as F16 samples with exact values.
#[test]
fn half_channels_round_trip() {
let channels = [Chan { name: "A", ty: 1 }, Chan { name: "B", ty: 1 }, Chan { name: "G", ty: 1 }, Chan { name: "R", ty: 1 }, Chan { name: "Z", ty: 1 }];
let bytes = gen_deep(4, 4, &channels, 2, None, COUNTS_ONE, &|p, _ci| {
let v = f16::from_f32(0.5).to_f32();
vec![v * (p as f32 + 1.0) / 4.0]
});
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
for c in &deep.channels {
assert_eq!(c.sample, photocraft_codecs::SampleType::F16, "{}", c.name);
}
let a = deep.channel("A").expect("A");
assert_eq!(a.samples.len(), 16);
}
/// Without an alpha channel the front-most sample wins, opaque; luminance maps to gray.
#[test]
fn without_alpha_front_sample_is_opaque() {
let channels = [Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }];
let counts = vec![2u32; 16];
let bytes = gen_deep(4, 4, &channels, 0, None, &counts, &|p, ci| {
if p % 2 == 0 {
match ci {
0 => vec![0.9, 0.1],
1 => vec![0.2, 0.8],
2 => vec![0.4, 0.6],
_ => vec![2.0, 6.0],
}
} else {
vec![[0.0, 0.0, 1.0, 1.0][ci], [0.0, 0.0, 1.0, 9.0][ci]]
}
});
let img = decode(&bytes).expect("decodes");
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgb);
let px = img.to_f32_samples().expect("f32");
// Even pixels: the front sample (z=2) is (r 0.4, g 0.2, b 0.9).
assert!((px[0] - 0.4).abs() < 1e-6 && (px[1] - 0.2).abs() < 1e-6 && (px[2] - 0.9).abs() < 1e-6);
}
/// decode_deep returns the counts and channels in file order.
#[test]
fn decode_deep_structure() {
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep");
assert_eq!((deep.width, deep.height), (4, 4));
assert_eq!(deep.channels.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B", "G", "R", "Z"]);
assert_eq!(deep.counts.len(), 17);
assert_eq!(deep.counts[0], 0);
assert_eq!(deep.total_samples(), 16);
for p in 0..16 {
assert_eq!(deep.sample_range(p).end - deep.sample_range(p).start, 1);
}
assert_eq!(deep.channel("Z").expect("Z").samples[5], 15.0);
}
// ---------------------------------------------------------------------------------------------
// Hostile input: every damaged file errors, nothing panics.
fn truncate_sweep(bytes: &[u8]) {
// Cut at every offset (cap the count for speed) — a cut file must error, not panic.
let step = (bytes.len() / 96).max(1);
let mut i = step;
while i < bytes.len() {
let cut = &bytes[..i];
let _ = decode_deep_exr(cut, &Limits::none());
let _ = decode(cut);
i += step;
}
}
#[test]
fn truncated_files_error_never_panic() {
for kind in [0u8, 1, 2] {
let bytes = gen_deep(4, 4, &RGBAZ, kind, None, COUNTS_ONE, &sample_rgba);
truncate_sweep(&bytes);
}
let tiled = gen_deep(4, 4, &RGBAZ, 1, Some((2, 2)), COUNTS_ONE, &sample_rgba);
truncate_sweep(&tiled);
}
#[test]
fn hostile_sizes_and_offsets_error() {
let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
// A tiny sample-data budget rejects the file.
let tight = Limits { max_alloc: 64, ..Limits::none() };
assert!(matches!(decode_deep_exr(&bytes, &tight), Err(CodecError::LimitExceeded(_))));
// A non-deep file is unsupported for decode_deep.
let flat = photocraft_codecs::encode(
&photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgba, [128; 16].to_vec()).expect("img"),
photocraft_codecs::Format::OpenExr,
&Default::default(),
)
.expect("encode");
assert!(matches!(decode_deep_exr(&flat, &Limits::none()), Err(CodecError::Unsupported { .. })));
// The last chunk's y coordinate destroyed: out of the data window.
let mut broken = bytes.clone();
let header_end = header_end_of(&broken);
let last_off = u64::from_le_bytes(broken[header_end + 24..header_end + 32].try_into().expect("8")) as usize;
broken[last_off..last_off + 4].copy_from_slice(&i32::MAX.to_le_bytes());
assert!(decode_deep_exr(&broken, &Limits::none()).is_err());
assert!(decode(&broken).is_err());
// Two chunks claiming the same block: duplicate offsets.
let mut dup = bytes.clone();
let header_end = header_end_of(&dup);
let first: [u8; 8] = dup[header_end..header_end + 8].try_into().expect("8");
dup[header_end + 8..header_end + 16].copy_from_slice(&first);
assert!(matches!(decode_deep_exr(&dup, &Limits::none()), Err(CodecError::Malformed { .. })));
// An offset pointing past the file.
let mut far = bytes.clone();
far[header_end..header_end + 8].copy_from_slice(&u64::MAX.to_le_bytes());
assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. })));
}
/// Walk the attributes like the format prescribes: the offset table starts after the
/// header's terminating zero byte.
fn header_end_of(bytes: &[u8]) -> usize {
let mut p = 8usize; // magic + version
loop {
let Some(nul) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute name") };
if nul == 0 {
return p + 1; // the empty name terminates the header
}
p += nul + 1;
let Some(t) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute type") };
p += t + 1;
let size = i32::from_le_bytes(bytes[p..p + 4].try_into().expect("size")) as usize;
p += 4 + size;
}
}
#[test]
fn inconsistent_count_table_errors() {
// The declared unpacked size disagrees with the count table.
let mut bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba);
// Find the first chunk: the offset table follows the header (find the "version" attr end).
let header_end = header_end_of(&bytes);
let first_chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("8")) as usize;
// Scanline chunk: y (4), three u64s (24); bump the unpacked size by one.
let at = first_chunk + 4 + 16;
let v = u64::from_le_bytes(bytes[at..at + 8].try_into().expect("8"));
bytes[at..at + 8].copy_from_slice(&(v + 1).to_le_bytes());
assert!(matches!(decode_deep_exr(&bytes, &Limits::none()), Err(CodecError::Malformed { .. })));
}
// ---------------------------------------------------------------------------------------------
// Real reference files (corpus/exr, copied in by hand from the OpenEXR repository's test
// images; not pinned by xtask). Skipped with a message when the folder is absent.
#[test]
fn real_deep_files_decode() {
let dir = std::path::Path::new("../../corpus/exr");
let entries: Vec<_> =
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "exr")).collect();
if entries.is_empty() {
eprintln!("skipped: no files in corpus/exr (copy the *.deep.exr test images there)");
return;
}
for path in entries {
let bytes = std::fs::read(&path).expect("read");
let deep = decode_deep_exr(&bytes, &Limits::none()).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
let img = decode(&bytes).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
assert_eq!((deep.width, deep.height), (img.width(), img.height()));
assert!(deep.total_samples() > 0, "{}: no samples", path.display());
let flat = img.to_f32_samples().expect("f32");
assert_eq!(flat.len(), deep.width as usize * deep.height as usize * 4);
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DeepFlattened { .. })));
}
}