mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
6 files changed
+1286
-3
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user