From 6fb0d22326b3e1f0c4720cf6f809334760992d00 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Wed, 14 Oct 2026 12:56:59 +0200 Subject: [PATCH] LR-MERGE-HDR: decode full-size merge frames one at a time; AHD writes its tiles straight into the picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 7-frame bracket of 67 MP raws ran out of memory: the merge decoded the frames four at a time, and the AHD demosaic collected every tile before copying them into a second full-size picture. Full-size frames now decode one after the other (the demosaic is parallel inside; previews stay parallel), and AHD fills the picture by rows of tiles. Peak memory of 67 MP HDR merges: 3 frames 4.8 → 3.8 GB, 4 frames 6.3 → 4.7 GB, about the same time; AHD output is pixel-identical. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/merge.rs | 26 ++++++++++++-------------- crates/raw/src/demosaic/ahd.rs | 25 +++++++++++++------------ docs/merge.md | 3 +++ 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9..01000f429 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -105,20 +105,18 @@ impl MergeJob { use rayon::prelude::*; let done = std::sync::atomic::AtomicUsize::new(0); let n = self.sources.len(); - let frames: std::result::Result, String> = self - .sources - .par_iter() - .map(|(_, path)| { - let bytes = (self.read)(path)?; - // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) - let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) - .map_err(|e| format!("{path}: {e}")); - let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); - f - }) - .collect(); - frames + let one = |(_, path): &(PhotoId, String)| { + let bytes = (self.read)(path)?; + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .map_err(|e| format!("{path}: {e}")); + let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); + f + }; + // Full-size photos are decoded one at a time (the demosaic is parallel inside): decoding several 60 MP raws at + // once multiplied the decoders' scratch memory, and a 7-frame bracket ran out of memory. + if self.preview.is_some() { self.sources.par_iter().map(one).collect() } else { self.sources.iter().map(one).collect() } } /// Run the merge. `progress(fraction, stage)` returns false to cancel. diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 3f17413ff..d97b56215 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -19,20 +19,21 @@ const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); - let tiles: Vec<(usize, usize)> = (0..h.div_ceil(TILE)).flat_map(|ty| (0..w.div_ceil(TILE)).map(move |tx| (tx * TILE, ty * TILE))).collect(); - let results: Vec<(usize, usize, usize, usize, Vec<[f32; 3]>)> = tiles - .par_iter() - .map(|&(x0, y0)| { - let (tw, th) = (TILE.min(w - x0), TILE.min(h - y0)); - (x0, y0, tw, th, tile(m, x0, y0, tw, th)) - }) - .collect(); let mut out = Rgb32f::new(w, h); - for (x0, y0, tw, th, px) in results { - for y in 0..th { - out.data[(y0 + y) * w + x0..(y0 + y) * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); - } + if w == 0 || h == 0 { + return out; } + // one row of tiles per task, written straight into the picture (collecting the tiles first held a second copy) + out.data.par_chunks_mut(w * TILE).enumerate().for_each(|(ty, band)| { + let (y0, th) = (ty * TILE, band.len() / w); + for x0 in (0..w).step_by(TILE) { + let tw = TILE.min(w - x0); + let px = tile(m, x0, y0, tw, th); + for y in 0..th { + band[y * w + x0..y * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); + } + } + }); out } diff --git a/docs/merge.md b/docs/merge.md index 537ab0f2c..c3d86fb8b 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -74,4 +74,7 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) - *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its caption also lists them. - Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- HDR memory: every frame is held as 32-bit float RGB (~0.86 GB per 67 MP frame at the peak, plus ~1.2 GB); full-size + photos are decoded one at a time. Measured on 67 MP raws: 3 frames 3.8 GB, 4 frames 4.7 GB, 5 frames 5.5 GB (was + 4.8 / 6.3 GB for 3 / 4). Half-float frames would halve the per-frame share. - The browser build has no file access for merges yet.