Open and export a linear DNG too large for one texture

A 22927×8966 Lightroom panorama opened as its embedded preview with
develop withheld, because no texture could hold it. DevelopSession now
opens a linear DNG past PROXY_EDGE (8192) on a box-reduced copy, and
keeps the full resolution on the CPU. The canvas at fit, the thumbnail,
the histograms and the masks work from the copy; a render finer than
it — the canvas zoomed in, a tile of the export — samples a window cut
from the full resolution, kept while the view stays inside it. The
export renders in halo-grown tiles of 4096 and assembles them.

On the panorama: decode 1.6 s, open 210 ms, canvas 37 ms, a zoomed
window 130 ms, the full-size export 5.9 s.
This commit is contained in:
2026-09-27 17:37:06 -04:00
parent 0007fa459f
commit 885864b6a0
4 changed files with 426 additions and 40 deletions
File diff suppressed because one or more lines are too long
+345 -13
View File
@@ -11,6 +11,24 @@ use crate::labels;
use super::session::DevelopSession; use super::session::DevelopSession;
/// Whether `outer` covers `inner`, both in the same normalised coordinates.
fn contains(outer: &CropRect, inner: &CropRect) -> bool {
const EPS: f32 = 1e-6;
inner.x >= outer.x - EPS
&& inner.y >= outer.y - EPS
&& inner.x + inner.width <= outer.x + outer.width + EPS
&& inner.y + inner.height <= outer.y + outer.height + EPS
}
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The longest edge of one tile of a tiled export, before its halo.
///
/// Well short of any device's texture limit, because a tile is not one
/// texture: the window it samples, the fused pass's intermediate and the
/// detail stage's ping-pong pair are all its size or larger, and at 4096 the
/// lot is a few hundred megabytes rather than a few gigabytes.
pub(super) const EXPORT_TILE_EDGE: u32 = 4096;
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect. /// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
/// ///
/// Rendering to the letterboxed size rather than the full viewport avoids /// Rendering to the letterboxed size rather than the full viewport avoids
@@ -152,11 +170,6 @@ impl DevelopSession {
let ctx = self.ctx.clone(); let ctx = self.ctx.clone();
self.ensure_subject_fields(&ctx); self.ensure_subject_fields(&ctx);
let masks = self
.rasterise_masks()
.then(|| self.masks.as_ref().and_then(|p| p.array()))
.flatten();
// The neighbourhood stage, composed at the size actually being drawn. // The neighbourhood stage, composed at the size actually being drawn.
// //
// It has to be composed *per render* rather than cached with the edit, // It has to be composed *per render* rather than cached with the edit,
@@ -180,12 +193,91 @@ impl DevelopSession {
.invalidation() .invalidation()
.through(dr_pipeline::Affects::Colour); .through(dr_pipeline::Affects::Colour);
// Chosen before the masks are borrowed: a window is cut here, and
// cutting one needs the session.
let source = self.source_for(w, h, detail.reach())?;
let masks = self
.rasterise_masks()
.then(|| self.masks.as_ref().and_then(|p| p.array()))
.flatten();
self.adjust self.adjust
.render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key) .render_detailed(&source, shader, w, h, masks, &detail, colour_key)
.map(|_| ()) .map(|_| ())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The texture a `w`×`h` render of the current view samples.
///
/// Always the session's own source for a photograph that fits in one
/// texture. For one that does not, that source is a reduced copy, and it
/// serves every render it has the pixels for — the canvas at fit, a
/// thumbnail. A render finer than the copy — zoomed in, or a tile of the
/// export — gets a window cut from the full resolution instead, covering
/// what the view reads plus `reach` render pixels of detail-stage halo,
/// at the coarsest reduction that still gives the render a texel per
/// pixel.
///
/// The window is kept, grown by some slack, and handed back again while
/// the view stays inside it, so panning a zoomed canvas uploads once per
/// window rather than once per frame.
fn source_for(
&mut self,
w: u32,
h: u32,
reach: u32,
) -> Result<std::sync::Arc<dr_gpu::DemosaicedImage>, String> {
let Some(full) = self.full.clone() else {
return Ok(self.demosaiced.clone());
};
let frame = self.demosaiced.size();
let ratio = self.graph.render_scale(frame, (w, h)).ratio();
let copy = self.demosaiced.texture_size().0 as f32 / frame.0.max(1) as f32;
if ratio <= copy * 1.001 {
return Ok(self.demosaiced.clone());
}
let reduce = ((1.0 / ratio).floor() as u32).max(1);
let halo = (reach as f32 / ratio).ceil() as u32;
let needed = self.graph.source_region(frame, halo);
if let Some((held, r, image)) = &self.window {
if *r == reduce && contains(held, &needed) {
return Ok(image.clone());
}
}
// Slack on each side, up to half the region again, and never so much
// that the window stops fitting the device.
let limit = dr_gpu::DemosaicedImage::max_dimension(&self.ctx) as f32 * reduce as f32;
let grow = |start: f32, extent: f32, edge: u32| {
let px = extent * edge as f32;
let slack = (px * 0.5).min((limit - px) * 0.5).max(0.0) / edge as f32;
let a = (start - slack).max(0.0);
let b = (start + extent + slack).min(1.0);
let a = (a * edge as f32).floor() as u32;
let b = ((b * edge as f32).ceil() as u32).max(a + 1);
(a, b - a)
};
let (x, width) = grow(needed.x, needed.width, frame.0);
let (y, height) = grow(needed.y, needed.height, frame.1);
let image = dr_gpu::DemosaicedImage::linear_rgb16_window(
&self.ctx,
&full,
[x, y, width, height],
reduce,
)
.map(std::sync::Arc::new)
.map_err(|e| e.to_string())?;
let held = CropRect {
x: x as f32 / frame.0 as f32,
y: y as f32 / frame.1 as f32,
width: width as f32 / frame.0 as f32,
height: height as f32 / frame.1 as f32,
};
self.window = Some((held, reduce, image.clone()));
Ok(image)
}
/// TRACES: FR-DSP-8 /// TRACES: FR-DSP-8
/// Encode the canvas for a different display from now on. /// Encode the canvas for a different display from now on.
/// ///
@@ -645,16 +737,65 @@ impl DevelopSession {
let saved_view = self.graph.framing().view(); let saved_view = self.graph.framing().view();
self.graph.framing_mut().set_view(CropRect::default()); self.graph.framing_mut().set_view(CropRect::default());
// Composed *inside* the suspension: the view reaches the shader as a let edge = EXPORT_TILE_EDGE.min(dr_gpu::DemosaicedImage::max_dimension(&self.ctx));
// uniform baked at composition, so composing before this point would let rendered = if self.full.is_some() && w.max(h) > edge {
// restore the framing and export the zoom anyway. self.render_in_tiles(w, h, space, edge)
let shader = self.graph.compose_for(space); } else {
let rendered = self.render_with_masks(&shader, w, h); // Composed *inside* the suspension: the view reaches the shader
// as a uniform baked at composition, so composing before this
// point would restore the framing and export the zoom anyway.
let shader = self.graph.compose_for(space);
self.render_with_masks(&shader, w, h)
.and_then(|()| self.adjust.export_pixels().map_err(|e| e.to_string()))
};
self.graph.framing_mut().set_view(saved_view); self.graph.framing_mut().set_view(saved_view);
rendered?; rendered
}
self.adjust.export_pixels().map_err(|e| e.to_string()) /// TRACES: FR-DSP-2 | NFR-RES-2
/// Render the whole frame a tile at a time and assemble it.
///
/// For a photograph whose file is larger than any render target the
/// device can hold. Each tile is the frame viewed through a rectangle of
/// it, grown by the detail stage's reach so the kernels near its edges
/// read what they would read untiled, and sampled from a window of the
/// full resolution (see `source_for`); only its interior is kept. The
/// view is left wherever the last tile put it — [`Self::render_the_file`]
/// restores it.
fn render_in_tiles(
&mut self,
w: u32,
h: u32,
space: dr_types::ColourSpace,
edge: u32,
) -> Result<(Vec<u8>, u32, u32), String> {
let frame = self.demosaiced.size();
let reach = self.graph.compose_detail(frame, (w, h)).reach();
let plan = dr_pipeline::tiles::plan((w, h), edge, reach).ok_or_else(|| {
format!(
"the edit reads {reach} pixels around each one, more than a {edge}-pixel \
tile can hold; a repair cloning from far across the frame is the usual cause"
)
})?;
log::info!("exporting {w}×{h} in {} tiles", plan.len());
let mut pixels = vec![0u8; w as usize * h as usize * 4];
for tile in &plan {
self.graph.framing_mut().set_view(tile.view((w, h)));
let shader = self.graph.compose_for(space);
self.render_with_masks(&shader, tile.grown[2], tile.grown[3])?;
let (rendered, rw, _) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
let (ox, oy) = tile.keep_offset();
let row = tile.keep[2] as usize * 4;
for r in 0..tile.keep[3] {
let from = ((oy + r) as usize * rw as usize + ox as usize) * 4;
let to = ((tile.keep[1] + r) as usize * w as usize + tile.keep[0] as usize) * 4;
pixels[to..to + row].copy_from_slice(&rendered[from..from + row]);
}
}
// The last tile's window is no use to the canvas, and it is large.
self.window = None;
Ok((pixels, w, h))
} }
/// TRACES: FR-CAT-9 /// TRACES: FR-CAT-9
@@ -1694,4 +1835,195 @@ mod tests {
"an sRGB export followed the monitor" "an sRGB export followed the monitor"
); );
} }
/// A linear frame wide enough to take the reduced-copy path, with detail
/// at every scale so a tile that read one pixel off would show.
fn wide_linear(w: u32, h: u32) -> RawImage {
let mut data = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let base =
4000.0 + 30000.0 * (x as f32 / w as f32) + 9000.0 * (y as f32 / h as f32);
let hash =
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 6000) as f32;
for c in 0..3 {
data.push((base * (0.7 + 0.15 * c as f32) + hash) as u16);
}
}
}
RawImage {
width: w,
height: h,
data,
cfa_pattern: dr_decode::CfaPattern::Unknown,
black_level: [512; 4],
white_level: 65535,
wb_coeffs: [2.0, 1.0, 1.5, 1.0],
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
samples_per_pixel: 3,
profile: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: w,
height: h,
},
}
}
/// TRACES: FR-DSP-2 | NFR-RES-2
#[test]
fn a_frame_too_large_for_one_texture_exports_in_tiles_as_if_whole() {
// The panorama case at a size a test can hold: wider than
// `PROXY_EDGE`, so the session opens on a reduced copy, and wider
// than `EXPORT_TILE_EDGE`, so the export is cut into tiles. The
// device can still hold it whole, which is what makes the untiled
// render available to compare against.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let (w, h) = (super::super::session::PROXY_EDGE + 808, 160);
if w > dr_gpu::DemosaicedImage::max_dimension(&ctx) {
log::warn!("this device cannot hold the reference whole; skipping");
return;
}
let raw = wide_linear(w, h);
let orientation = dr_types::Orientation::NORMAL;
let mut whole = DevelopSession::open(&ctx, &raw, orientation).expect("whole");
let mut giant = DevelopSession::open_owned(&ctx, raw, orientation).expect("giant");
assert!(whole.full.is_none());
assert!(
giant.full.is_some(),
"a frame this wide opens on a reduced copy"
);
assert_eq!(
giant.source_size(),
(w, h),
"the copy measures the photograph"
);
for s in [&mut whole, &mut giant] {
s.graph.set_param(
dr_pipeline::descriptor::OpId("exposure"),
dr_pipeline::descriptor::ParamId("exposure"),
0.8,
);
s.graph.set_param(
dr_pipeline::descriptor::OpId("clarity"),
dr_pipeline::descriptor::ParamId("amount"),
40.0,
);
}
let space = dr_types::ColourSpace::Srgb;
let a = whole.render_for_export(space).expect("whole export");
let b = giant.render_for_export(space).expect("tiled export");
assert_eq!((b.width, b.height), (w, h), "exported at full resolution");
let worst = a
.rgba
.iter()
.zip(&b.rgba)
.map(|(x, y)| x.abs_diff(*y))
.max()
.unwrap_or(0);
assert!(
worst <= 1,
"the tiled export differs by {worst} code values"
);
assert!(
giant.window.is_none(),
"the export's last window was dropped"
);
// Zoomed in, the canvas needs more than the copy has, and gets a
// window of the full resolution rather than an enlarged copy.
giant.graph.framing_mut().set_view(CropRect {
x: 0.4,
y: 0.0,
width: 0.05,
height: 1.0,
});
giant.render(1000, 400).expect("zoomed render");
let (held, reduce, _) = giant.window.as_ref().expect("a window was cut");
assert_eq!(*reduce, 1);
assert!(held.x <= 0.4 && held.x + held.width >= 0.45);
assert!(held.width < 0.5, "only around the view was uploaded");
}
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The real thing, timed: `DR_GIANT_DNG=IMG_4181-Pano.dng cargo test
/// --release -p dr-ui --lib giant_dng -- --ignored --nocapture
/// --test-threads=1`.
#[test]
#[ignore]
fn giant_dng() {
let Ok(path) = std::env::var("DR_GIANT_DNG") else {
eprintln!("set DR_GIANT_DNG to a linear DNG larger than one texture");
return;
};
let ctx = pollster::block_on(dr_gpu::GpuContext::new_headless()).expect("GPU");
let t = std::time::Instant::now();
let bytes = std::fs::read(&path).expect("read");
let raw = dr_decode::decode(&bytes).expect("decode");
drop(bytes);
eprintln!(
"decode {:>6} ms {}×{}",
t.elapsed().as_millis(),
raw.crop.width,
raw.crop.height
);
let t = std::time::Instant::now();
let mut s =
DevelopSession::open_owned(&ctx, raw, dr_types::Orientation::NORMAL).expect("open");
eprintln!(
"open {:>6} ms copy {:?}",
t.elapsed().as_millis(),
s.demosaiced.texture_size()
);
s.graph.set_param(
dr_pipeline::descriptor::OpId("exposure"),
dr_pipeline::descriptor::ParamId("exposure"),
0.5,
);
let t = std::time::Instant::now();
s.render(2400, 1300).expect("fit");
eprintln!("canvas at fit {:>6} ms", t.elapsed().as_millis());
s.graph.framing_mut().set_view(CropRect {
x: 0.5,
y: 0.4,
width: 0.1,
height: 0.2,
});
let t = std::time::Instant::now();
s.render(2400, 1300).expect("zoomed");
eprintln!("zoomed, cut {:>6} ms", t.elapsed().as_millis());
let t = std::time::Instant::now();
s.render(2400, 1300).expect("zoomed again");
eprintln!("zoomed, held {:>6} ms", t.elapsed().as_millis());
s.graph.framing_mut().set_view(CropRect::default());
let t = std::time::Instant::now();
let (tw, th, _) = s.render_thumbnail(512).expect("thumbnail");
eprintln!("thumbnail {:>6} ms {tw}×{th}", t.elapsed().as_millis());
let t = std::time::Instant::now();
let frame = s
.render_for_export(dr_types::ColourSpace::Srgb)
.expect("export");
eprintln!(
"export {:>6} ms {}×{}",
t.elapsed().as_millis(),
frame.width,
frame.height
);
let mean = frame
.rgba
.iter()
.step_by(4 * 997)
.map(|&v| v as f64)
.sum::<f64>()
/ (frame.rgba.len() / (4 * 997)) as f64;
eprintln!("mean red {mean:.1}");
}
} }
+54
View File
@@ -112,6 +112,19 @@ pub struct DevelopSession {
/// history still stands on that crop. See [`super::framing::CropNotice`]. /// history still stands on that crop. See [`super::framing::CropNotice`].
pub(super) crop_notice: Option<super::framing::CropNotice>, pub(super) crop_notice: Option<super::framing::CropNotice>,
pub(super) demosaiced: Arc<DemosaicedImage>, pub(super) demosaiced: Arc<DemosaicedImage>,
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The photograph at full resolution, when it is too large to hold in one
/// texture. `demosaiced` is then a reduced copy of it, which is all the
/// canvas needs at fit and all the histograms, masks and probes ever
/// read; a render that wants more detail than the copy has — the canvas
/// zoomed in, a tile of the export — cuts a window from this instead
/// (see `source_for`). `None` for every photograph that fits, which is
/// nearly all of them.
pub(super) full: Option<Arc<RawImage>>,
/// The last window cut from `full`: the region it covers in normalised
/// source coordinates, the reduction it was cut at, and the texture. Kept
/// so panning a zoomed canvas does not re-upload what is already there.
pub(super) window: Option<(CropRect, u32, Arc<DemosaicedImage>)>,
pub(super) adjust: AdjustPass, pub(super) adjust: AdjustPass,
/// TRACES: FR-DSP-7 /// TRACES: FR-DSP-7
/// Optional, because a session that cannot count its frames is still a /// Optional, because a session that cannot count its frames is still a
@@ -312,6 +325,16 @@ pub struct DevelopSession {
pub(super) display_space: dr_types::ColourSpace, pub(super) display_space: dr_types::ColourSpace,
} }
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The longest edge a photograph is developed at whole; one larger is
/// developed from a reduced copy and full-resolution windows.
///
/// Below the device's own limit on purpose. A 16384-texel texture of
/// half floats is two gigabytes, and the canvas never shows more than a few
/// thousand pixels of it; 8192 is four times a 4K long edge, the same bound
/// the JPEG path fits a film scan to.
pub const PROXY_EDGE: u32 = 8192;
impl DevelopSession { impl DevelopSession {
/// Demosaic an image and prepare its edit graph. /// Demosaic an image and prepare its edit graph.
/// ///
@@ -329,6 +352,35 @@ impl DevelopSession {
Ok(Self::with_source(ctx, demosaiced, orientation)) Ok(Self::with_source(ctx, demosaiced, orientation))
} }
/// TRACES: FR-DSP-2 | NFR-RES-2
/// Open a decoded photograph, whatever its size.
///
/// A photograph that fits the device goes through [`Self::open`] as it
/// always did. A linear DNG larger than [`PROXY_EDGE`] — a stitched
/// panorama — is instead held at full resolution on the CPU and opened
/// on a copy reduced to fit, from which the canvas, the histograms and
/// the masks work; the full resolution is cut into windows only where a
/// render needs it. Without this the file opened as its embedded preview
/// with develop withheld.
pub fn open_owned(
ctx: &GpuContext,
raw: RawImage,
orientation: dr_types::Orientation,
) -> Result<Self, String> {
let (w, h) = (raw.crop.width.max(1), raw.crop.height.max(1));
let edge = PROXY_EDGE.min(DemosaicedImage::max_dimension(ctx));
if raw.samples_per_pixel != 3 || w.max(h) <= edge {
return Self::open(ctx, &raw, orientation);
}
let reduce = w.max(h).div_ceil(edge);
log::info!("{w}×{h} is larger than one texture; developing from a 1/{reduce} copy");
let proxy = DemosaicedImage::linear_rgb16_window(ctx, &raw, [0, 0, w, h], reduce)
.map_err(|e| e.to_string())?;
let mut session = Self::with_source(ctx, proxy, orientation);
session.full = Some(Arc::new(raw));
Ok(session)
}
/// Prepare an edit graph over an already-processed RGB image. /// Prepare an edit graph over an already-processed RGB image.
/// ///
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor /// The JPEG path. A JPEG is already demosaiced, so there is no sensor
@@ -379,6 +431,8 @@ impl DevelopSession {
compared_snapshot: None, compared_snapshot: None,
crop_notice: None, crop_notice: None,
demosaiced: Arc::new(demosaiced), demosaiced: Arc::new(demosaiced),
full: None,
window: None,
adjust: AdjustPass::new(ctx), adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx) histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}")) .inspect_err(|e| log::warn!("no histogram on this device: {e}"))
+1 -1
View File
@@ -366,7 +366,7 @@ fn open_pixels(
decoder decoder
.decode(bytes) .decode(bytes)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
.and_then(|raw| DevelopSession::open(ctx, &raw, orientation)) .and_then(|raw| DevelopSession::open_owned(ctx, raw, orientation))
} }
/// Collect displayable images from file or directory arguments. /// Collect displayable images from file or directory arguments.