mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Deep EXR: open cropped renders on their full display-window stage (#4907)
A renderer's data window is its crop of the frame; the composition lives on the whole display window (our MY27 renders: data 1782x1267 at (1109,1100) inside a 4000x3000 stage). decode_deep now validates the display window against the limits and reports it as stage + origin on DeepImage; flatten pads the composed region to the stage at its offset (transparent elsewhere) and tags the image with a DisplayWindowStage warning. Files without a usable display window open as before. Includes a cropped-deep generator and a stage/offset oracle test.
This commit is contained in:
1 parent
2571423bb3
commit
1bcb662828
3 files changed
+187
-2
No files matched your search
@@ -227,6 +227,27 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
|
||||
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)?;
|
||||
|
||||
// The display window is the full stage the renderer composed onto (a render pass's data
|
||||
// window is often its crop). Open on the stage when it plausibly holds the data region:
|
||||
// sane size for the limits, and the data region inside it. Otherwise fall back to the
|
||||
// data window alone, as before.
|
||||
let display = header.shared_attributes.display_window;
|
||||
let display_size = (display.size.0.max(1) as u32, display.size.1.max(1) as u32);
|
||||
let (stage, origin) = match display_size {
|
||||
(sw, sh)
|
||||
if sw <= 1 << 15
|
||||
&& sh <= 1 << 15
|
||||
&& limits.check_bytes(sw, sh, 4).is_ok()
|
||||
&& dw.position.0 >= display.position.0
|
||||
&& dw.position.1 >= display.position.1
|
||||
&& dw.position.0.saturating_add(w as i32) <= display.position.0.saturating_add(sw as i32)
|
||||
&& dw.position.1.saturating_add(h as i32) <= display.position.1.saturating_add(sh as i32) =>
|
||||
{
|
||||
(Some((sw, sh)), (dw.position.0 - display.position.0, dw.position.1 - display.position.1))
|
||||
}
|
||||
_ => (None, (0, 0)),
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -440,7 +461,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts })
|
||||
Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts: deep_counts })
|
||||
}
|
||||
|
||||
/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]
|
||||
@@ -565,7 +586,28 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?;
|
||||
// Open on the full display-window stage when the file declares one: the renderer's data
|
||||
// window is its crop, and the composition lives on the whole stage. The composed region
|
||||
// pads to the stage at its offset, transparent elsewhere (limits validated the size).
|
||||
let mut img = match deep.stage {
|
||||
Some((sw, sh)) => {
|
||||
let mut padded = vec![0f32; sw as usize * sh as usize * nc];
|
||||
let row_len = deep.width as usize * nc;
|
||||
let dx = deep.origin.0.max(0) as usize;
|
||||
for row in 0..deep.height as usize {
|
||||
let src = row * row_len;
|
||||
let dst = (row + deep.origin.1.max(0) as usize) * sw as usize + dx;
|
||||
if let (Some(d), Some(r)) = (padded.get_mut(dst * nc..dst * nc + row_len), out.get(src..src + row_len)) {
|
||||
d.copy_from_slice(r);
|
||||
}
|
||||
}
|
||||
Image::from_f32(sw, sh, layout, &padded)?
|
||||
}
|
||||
None => Image::from_f32(deep.width, deep.height, layout, &out)?,
|
||||
};
|
||||
if let Some((sw, sh)) = deep.stage {
|
||||
img.warnings.push(DecodeWarning::DisplayWindowStage { stage: (sw, sh), origin: deep.origin });
|
||||
}
|
||||
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
|
||||
Ok(img)
|
||||
}
|
||||
@@ -150,6 +150,8 @@ pub enum DecodeWarning {
|
||||
MorePages { total: Option<u32> },
|
||||
/// Only one part of a multi-part file was decoded; `total` counts every part when known.
|
||||
MoreParts { total: Option<u32> },
|
||||
/// The image opened on the full `display_window` stage; the data region sits at `origin`.
|
||||
DisplayWindowStage { stage: (u32, u32), origin: (i32, i32) },
|
||||
/// 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 },
|
||||
@@ -193,6 +195,9 @@ impl fmt::Display for DecodeWarning {
|
||||
DecodeWarning::MorePages { total: None } => write!(f, "only the first page of the file was imported"),
|
||||
DecodeWarning::MoreParts { total: Some(n) } => write!(f, "only one of {n} EXR parts was imported"),
|
||||
DecodeWarning::MoreParts { total: None } => write!(f, "only one EXR part of the file was imported"),
|
||||
DecodeWarning::DisplayWindowStage { stage, origin } => {
|
||||
write!(f, "opened on the full display window stage ({}x{}); the data sits at ({}, {})", stage.0, stage.1, origin.0, origin.1)
|
||||
}
|
||||
DecodeWarning::Truncated { format } => {
|
||||
write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name())
|
||||
}
|
||||
@@ -232,6 +237,13 @@ pub struct DeepChannel {
|
||||
pub struct DeepImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The full stage the image opens on: the EXR `display_window` size, when the file declares
|
||||
/// one the data region fits into and the limits allow. `None` when absent, absurd or not
|
||||
/// containing the data region — then the image opens on the data window alone, as before.
|
||||
pub stage: Option<(u32, u32)>,
|
||||
/// Where this image's (0, 0) sits on the stage: `data_window.position - display_window.position`.
|
||||
/// `(0, 0)` when [`Self::stage`] is `None`.
|
||||
pub origin: (i32, i32),
|
||||
/// The sample channels in file order.
|
||||
pub channels: Vec<DeepChannel>,
|
||||
/// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i`
|
||||
|
||||
@@ -254,6 +254,9 @@ fn gen_deep(
|
||||
out
|
||||
}
|
||||
|
||||
/// RGBA float channels in chlist (alphabetical) order, no Z (everything composites).
|
||||
const RGBA_NO_Z: [Chan; 4] = [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }];
|
||||
|
||||
/// 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 }];
|
||||
@@ -709,3 +712,131 @@ fn real_deep_files_decode() {
|
||||
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DeepFlattened { .. })), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `gen_deep` for a cropped render: the data window sits at `(dwx, dwy)` inside a larger
|
||||
/// display window of `sw × sh` (the stage the renderer composed onto). Scanline chunk Y
|
||||
/// coordinates are absolute display rows, so they carry the data-window offset.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn gen_deep_cropped(
|
||||
w: usize,
|
||||
h: usize,
|
||||
dwx: i32,
|
||||
dwy: i32,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
channels: &[Chan],
|
||||
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());
|
||||
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);
|
||||
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", &[0u8]);
|
||||
// box2i is inclusive: the windows end at position + size - 1.
|
||||
let dw: Vec<u8> = [dwx, dwy, dwx + w as i32 - 1, dwy + h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
attr(&mut out, "dataWindow", "box2i", &dw);
|
||||
let disp: Vec<u8> = [0i32, 0, sw as i32 - 1, sh as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
attr(&mut out, "displayWindow", "box2i", &disp);
|
||||
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());
|
||||
attr(&mut out, "type", "string", b"deepscanline");
|
||||
attr(&mut out, "version", "int", &1i32.to_le_bytes());
|
||||
out.push(0);
|
||||
|
||||
// One scanline block per data row; the pixel indexing stays data-relative.
|
||||
let mut chunks: Vec<Vec<u8>> = Vec::new();
|
||||
for y in 0..h {
|
||||
let pixels: Vec<usize> = (y * w..y * w + w).collect();
|
||||
let mut c = Vec::new();
|
||||
c.extend_from_slice(&(dwy + y as i32).to_le_bytes());
|
||||
let mut table = Vec::with_capacity(pixels.len() * 4);
|
||||
let mut acc = 0u32;
|
||||
for &p in &pixels {
|
||||
acc += counts[p];
|
||||
table.extend_from_slice(&acc.to_le_bytes());
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
for (ci, _) in channels.iter().enumerate() {
|
||||
for &p in &pixels {
|
||||
for v in sample(p, ci) {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
c.extend_from_slice(&(table.len() as u64).to_le_bytes());
|
||||
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
|
||||
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
|
||||
c.extend_from_slice(&table);
|
||||
c.extend_from_slice(&data);
|
||||
chunks.push(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
|
||||
}
|
||||
|
||||
/// A cropped render opens on the full display-window stage: MY27-style renders carry a
|
||||
/// `display_window` of the full frame (e.g. 4000×3000) while the deep data window is the
|
||||
/// render pass's crop inside it. The image must open at stage size with the data at its
|
||||
/// crop offset, transparent elsewhere — not cropped to the data window.
|
||||
#[test]
|
||||
fn cropped_deep_data_opens_on_the_display_stage() {
|
||||
let (w, h, dwx, dwy, sw, sh) = (24usize, 16usize, 8i32, 6i32, 48usize, 32usize);
|
||||
let counts = vec![1u32; w * h];
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> {
|
||||
// chlist order (RGBA_NO_Z): A, B, G, R.
|
||||
let x = p % w;
|
||||
let y = p / w;
|
||||
match ci {
|
||||
0 => vec![1.0], // A
|
||||
1 => vec![0.5], // B
|
||||
2 => vec![y as f32 / h as f32], // G
|
||||
_ => vec![x as f32 / w as f32], // R
|
||||
}
|
||||
};
|
||||
let bytes = gen_deep_cropped(w, h, dwx, dwy, sw, sh, &RGBA_NO_Z, &counts, &sample);
|
||||
let img = decode(&bytes).expect("cropped deep EXR decodes");
|
||||
assert_eq!((img.width(), img.height()), (sw as u32, sh as u32), "opens on the display window stage");
|
||||
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DisplayWindowStage { origin, .. } if *origin == (dwx, dwy))));
|
||||
|
||||
// The data sits at its crop offset: the first data pixel's green channel carries its
|
||||
// data-row fraction, everything outside the data region stays transparent black.
|
||||
let s = img.to_f32_samples().expect("f32");
|
||||
let nc = s.len() / (sw * sh);
|
||||
let px = |x: usize, y: usize| -> Vec<f32> { s[y * sw * nc + x * nc..y * sw * nc + x * nc + nc].to_vec() };
|
||||
let first = px(dwx as usize, dwy as usize);
|
||||
assert!(
|
||||
(first[0] - 0.0).abs() < 1e-5 && (first[1] - 0.0).abs() < 1e-5 && (first[2] - 0.5).abs() < 1e-5 && (first[3] - 1.0).abs() < 1e-5,
|
||||
"first data pixel at the offset: {first:?}"
|
||||
);
|
||||
let last = px(dwx as usize + w - 1, dwy as usize + h - 1);
|
||||
assert!(
|
||||
(last[0] - (w - 1) as f32 / w as f32).abs() < 1e-5 && (last[1] - (h - 1) as f32 / h as f32).abs() < 1e-5,
|
||||
"last data pixel keeps its coordinates: {last:?}"
|
||||
);
|
||||
let outside = px(0, 0);
|
||||
assert_eq!(outside, vec![0.0, 0.0, 0.0, 0.0], "outside the data region: transparent");
|
||||
}
|
||||
Reference in new issue
Block a user