From 42d2298118f51451015cc92cdbf39accc9ff583c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:42:50 -0400 Subject: [PATCH 1/5] =?UTF-8?q?M5.3:=20stage=20cache=20=E2=80=94=20slider?= =?UTF-8?q?=20drags=20redo=20only=20the=20stages=20the=20setting=20feeds;?= =?UTF-8?q?=20draft=20quality=20during=20drags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pipeline: StageCache + render_cached: resampled source, white-balanced/retouched/denoised image and each spatial plane (log L, guided base, clarity, texture, dark channel + airlight) are keyed by exactly their inputs; keeps a draft-size and a full-size entry warm. - Exposure moved after the spatial filters (applied per pixel; planes shifted by EV): identical up to rounding (test: <= 2 LSB vs. exposing the source), so exposure drags hit the cache too. - engine: RenderJob::draft / with_stages; ui: loupe and before views own a StageCache, loupe renders at Draft quality while interacting. - engine example render_bench (min-of-N wall + process CPU time). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/engine/Cargo.toml | 3 + crates/engine/examples/render_bench.rs | 143 +++++++++++++++++++++ crates/engine/src/media.rs | 29 ++++- crates/pipeline/src/finish.rs | 19 +-- crates/pipeline/src/lib.rs | 169 ++++++++++++++++++++++--- crates/pipeline/src/local.rs | 88 ++++++++++--- crates/pipeline/src/masks.rs | 26 ++-- crates/pipeline/src/tests.rs | 76 +++++++++++ crates/ui-egui/src/panels/detail.rs | 1 + crates/ui-egui/src/render.rs | 14 +- 12 files changed, 510 insertions(+), 60 deletions(-) create mode 100644 crates/engine/examples/render_bench.rs diff --git a/Cargo.lock b/Cargo.lock index d8f36eab1..b52843184 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2078,6 +2078,7 @@ name = "lightcraft-engine" version = "0.1.0" dependencies = [ "ab_glyph", + "libc", "lightcraft-catalog", "lightcraft-codecs", "lightcraft-color", diff --git a/Cargo.toml b/Cargo.toml index 86780d7a3..f52003e69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ proptest = "1" bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" siphasher = "1" +libc = "0.2" # dev-only: process CPU time in benches # formats quick-xml = "0.38" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 39a9e2f5f..f075e50bb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,3 +25,6 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } ab_glyph = { workspace = true } + +[dev-dependencies] +libc = { workspace = true } diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs new file mode 100644 index 000000000..b2bb11ec0 --- /dev/null +++ b/crates/engine/examples/render_bench.rs @@ -0,0 +1,143 @@ +//! End-to-end render benchmark (min of N runs, robust to a loaded machine): +//! `cargo run --release -p lightcraft-engine --example render_bench -- corpus/raw/arw-sony-a7m3-compressed.arw` +//! (`N=9` runs per scenario, `RAYON_NUM_THREADS=1` for algorithmic comparisons, +//! `LIGHTCRAFT_PROFILE=1` for per-stage timings). +//! +//! Without a file argument a procedural 6000×4000 source is used. Scenarios: a ~2.5 MP loupe render +//! of the 2560 px preview (cold, and with a warm stage cache while a slider is dragged), a draft, +//! and a full-size render + JPEG encode (export). Prints minimum wall-clock and minimum process CPU +//! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores. +use std::time::Instant; + +use lightcraft_develop::DevelopSettings; +use lightcraft_engine::export::{ExportOptions, encode_image}; +use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo, StageCache, render, render_cached}; +use lightcraft_raster::Rgb32f; +use lightcraft_raster::resample::{Filter, fit}; + +/// Process CPU time in ms (all threads). The only `unsafe` is this libc clock read, in a dev-only example. +#[allow(unsafe_code)] +fn cpu_ms() -> f64 { + #[cfg(unix)] + { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: `clock_gettime` only writes into the timespec we pass. + unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, &mut ts) }; + ts.tv_sec as f64 * 1e3 + ts.tv_nsec as f64 / 1e6 + } + #[cfg(not(unix))] + { + 0.0 + } +} + +/// Minimum wall-clock and minimum CPU time (ms) over the runs. +struct T(f64, f64); + +impl std::fmt::Display for T { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.1} ms wall, {:.1} ms cpu", self.0, self.1) + } +} + +fn best(n: usize, mut f: impl FnMut()) -> T { + let mut b = T(f64::MAX, f64::MAX); + for _ in 0..n { + let (t, c) = (Instant::now(), cpu_ms()); + f(); + b.1 = b.1.min(cpu_ms() - c); + b.0 = b.0.min(t.elapsed().as_secs_f64() * 1e3); + } + b +} + +fn typical() -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.light.exposure = 0.3; + s.light.highlights = -40.0; + s.light.shadows = 30.0; + s.effects.clarity = 15.0; + s.effects.texture = 10.0; + s.effects.dehaze = 10.0; + s.detail.nr_luminance = 30.0; + s.detail.nr_color = 25.0; + s.detail.sharpen_amount = 40.0; + s +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5); + let only = std::env::var("ONLY").unwrap_or_default(); + let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); + let (full, info) = match args.first() { + Some(path) => { + let bytes = std::fs::read(path).expect("read"); + let t = Instant::now(); + let r = lightcraft_engine::files::load_bytes(&bytes, usize::MAX).expect("decode"); + println!("decode full: {:.0} ms", t.elapsed().as_secs_f64() * 1e3); + r + } + None => ( + Rgb32f::from_fn(6000, 4000, |x, y| { + let v = ((x as f32 * 0.011).sin() * (y as f32 * 0.007).cos() * 0.5 + 0.5) * 0.4 + ((x * 31 + y * 17) % 13) as f32 * 0.002; + [v, v * 0.9, v * 0.7] + }), + SourceInfo { raw: true, ..Default::default() }, + ), + }; + println!("source {}×{} ({:.1} MP), {n} runs each", full.width, full.height, (full.width * full.height) as f64 / 1e6); + let preview = std::sync::Arc::new(fit(&full, 2560, 2560, Filter::Box)); + let s = typical(); + let view = RenderRequest::fit(1920, 1280); + let draft = RenderRequest { max_w: 1152, max_h: 768, quality: Quality::Draft, apply_crop: true }; + + if run("cold") { + println!("loupe 1920×1280 cold: {}", best(n, || drop(render(&preview, &info, &s, &view)))); + println!("loupe draft 1152×768 cold: {}", best(n, || drop(render(&preview, &info, &s, &draft)))); + } + let cache = StageCache::default(); + let mut k = 0.0; + if run("drag") { + drop(render_cached(&preview, &info, &s, &view, &cache)); + let ms = best(n, || { + let mut t = s.clone(); + k += 1.0; + t.light.contrast = k; + t.light.exposure = 0.3 + k * 0.01; + drop(render_cached(&preview, &info, &t, &view, &cache)); + }); + println!("loupe 1920×1280 exposure drag (warm): {ms}"); + drop(render_cached(&preview, &info, &s, &draft, &cache)); + let ms = best(n, || { + let mut t = s.clone(); + k += 1.0; + t.light.highlights = -40.0 + k; + drop(render_cached(&preview, &info, &t, &draft, &cache)); + }); + println!("loupe draft highlights drag (warm): {ms}"); + let ms = best(n, || { + let mut t = s.clone(); + k += 1.0; + t.effects.clarity = 15.0 + k; + drop(render_cached(&preview, &info, &t, &draft, &cache)); + }); + println!("loupe draft clarity drag (warm): {ms}"); + let ms = best(n, || { + let mut t = s.clone(); + k += 1.0; + t.detail.nr_luminance = 30.0 + k; + drop(render_cached(&preview, &info, &t, &draft, &cache)); + }); + println!("loupe draft NR drag (warm): {ms}"); + } + if run("export") { + let big = RenderRequest::fit(full.width, full.height); + let ne = n.min(3); + let mut img = None; + let ms = best(ne, || img = Some(render(&full, &info, &s, &big).image)); + println!("export render {}×{}: {ms}", full.width, full.height); + let img = img.expect("rendered"); + println!("export JPEG encode: {}", best(ne, || drop(encode_image(&img, &ExportOptions::default())))); + } +} diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index c9cd303f2..0840895af 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; -use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo}; +use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; use lightcraft_raster::{Histogram, Rgb32f}; use serde::{Deserialize, Serialize}; @@ -174,6 +174,9 @@ pub struct RenderJob { pub key: u64, /// Rendered-thumbnail cache and this job's key in it. pub cache: Option<(Arc, Hash128)>, + /// Intermediate results of this view's previous renders (set by the frontend for the loupe): + /// slider drags then only redo the stages the changed setting feeds. + pub stages: Option>, } pub struct RenderResult { @@ -186,6 +189,21 @@ pub struct RenderResult { } impl RenderJob { + /// Render at draft quality (interactive drags). Gets its own result key. + pub fn draft(mut self) -> Self { + if self.request.quality != Quality::Draft { + self.request.quality = Quality::Draft; + self.key ^= 0x9e37_79b9_7f4a_7c15; + } + self + } + + /// Reuse `stages` across this view's renders. + pub fn with_stages(mut self, stages: Arc) -> Self { + self.stages = Some(stages); + self + } + pub fn run(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get(*key) @@ -197,7 +215,10 @@ impl RenderJob { let was_loaded = matches!(self.source, SourceRef::Loaded(_)); match self.source.load() { Ok(src) => { - let rendered = lightcraft_pipeline::render(&src, &self.info, &self.settings, &self.request); + let rendered = match &self.stages { + Some(st) => lightcraft_pipeline::render_cached(&src, &self.info, &self.settings, &self.request, st), + None => lightcraft_pipeline::render(&src, &self.info, &self.settings, &self.request), + }; if let Some((cache, key)) = &self.cache { cache.put(*key, Arc::new(rendered.image.clone())); } @@ -251,7 +272,7 @@ impl crate::Session { let level = SourceLevel::for_size(max_w.max(max_h)); let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() }; - let request = RenderRequest { max_w, max_h, quality: lightcraft_pipeline::Quality::Full, apply_crop }; + let request = RenderRequest { max_w, max_h, quality: Quality::Full, apply_crop }; let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) @@ -261,7 +282,7 @@ impl crate::Session { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); (self.media.rendered.clone(), k) }); - Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache }) + Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache, stages: None }) } /// Accept a finished job's loaded source into the cache. diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index ff874b8fc..601df578a 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -9,7 +9,7 @@ use lightcraft_raster::Rgba8; use crate::colorops::ColorOps; use crate::geometry::Frame; -use crate::local::{airlight, log_lum}; +use crate::local::log_lum; use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; @@ -119,7 +119,8 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf }; let sharpen = (s.detail.sharpen_amount / 150.0) as f32; let sharpen_mask = (s.detail.sharpen_masking / 100.0) as f32; - let air = p.dark.as_ref().map(airlight).unwrap_or(1.0); + // Planes are pre-exposure: scale the airlight, shift log luminance (see `Prepared`). + let (air, air_pre, gain, ev) = (p.air * p.gain, p.air, p.gain, p.ev); let grain = (s.grain.amount > 0.0 && s.section_enabled("effects")).then(|| { let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * p.px_per_long as f32; ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) @@ -131,8 +132,10 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf for_rows(&mut out.data, w, |y, row| { for (x, px) in row.iter_mut().enumerate() { let i = y * w + x; - let mut c = p.img.data[i]; - let l0 = p.log_l.data[i]; + let raw = p.img.data[i]; + let mut c = if gain == 1.0 { raw } else { raw.map(|v| v * gain) }; + let l_pre = p.log_l.data[i]; + let l0 = l_pre + ev; // --- local (mask) contributions let (mut l_exp, mut l_temp, mut l_tint, mut l_con, mut l_hl, mut l_sh, mut l_wh, mut l_bl) = (0.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); @@ -169,7 +172,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf if dz != 0.0 && let Some(dark) = &p.dark { - let d = (dark.data[i] / air).clamp(0.0, 1.0); + let d = (dark.data[i] / air_pre).clamp(0.0, 1.0); if dz > 0.0 { let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12); c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0)); @@ -194,7 +197,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf // --- local tone in log luminance let l1 = if dz != 0.0 || l_exp != 0.0 { log_lum(c) } else { l0 }; let shift = l1 - l0; - let base = p.base.data[i] + shift; + let base = p.base.data[i] + ev + shift; let mut delta = 0.0f32; let (hh, ss) = (hl + l_hl, sh + l_sh); if hh != 0.0 || ss != 0.0 { @@ -215,7 +218,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf if cl != 0.0 && let Some(b) = &p.clarity_blur { - let det = (l0 - b.data[i]).clamp(-2.5, 2.5); + let det = (l_pre - b.data[i]).clamp(-2.5, 2.5); let mid = (-(base / 3.2).powi(2)).exp(); delta += cl * 0.85 * det * (0.35 + 0.65 * mid); } @@ -224,7 +227,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf if (tx != 0.0 || sp != 0.0) && let Some(b) = &p.texture_blur { - let det = l0 - b.data[i]; + let det = l_pre - b.data[i]; let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs()); delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame; if sp != 0.0 { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index b137551ae..30d01acd8 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -14,6 +14,11 @@ //! //! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a //! 60 MP export look alike. +//! +//! Exposure is a gain, so the spatial stages run on the un-exposed image and the per-pixel stage +//! applies it (filters on log luminance are shift-equivariant: identical result). With +//! [`render_cached`] each stage's output is reused while its inputs are unchanged ([`StageCache`]): +//! dragging a tone, colour or exposure slider re-runs only the per-pixel stage. #![forbid(unsafe_code)] pub mod auto; @@ -27,6 +32,8 @@ pub mod spots; mod tone; use lightcraft_develop::{DevelopSettings, Treatment}; +use std::sync::{Arc, Mutex}; + use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows}; pub use tone::ToneMap; @@ -77,15 +84,24 @@ pub struct Rendered { } /// Everything the per-pixel stage needs, precomputed at output resolution. +/// +/// The image and planes are computed *before exposure* (so they can be reused while exposure is +/// dragged): the per-pixel stage multiplies the image by `gain` and shifts log planes by `ev`. +/// Spatial filters on log luminance are shift-equivariant, so this equals filtering after exposure. pub(crate) struct Prepared { - pub img: Rgb32f, - /// Log2 luminance relative to middle grey. - pub log_l: Plane, - pub base: Plane, - pub clarity_blur: Option, - pub texture_blur: Option, - pub dark: Option, + pub img: Arc, + /// Log2 luminance relative to middle grey (before exposure: add `ev`). + pub log_l: Arc, + pub base: Arc, + pub clarity_blur: Option>, + pub texture_blur: Option>, + pub dark: Option>, + /// Airlight of `dark` (before exposure). + pub air: f32, pub masks: Vec, + /// Exposure in EV and as a linear gain. + pub ev: f32, + pub gain: f32, /// Output pixels per unit of the source long edge. pub px_per_long: f64, } @@ -95,9 +111,89 @@ pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &Render geometry::Frame::new(src_w, src_h, s, req.apply_crop).fit(req.max_w, req.max_h) } +/// Intermediate results of recent renders of one view, reused by [`render_cached`]. +/// +/// Each stage is keyed by exactly the inputs it depends on: the resampled source by the source +/// buffer, framing and output size; the white-balanced, retouched, denoised image by those plus +/// white balance, spots and noise reduction; each spatial plane by that plus its own radius. So a +/// tone or colour slider drag (or exposure) re-runs only the per-pixel stage, a clarity drag skips +/// resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a +/// full-size render of the same view both stay warm). +pub struct StageCache { + entries: Mutex>, + capacity: usize, +} + +impl Default for StageCache { + fn default() -> Self { + StageCache { entries: Mutex::new(Vec::new()), capacity: 2 } + } +} + +impl StageCache { + /// Drop everything (e.g. when memory is needed). + pub fn clear(&self) { + self.lock().clear(); + } + + /// Number of cached output sizes. + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { + self.entries.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn get(&self, src: &Arc, geo: u64) -> Option { + self.lock().iter().find(|e| e.geo == geo && Arc::ptr_eq(&e.src, src)).cloned() + } + + fn put(&self, e: CacheEntry) { + let mut v = self.lock(); + v.retain(|o| !(o.geo == e.geo && Arc::ptr_eq(&o.src, &e.src))); + v.push(e); + while v.len() > self.capacity { + v.remove(0); + } + } +} + +#[derive(Clone)] +struct CacheEntry { + src: Arc, + geo: u64, + sampled: Arc, + lin: Option<(u64, Arc)>, + planes: local::Planes, +} + +fn hash_of(parts: impl std::hash::Hash) -> u64 { + use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher}; + BuildHasherDefault::::default().hash_one(parts) +} + /// Render `src` with settings `s`. pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered { - let prof = std::env::var_os("LIGHTCRAFT_PROFILE").is_some(); + render_impl(Src::Borrowed(src), info, s, req, None) +} + +/// [`render`], reusing (and refreshing) the intermediate results in `cache`. The output is +/// identical to [`render`]'s. +pub fn render_cached(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: &StageCache) -> Rendered { + render_impl(Src::Shared(src), info, s, req, Some(cache)) +} + +enum Src<'a> { + Borrowed(&'a Rgb32f), + Shared(&'a Arc), +} + +fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered { // `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling. let lap = |what: &str, t: &mut Option| { if let Some(t) = t { @@ -105,20 +201,59 @@ pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &Render *t = std::time::Instant::now(); } }; - let mut t = prof.then(std::time::Instant::now); + let mut t = profiling().then(std::time::Instant::now); let s = &*profiles::effective(s); - let frame = geometry::Frame::new(src.width, src.height, s, req.apply_crop); + let src_img: &Rgb32f = match &src { + Src::Borrowed(r) => r, + Src::Shared(a) => a, + }; + let frame = geometry::Frame::new(src_img.width, src_img.height, s, req.apply_crop); let (w, h) = frame.fit(req.max_w, req.max_h); - let mut img = frame.sample(src, w, h); - lap("sample", &mut t); let px_per_long = frame.px_per_long(w); + let src_long = src_img.width.max(src_img.height); - local::scene_linear_pre(&mut img, info, s); - spots::apply(&mut img, &s.spots, &frame, px_per_long); - local::denoise(&mut img, s, src.width.max(src.height), w.max(h)); - lap("wb/exposure/spots/nr", &mut t); - let prep = local::prepare(img, s, &frame, px_per_long, req.quality); + let shared = match (&src, cache) { + (Src::Shared(a), Some(c)) => Some((*a, c)), + _ => None, + }; + let geo = hash_of((format!("{frame:?}"), w, h)); + let cached = shared.and_then(|(a, c)| c.get(a, geo)); + let sampled = match &cached { + Some(e) => e.sampled.clone(), + None => Arc::new(frame.sample(src_img, w, h)), + }; + lap("sample", &mut t); + + let (wb_t, wb_tint) = local::effective_wb(info, s); + let d = &s.detail; + let lin_key = hash_of(( + geo, + [wb_t, wb_tint, info.as_shot_temp, info.as_shot_tint].map(f64::to_bits), + format!("{:?}", s.spots), + [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), + src_long, + )); + let lin = match cached.as_ref().and_then(|e| e.lin.clone()).filter(|(k, _)| *k == lin_key) { + Some((_, img)) => img, + None => { + // Without a cache the resampled buffer is ours: work on it in place. + let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) }; + local::white_balance(&mut img, info, s); + spots::apply(&mut img, &s.spots, &frame, px_per_long); + local::denoise(&mut img, s, src_long, w.max(h)); + Arc::new(img) + } + }; + lap("wb/spots/nr", &mut t); + let mut planes = match cached.map(|e| e.planes) { + Some(p) if p.key == lin_key => p, + _ => local::Planes { key: lin_key, ..Default::default() }, + }; + let prep = local::prepare(lin.clone(), s, &frame, px_per_long, req.quality, &mut planes); lap("prepare", &mut t); + if let Some((a, c)) = shared { + c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); + } let image = finish::finish(&prep, s, &frame, info); lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index b84a048ed..f1b612104 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -5,6 +5,8 @@ use lightcraft_color::cct::{temp_tint_to_xy, wb_matrix}; use lightcraft_color::{REC2020, luminance_2020}; use lightcraft_develop::DevelopSettings; use lightcraft_raster::blur::gaussian; +use std::sync::Arc; + use lightcraft_raster::{Plane, Rgb32f}; use crate::geometry::Frame; @@ -12,6 +14,15 @@ use crate::{Prepared, Quality, SourceInfo, for_rows, masks, timed}; /// White balance (relative to the source's as-shot white) and exposure, in place. pub fn scene_linear_pre(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings) { + wb_gain(img, info, s, 2f32.powf(s.light.exposure as f32)); +} + +/// White balance only (exposure is applied by the per-pixel stage, see [`crate::Prepared`]). +pub fn white_balance(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings) { + wb_gain(img, info, s, 1.0); +} + +fn wb_gain(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings, gain: f32) { let (t, tint) = effective_wb(info, s); let m = if (t - info.as_shot_temp).abs() < 1e-6 && (tint - info.as_shot_tint).abs() < 1e-6 { None @@ -24,7 +35,6 @@ pub fn scene_linear_pre(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings let y = g[0] * 0.2627 + g[1] * 0.6780 + g[2] * 0.0593; Some(m.mul(&lightcraft_color::Mat3::diag(1.0 / y, 1.0 / y, 1.0 / y)).to_f32()) }; - let gain = 2f32.powf(s.light.exposure as f32); let w = img.width; for_rows(&mut img.data, w, |_, row| { for p in row.iter_mut() { @@ -167,8 +177,35 @@ pub fn log_lum(c: [f32; 3]) -> f32 { (luminance_2020(c).max(1e-7) / crate::tone::GREY).log2() } -pub fn prepare(img: Rgb32f, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality) -> Prepared { - let log_l = timed("log_l", || img.map(log_lum)); +/// Spatial planes of one (pre-exposure) image, each tagged with the radius it was computed at. +/// `key` identifies the image; see [`crate::StageCache`]. +#[derive(Clone, Default)] +pub(crate) struct Planes { + pub key: u64, + pub log_l: Option>, + pub base: Option<(u32, Arc)>, + pub clarity: Option<(u32, Arc)>, + pub texture: Option<(u32, Arc)>, + /// Dark channel and its airlight. + pub dark: Option<(u32, Arc, f32)>, +} + +/// Reuse `slot` if it was computed at `sigma`, else compute and store it. +fn plane_at(slot: &mut Option<(u32, Arc)>, sigma: f32, f: impl FnOnce() -> Plane) -> Arc { + match slot { + Some((k, p)) if *k == sigma.to_bits() => p.clone(), + _ => { + let p = Arc::new(f()); + *slot = Some((sigma.to_bits(), p.clone())); + p + } + } +} + +/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), +/// reusing whatever `planes` already holds for it. +pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared { + let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); let ppl = px_per_long as f32; let tone_active = s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0); @@ -176,26 +213,41 @@ pub fn prepare(img: Rgb32f, s: &DevelopSettings, frame: &Frame, px_per_long: f64 // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved). let sigma = (0.015 * ppl).max(1.0); let sigma = if q == Quality::Draft { sigma.min(24.0) } else { sigma }; - timed("base", || guided_fast(&log_l, sigma, 0.35)) + plane_at(&mut planes.base, sigma, || timed("base", || guided_fast(&log_l, sigma, 0.35))) } else { log_l.clone() }; let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); - let clarity_blur = - (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| timed("clarity", || guided_fast(&log_l, (0.012 * ppl).max(1.0), 0.8))); - let texture_blur = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness)) - .then(|| timed("texture", || gaussian(&log_l, (0.0018 * ppl).max(0.6)))); - let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| { - let _t = crate::profiling().then(std::time::Instant::now); - let min_c = img.map(|c| c[0].min(c[1]).min(c[2])); - let d = gaussian(&min_c, (0.02 * ppl).max(1.0)); - if let Some(t) = _t { - eprintln!(" dehaze: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); - } - d + let clarity_blur = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| { + let sigma = (0.012 * ppl).max(1.0); + plane_at(&mut planes.clarity, sigma, || timed("clarity", || guided_fast(&log_l, sigma, 0.8))) }); - let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l)); - Prepared { img, log_l, base, clarity_blur, texture_blur, dark, masks, px_per_long } + let texture_blur = + (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness)).then(|| { + let sigma = (0.0018 * ppl).max(0.6); + plane_at(&mut planes.texture, sigma, || timed("texture", || gaussian(&log_l, sigma))) + }); + let (dark, air) = if s.effects.dehaze != 0.0 || local_any(|a| a.dehaze) { + let sigma = (0.02 * ppl).max(1.0); + match &planes.dark { + Some((k, d, air)) if *k == sigma.to_bits() => (Some(d.clone()), *air), + _ => { + let d = timed("dehaze", || { + let min_c = img.map(|c| c[0].min(c[1]).min(c[2])); + Arc::new(gaussian(&min_c, sigma)) + }); + let air = airlight(&d); + planes.dark = Some((sigma.to_bits(), d.clone(), air)); + (Some(d), air) + } + } + } else { + (None, 1.0) + }; + let ev = s.light.exposure as f32; + let gain = 2f32.powf(ev); + let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev)); + Prepared { img, log_l, base, clarity_blur, texture_blur, dark, air, masks, ev, gain, px_per_long } } /// Airlight estimate: bright end of the dark channel. diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 910f28740..bd27f90f7 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -21,7 +21,7 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 { t * t * (3.0 - 2.0 * t) } -pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane) -> Vec { +pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec { masks .iter() .filter(|m| m.visible && !m.components.is_empty()) @@ -29,7 +29,7 @@ pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, let mut alpha = Plane::new(w, h); let mut first = true; for comp in &m.components { - let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l); + let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev); if comp.invert { c.data.iter_mut().for_each(|v| *v = 1.0 - *v); } @@ -71,9 +71,11 @@ fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(P }); } -pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane) -> Plane { +pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane { let mut out = Plane::new(w, h); let to_long = |p: Point| frame.norm_to_long(p); + // `img`/`log_l` are before exposure: range masks select on the exposed values. + let gain = ev.exp2(); match shape { MaskShape::Linear { start, end } => { let (a, b) = (to_long(*start), to_long(*end)); @@ -103,7 +105,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3)); for (v, l) in out.data.iter_mut().zip(&log_l.data) { // map log luminance to a 0..1 perceptual scale (−8..+4 EV) - let y = ((l + 8.0) / 12.0).clamp(0.0, 1.0); + let y = ((l + ev + 8.0) / 12.0).clamp(0.0, 1.0); *v = smooth(lo - lf, lo, y) * (1.0 - smooth(hi, hi + hf, y)); } } @@ -111,7 +113,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R let tol = 0.04 + 0.16 * (*refine as f32 / 100.0); let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect(); for (v, c) in out.data.iter_mut().zip(&img.data) { - let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(*c)); + let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain))); let d = samples .iter() .map(|s| ((lab[1] - s[1]).powi(2) + (lab[2] - s[2]).powi(2) + 0.25 * (lab[0] - s[0]).powi(2)).sqrt()) @@ -126,8 +128,8 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R for (i, v) in out.data.iter_mut().enumerate() { let (x, y) = (i % w, i / w); let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let c = img.data[i]; - let l = log_l.data[i]; + let c = img.data[i].map(|v| v * gain); + let l = log_l.data[i] + ev; let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4); let top = 1.0 - smooth(0.25, 0.7, n.y as f32); *v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l))); @@ -148,7 +150,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R smooth_plane(&mut out, 0.015 * frame_px(frame, w)); } MaskShape::Background => { - let mut s = shape_alpha(&MaskShape::Subject, frame, w, h, img, log_l); + let mut s = shape_alpha(&MaskShape::Subject, frame, w, h, img, log_l, ev); s.data.iter_mut().for_each(|v| *v = 1.0 - *v); out = s; } @@ -230,7 +232,7 @@ mod tests { let f = frame(100, 50); let img = Rgb32f::new(100, 50); let l = Plane::new(100, 50); - let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l); + let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0); assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); assert!((a.get(50, 25) - 0.5).abs() < 0.05); } @@ -241,10 +243,10 @@ mod tests { let img = Rgb32f::new(100, 100); let l = Plane::new(100, 100); let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; - let a = shape_alpha(&shape, &f, 100, 100, &img, &l); + let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0); assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); let m = Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: true, shape }], ..Default::default() }; - let e = evaluate(&[m], &f, 100, 100, &img, &l); + let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0); assert!(e[0].alpha.get(50, 50) < 0.01); } @@ -259,7 +261,7 @@ mod tests { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], ..Default::default() }; - let e = evaluate(&[m], &f, 200, 100, &img, &l); + let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0); let a = &e[0].alpha; assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50)); assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 13bcb467a..e52cdf6a3 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -149,3 +149,79 @@ fn unedited_rendered_source_is_passthrough() { } assert!(worst <= 3, "max error {worst}"); } + +fn typical_edits() -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.light.exposure = 0.4; + s.light.highlights = -40.0; + s.light.shadows = 30.0; + s.effects.clarity = 15.0; + s.effects.texture = 10.0; + s.effects.dehaze = 12.0; + s.detail.nr_luminance = 30.0; + s.detail.nr_color = 25.0; + s.detail.sharpen_amount = 40.0; + s +} + +fn max_diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> i32 { + assert_eq!((a.width, a.height), (b.width, b.height)); + a.data.iter().zip(&b.data).map(|(p, q)| (0..3).map(|c| (p[c] as i32 - q[c] as i32).abs()).max().unwrap_or(0)).max().unwrap_or(0) +} + +#[test] +fn stage_cache_matches_uncached_render_through_a_slider_session() { + use crate::{Quality, StageCache, render_cached}; + use std::sync::Arc; + let src = Arc::new(scene()); + let info = SourceInfo { raw: true, ..Default::default() }; + let cache = StageCache::default(); + let full = RenderRequest::fit(200, 200); + let draft = RenderRequest { max_w: 120, max_h: 120, quality: Quality::Draft, apply_crop: true }; + let mut s = typical_edits(); + // every kind of edit: tone, exposure, spatial amounts, NR, WB, crop, colour — at two sizes + let steps: Vec> = vec![ + Box::new(|s| s.light.contrast = 20.0), + Box::new(|s| s.light.exposure = -0.7), + Box::new(|s| s.light.highlights = -80.0), + Box::new(|s| s.effects.clarity = -30.0), + Box::new(|s| s.effects.dehaze = 0.0), + Box::new(|s| s.effects.dehaze = 25.0), + Box::new(|s| s.detail.nr_luminance = 60.0), + Box::new(|s| { + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = 4000.0; + }), + Box::new(|s| s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.1, 0.9, 0.8)), + Box::new(|s| s.color.saturation = 30.0), + ]; + for (i, step) in steps.iter().enumerate() { + step(&mut s); + for req in [&full, &draft] { + let a = render_cached(&src, &info, &s, req, &cache).image; + let b = render(&src, &info, &s, req).image; + assert_eq!(a, b, "step {i} {:?}", req.quality); + } + } + assert_eq!(cache.len(), 2); + // a different source buffer never hits another source's entries + let other = Arc::new(Rgb32f::filled(src.width, src.height, [0.3, 0.3, 0.3])); + assert_eq!(render_cached(&other, &info, &s, &full, &cache).image, render(&other, &info, &s, &full).image); +} + +#[test] +fn exposure_after_spatial_filters_equals_exposing_the_source() { + // The pipeline filters the image before exposure and applies exposure per pixel; filters on + // log luminance are shift-equivariant, so this must equal scaling the source. + let src = scene(); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut s = typical_edits(); + s.light.exposure = 1.3; + let a = render(&src, &info, &s, &RenderRequest::fit(200, 200)).image; + let g = 2f32.powf(1.3); + let scaled = src.map(|c| c.map(|v| v * g)); + s.light.exposure = 0.0; + let b = render(&scaled, &info, &s, &RenderRequest::fit(200, 200)).image; + let d = max_diff(&a, &b); + assert!(d <= 2, "max difference {d}"); +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 361c14f8a..54ca151a1 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -122,6 +122,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, !crop_tool) { + let job = if interacting { job.draft() } else { job }; app.renderer.request(Slot::Main, job, 100); } let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash)); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index ecfe694b3..aa2ebee4c 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -11,8 +11,10 @@ use std::collections::HashMap; use lightcraft_catalog::PhotoId; use lightcraft_engine::Session; use lightcraft_engine::media::{RenderJob, RenderResult}; +use lightcraft_engine::pipeline::StageCache; use lightcraft_preview::JobPool; use lightcraft_raster::Histogram; +use std::sync::Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Slot { @@ -39,12 +41,21 @@ pub struct Renderer { pub last_main_ms: f64, /// Jobs finished since start (for inspect/perf). pub completed: u64, + /// Per-view intermediate results (loupe and "before"), so slider drags redo only what changed. + stages: HashMap>, } impl Default for Renderer { fn default() -> Self { let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; - Renderer { pool: JobPool::new(threads), pending: HashMap::new(), textures: HashMap::new(), last_main_ms: 0.0, completed: 0 } + Renderer { + pool: JobPool::new(threads), + pending: HashMap::new(), + textures: HashMap::new(), + last_main_ms: 0.0, + completed: 0, + stages: HashMap::new(), + } } } @@ -67,6 +78,7 @@ impl Renderer { } self.pending.insert(slot, (job.key, priority)); let key = job.key; + let job = if matches!(slot, Slot::Main | Slot::Before) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; self.pool.submit(slot, key, priority, Box::new(move || job.run())); } From 0892148bab7119990201226d9f2446f5fdf726a5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:57:02 -0400 Subject: [PATCH 2/5] M5.3: faster per-pixel stage and row-parallel guided filter - finish: sRGB encode through an interpolated 4096-entry table (< 2e-5 from the exact curve, tested), exp2 instead of powf(2, x). - guided / guided_fast: coefficient and output passes run row-parallel (were serial loops); Image::zip_map. - blur_bench reports process CPU time next to wall-clock (meaningful on a loaded machine). - RENDER_CACHE_VERSION 3 (exposure-after-filters and the table change pixels by <= 2 LSB). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/src/media.rs | 2 +- crates/pipeline/src/finish.rs | 49 +++++++++++++++++++-- crates/pipeline/src/local.rs | 65 ++++++++++++---------------- crates/raster/Cargo.toml | 1 + crates/raster/examples/blur_bench.rs | 40 ++++++++++++----- crates/raster/src/lib.rs | 16 +++++++ 7 files changed, 121 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b52843184..cdf92bed5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2158,6 +2158,7 @@ dependencies = [ name = "lightcraft-raster" version = "0.1.0" dependencies = [ + "libc", "lightcraft-color", "lightcraft-geom", "proptest", diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 0840895af..cac58cc29 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -20,7 +20,7 @@ use lightcraft_raster::{Histogram, Rgb32f}; use serde::{Deserialize, Serialize}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 2; +pub const RENDER_CACHE_VERSION: u64 = 3; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 601df578a..196a75b2c 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -128,6 +128,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf let out_to_norm = frame.out_to_norm(w, h); let aspect = w as f32 / h as f32; + let srgb = srgb_lut(); let mut out = Rgba8::new(w, h); for_rows(&mut out.data, w, |y, row| { for (x, px) in row.iter_mut().enumerate() { @@ -184,7 +185,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf // --- local exposure / temp / tint if l_exp != 0.0 { - let g = 2f32.powf(l_exp); + let g = l_exp.exp2(); c = c.map(|v| v * g); } if l_temp != 0.0 || l_tint != 0.0 { @@ -236,7 +237,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf } } if delta != 0.0 { - let g = 2f32.powf(delta); + let g = delta.exp2(); c = c.map(|v| v * g); } @@ -305,7 +306,7 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf } // --- encode, curves, grain - let mut e = r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))); + let mut e = r.map(|v| encode_srgb(srgb, v)); if let Some(l) = &curves { e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; } @@ -325,8 +326,50 @@ pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInf out } +const SRGB_LUT_N: usize = 4096; + +fn srgb_lut() -> &'static [f32; SRGB_LUT_N + 1] { + static LUT: std::sync::OnceLock> = std::sync::OnceLock::new(); + LUT.get_or_init(|| { + let mut t = Box::new([0.0f32; SRGB_LUT_N + 1]); + for (i, v) in t.iter_mut().enumerate() { + *v = linear_to_srgb(i as f32 / SRGB_LUT_N as f32); + } + t + }) +} + +/// Linear → sRGB-encoded (clamped to 0..1) by an interpolated table: within 2e-5 of the exact +/// curve (≪ one 8-bit or 10-bit step), several times faster than `powf`. +#[inline] +fn encode_srgb(lut: &[f32; SRGB_LUT_N + 1], v: f32) -> f32 { + let f = v.clamp(0.0, 1.0) * SRGB_LUT_N as f32; + let i = (f as usize).min(SRGB_LUT_N - 1); + let t = f - i as f32; + lut[i] + (lut[i + 1] - lut[i]) * t +} + #[inline] fn enc(v: f32) -> u8 { // `v` is already sRGB-encoded; round to 8 bits. (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn srgb_table_matches_the_exact_curve() { + let lut = srgb_lut(); + let mut worst = 0.0f32; + for i in 0..=200_000 { + // dense near black, where the curve bends most + let v = (i as f32 / 200_000.0).powi(3); + worst = worst.max((encode_srgb(lut, v) - linear_to_srgb(v)).abs()); + } + assert!(worst < 2e-5, "{worst}"); + assert_eq!(encode_srgb(lut, -1.0), 0.0); + assert!((encode_srgb(lut, 2.0) - 1.0).abs() < 1e-6); + } +} diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index f1b612104..faaacf766 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -63,23 +63,31 @@ pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) { /// Self-guided filter (He et al.) on a single plane with Gaussian windows of `sigma` px. pub fn guided(p: &Plane, sigma: f32, eps: f32) -> Plane { + let (ma, mb) = guided_coeffs(p, sigma, eps); + guided_apply(p, &ma, &mb) +} + +/// Blurred guided-filter coefficients (mean a, mean b) of `p`; every pass row-parallel. +fn guided_coeffs(p: &Plane, sigma: f32, eps: f32) -> (Plane, Plane) { let mean = gaussian(p, sigma); - let sq = p.map(|v| v * v); - let corr = gaussian(&sq, sigma); - let mut a = Plane::new(p.width, p.height); - let mut b = Plane::new(p.width, p.height); - for i in 0..p.len() { - let var = (corr.data[i] - mean.data[i] * mean.data[i]).max(0.0); - let ai = var / (var + eps); - a.data[i] = ai; - b.data[i] = mean.data[i] - ai * mean.data[i]; - } - let ma = gaussian(&a, sigma); - let mb = gaussian(&b, sigma); + let corr = gaussian(&p.map(|v| v * v), sigma); + let a = corr.zip_map(&mean, |c, m| { + let var = (c - m * m).max(0.0); + var / (var + eps) + }); + let b = mean.zip_map(&a, |m, a| m - a * m); + (gaussian(&a, sigma), gaussian(&b, sigma)) +} + +/// `q = a·p + b` per pixel. +fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane { let mut q = Plane::new(p.width, p.height); - for i in 0..p.len() { - q.data[i] = ma.data[i] * p.data[i] + mb.data[i]; - } + let w = p.width; + for_rows(&mut q.data, w, |y, row| { + for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) { + *v = av * pv + bv; + } + }); q } @@ -94,28 +102,9 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { } let (lw, lh) = (p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1)); let lo = resize(p, lw, lh, Filter::Box); - let ls = sigma / s as f32; - let mean = gaussian(&lo, ls); - let corr = gaussian(&lo.map(|v| v * v), ls); - let mut a = Plane::new(lw, lh); - let mut b = Plane::new(lw, lh); - for i in 0..lo.len() { - let var = (corr.data[i] - mean.data[i] * mean.data[i]).max(0.0); - let ai = var / (var + eps); - a.data[i] = ai; - b.data[i] = mean.data[i] - ai * mean.data[i]; - } - let ma = resize(&gaussian(&a, ls), p.width, p.height, Filter::Bilinear); - let mb = resize(&gaussian(&b, ls), p.width, p.height, Filter::Bilinear); - let mut q = Plane::new(p.width, p.height); - let w = p.width; - for_rows(&mut q.data, w, |y, row| { - for (x, v) in row.iter_mut().enumerate() { - let i = y * w + x; - *v = ma.data[i] * p.data[i] + mb.data[i]; - } - }); - q + let (ma, mb) = guided_coeffs(&lo, sigma / s as f32, eps); + let up = |c: &Plane| resize(c, p.width, p.height, Filter::Bilinear); + guided_apply(p, &up(&ma), &up(&mb)) } /// Noise reduction at output resolution: luminance via an edge-aware self-guided filter on @@ -140,7 +129,7 @@ pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: for (x, p) in row.iter_mut().enumerate() { let i = y * w + x; let d = (f.data[i] - l.data[i]) * k; - let g = 2f32.powf(d); + let g = d.exp2(); *p = p.map(|v| v * g); } }); diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index bb59b3486..57dbd9390 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -20,3 +20,4 @@ parallel = ["dep:rayon"] [dev-dependencies] proptest = { workspace = true } +libc = { workspace = true } diff --git a/crates/raster/examples/blur_bench.rs b/crates/raster/examples/blur_bench.rs index 0a222a336..0bad9f6ef 100644 --- a/crates/raster/examples/blur_bench.rs +++ b/crates/raster/examples/blur_bench.rs @@ -1,16 +1,34 @@ -//! Micro-benchmark for the blur/map primitives (min of N runs, robust to a loaded machine): +//! Micro-benchmark for the blur/map primitives (min of N runs, wall-clock and process CPU time, so +//! it stays meaningful on a loaded machine): //! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-raster --example blur_bench`. use lightcraft_raster::{Plane, Rgb32f, blur::gaussian}; use std::time::Instant; -fn best(n: usize, mut f: impl FnMut()) -> f64 { - (0..n) - .map(|_| { - let t = Instant::now(); - f(); - t.elapsed().as_secs_f64() * 1e3 - }) - .fold(f64::MAX, f64::min) +/// Process CPU time in ms (all threads). The only `unsafe` is this libc clock read, in a dev-only example. +#[allow(unsafe_code)] +fn cpu_ms() -> f64 { + #[cfg(unix)] + { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: `clock_gettime` only writes into the timespec we pass. + unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, &mut ts) }; + ts.tv_sec as f64 * 1e3 + ts.tv_nsec as f64 / 1e6 + } + #[cfg(not(unix))] + { + 0.0 + } +} + +fn best(n: usize, mut f: impl FnMut()) -> String { + let (mut wall, mut cpu) = (f64::MAX, f64::MAX); + for _ in 0..n { + let (t, c) = (Instant::now(), cpu_ms()); + f(); + cpu = cpu.min(cpu_ms() - c); + wall = wall.min(t.elapsed().as_secs_f64() * 1e3); + } + format!("{wall:.1} ms wall, {cpu:.1} ms cpu") } fn main() { @@ -18,7 +36,7 @@ fn main() { let p = Plane::from_fn(w, h, |x, y| ((x * 7 + y * 13) % 255) as f32 / 255.0); let c = Rgb32f::from_fn(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, 0.5]); for sigma in [1.0f32, 4.0, 24.0] { - println!("plane gaussian σ={sigma}: {:.1} ms", best(9, || drop(gaussian(&p, sigma)))); + println!("plane gaussian σ={sigma}: {}", best(9, || drop(gaussian(&p, sigma)))); } - println!("rgb gaussian σ=4: {:.1} ms", best(9, || drop(gaussian(&c, 4.0)))); + println!("rgb gaussian σ=4: {}", best(9, || drop(gaussian(&c, 4.0)))); } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 3cc311ed4..4e358ee10 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -98,6 +98,22 @@ impl Image { }); out } + /// Per-pixel combination of two images of the same size (row-parallel). + pub fn zip_map(&self, other: &Image, f: impl Fn(T, U) -> V + Sync + Send) -> Image + where + T: Sync, + { + assert_eq!((self.width, self.height), (other.width, other.height), "zip_map: size mismatch"); + let mut out = Image::::new(self.width, self.height); + let w = self.width; + par_rows(&mut out.data, w, |y, row| { + let (a, b) = (&self.data[y * w..(y + 1) * w], &other.data[y * w..(y + 1) * w]); + for ((o, s), t) in row.iter_mut().zip(a).zip(b) { + *o = f(*s, *t); + } + }); + out + } /// Crop to `[x0, x0+w) × [y0, y0+h)` (clamped to the image). pub fn crop(&self, x0: usize, y0: usize, w: usize, h: usize) -> Self { let x0 = x0.min(self.width); From 452521a16f3b6eb2deb0e4ffe741689834e012d7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:03:48 -0400 Subject: [PATCH 3/5] M5.1: full-resolution source level for exports larger than a preview Exports above 2560 px were rendered from the 2560 px preview proxy and upsampled. SourceLevel::Full (native size; procedural demo scenes at their nominal size) is now selected for outputs larger than a preview; the media cache keeps only the last full-size original. render_bench gains a batch-export scenario (decode + render + JPEG per file). Co-Authored-By: Claude Opus 5.5 --- crates/engine/examples/render_bench.rs | 20 +++++++- crates/engine/src/media.rs | 63 ++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index b2bb11ec0..2a94b9fa3 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -5,7 +5,8 @@ //! //! Without a file argument a procedural 6000×4000 source is used. Scenarios: a ~2.5 MP loupe render //! of the 2560 px preview (cold, and with a warm stage cache while a slider is dragged), a draft, -//! and a full-size render + JPEG encode (export). Prints minimum wall-clock and minimum process CPU +//! and a full-size render + JPEG encode (export); `ONLY=batch` with several files times a full-size +//! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU //! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores. use std::time::Instant; @@ -131,6 +132,23 @@ fn main() { }); println!("loupe draft NR drag (warm): {ms}"); } + if run("batch") && args.len() > 1 { + // full-size JPEG export of all the files given (decode + render + encode) + use lightcraft_engine::export::export_photo; + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import"); + let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect(); + let items: Vec<_> = ids.iter().enumerate().map(|(i, id)| (*id, i + 1)).collect(); + let o = ExportOptions::default(); + let ms = best(n.min(3), || { + for &(id, seq) in &items { + drop(export_photo(&mut session, id, &o, seq).expect("export")); + session.media.forget(id); // as in a batch: every original is decoded once + } + }); + println!("batch export of {} files (decode+render+JPEG): {ms}", items.len()); + return; + } if run("export") { let big = RenderRequest::fit(full.width, full.height); let ne = n.min(3); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index cac58cc29..8eb66332c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1,7 +1,8 @@ //! Source proxies and render jobs. //! //! Sources are decoded (or generated) once per level and cached in memory LRUs: `Thumb` (≤ 512 px -//! long edge) for the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe. A [`RenderJob`] is +//! long edge) for the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe, `Full` (native +//! resolution, the last one only) for exports larger than a preview. A [`RenderJob`] is //! self-contained and `Send`: frontends run it on a worker thread; if the source wasn't cached yet //! the job loads it and hands it back in the [`RenderResult`] so the cache can keep it. //! @@ -36,6 +37,8 @@ pub const DISK_CACHE_BYTES: u64 = 2 << 30; pub enum SourceLevel { Thumb, Preview, + /// The original at its native resolution (exports larger than a preview). + Full, } impl SourceLevel { @@ -43,11 +46,16 @@ impl SourceLevel { match self { SourceLevel::Thumb => 512, SourceLevel::Preview => 2560, + SourceLevel::Full => usize::MAX, } } /// Smallest level that can serve an output of `long_edge` pixels. pub fn for_size(long_edge: usize) -> SourceLevel { - if long_edge <= 520 { SourceLevel::Thumb } else { SourceLevel::Preview } + match long_edge { + 0..=520 => SourceLevel::Thumb, + 521..=2560 => SourceLevel::Preview, + _ => SourceLevel::Full, + } } } @@ -78,6 +86,8 @@ pub struct MediaCache { /// Decoded thumbnail-level sources (LRU by bytes). thumbs: Lru>, previews: Vec<(PhotoId, Arc)>, + /// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP). + full: Option<(PhotoId, Arc)>, /// How many previews to keep (LRU). pub preview_capacity: usize, pub file_loader: Option, @@ -92,6 +102,7 @@ impl Default for MediaCache { MediaCache { thumbs: Lru::new(THUMB_SOURCE_BYTES), previews: Vec::new(), + full: None, preview_capacity: 0, file_loader: None, file_probe: None, @@ -111,6 +122,7 @@ impl MediaCache { match level { SourceLevel::Thumb => self.thumbs.get(&id).cloned(), SourceLevel::Preview => self.previews.iter().find(|(p, _)| *p == id).map(|(_, a)| a.clone()), + SourceLevel::Full => self.full.as_ref().filter(|(p, _)| *p == id).map(|(_, a)| a.clone()), } } @@ -128,6 +140,7 @@ impl MediaCache { self.previews.remove(0); } } + SourceLevel::Full => self.full = Some((id, img)), } } @@ -135,6 +148,9 @@ impl MediaCache { pub fn forget(&mut self, id: PhotoId) { self.thumbs.remove(&id); self.previews.retain(|(p, _)| *p != id); + if self.full.as_ref().is_some_and(|(p, _)| *p == id) { + self.full = None; + } } /// (decoded thumbnail sources, bytes). @@ -146,17 +162,22 @@ impl MediaCache { if let Some(a) = self.get(p.id, level) { return SourceRef::Loaded(a); } + // Procedural scenes have a nominal size: "full" is that size, not unbounded. + let max_edge = level.max_edge().min(match (&p.source, level) { + (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, + _ => usize::MAX, + }); match &p.source { Source::Demo { scene } => { if self.scenes.is_empty() { self.scenes = lightcraft_scenes::demo_library(); } match self.scenes.iter().find(|s| s.id == *scene) { - Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge: level.max_edge() }, - None => SourceRef::File { path: format!("demo:{scene}"), max_edge: level.max_edge(), loader: None }, + Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, + None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None }, } } - Source::File { path } => SourceRef::File { path: path.clone(), max_edge: level.max_edge(), loader: self.file_loader.clone() }, + Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone() }, } } } @@ -273,11 +294,7 @@ impl crate::Session { let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() }; let request = RenderRequest { max_w, max_h, quality: Quality::Full, apply_crop }; - let key = settings.hash64() - ^ ((max_w as u64) << 40) - ^ ((max_h as u64) << 20) - ^ (apply_crop as u64) - ^ ((level == SourceLevel::Preview) as u64) << 60; + let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60; let cache = thumb_bucket.map(|b| { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); (self.media.rendered.clone(), k) @@ -325,3 +342,29 @@ pub struct ProbeInfo { } pub type FileProbe = Arc Result + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn levels_cover_sizes_and_large_exports_use_the_original() { + assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); + assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview); + assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full); + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(long > 2560, "demo photos are camera-sized"); + // a full-size export reads the source at its native size, not an upscaled preview + let job = s.render_job(p.id, long, long, false, true).unwrap(); + assert_eq!(job.level, SourceLevel::Full); + match &job.source { + SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long), + _ => panic!("demo source expected"), + } + let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); + assert_eq!(loupe.level, SourceLevel::Preview); + assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); + } +} From 74b94e089a43317ad2a504221a848cb9f5e8708b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 02:01:25 -0400 Subject: [PATCH 4/5] M5.3: compute independent spatial planes side by side The guided base, clarity band, texture band and dark channel (and the paired blurs inside the guided filter) are independent; each alone scales poorly (subsampled grids, short rows), so they now run under raster::par_join. Same arithmetic, identical pixels. render_bench gains a per-feature breakdown of the per-pixel stage (ONLY=finish). Co-Authored-By: Claude Opus 5.5 --- crates/engine/examples/render_bench.rs | 31 ++++++++++ crates/pipeline/src/local.rs | 84 ++++++++++++++------------ crates/raster/src/lib.rs | 13 ++++ 3 files changed, 90 insertions(+), 38 deletions(-) diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 2a94b9fa3..1fbbb969a 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -132,6 +132,37 @@ fn main() { }); println!("loupe draft NR drag (warm): {ms}"); } + if run("finish") { + // per-pixel stage cost by feature (warm cache: exposure changes only) + let variants: [(&str, fn(&mut DevelopSettings)); 6] = [ + ("typical", |_| {}), + ("- dehaze", |t| t.effects.dehaze = 0.0), + ("- clarity", |t| t.effects.clarity = 0.0), + ("- texture/sharpen", |t| { + t.effects.texture = 0.0; + t.detail.sharpen_amount = 0.0; + }), + ("- highlights/shadows", |t| { + t.light.highlights = 0.0; + t.light.shadows = 0.0; + }), + ("+ vibrance/saturation", |t| { + t.color.vibrance = 20.0; + t.color.saturation = 10.0; + }), + ]; + for (name, f) in variants { + let mut t = s.clone(); + f(&mut t); + drop(render_cached(&preview, &info, &t, &view, &cache)); + let ms = best(n, || { + k += 1.0; + t.light.exposure = 0.3 + k * 0.001; + drop(render_cached(&preview, &info, &t, &view, &cache)); + }); + println!("finish 1920×1280 {name:<22} {ms}"); + } + } if run("batch") && args.len() > 1 { // full-size JPEG export of all the files given (decode + render + encode) use lightcraft_engine::export::export_photo; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index faaacf766..27bb1d0df 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -7,7 +7,7 @@ use lightcraft_develop::DevelopSettings; use lightcraft_raster::blur::gaussian; use std::sync::Arc; -use lightcraft_raster::{Plane, Rgb32f}; +use lightcraft_raster::{Plane, Rgb32f, par_join}; use crate::geometry::Frame; use crate::{Prepared, Quality, SourceInfo, for_rows, masks, timed}; @@ -67,16 +67,16 @@ pub fn guided(p: &Plane, sigma: f32, eps: f32) -> Plane { guided_apply(p, &ma, &mb) } -/// Blurred guided-filter coefficients (mean a, mean b) of `p`; every pass row-parallel. +/// Blurred guided-filter coefficients (mean a, mean b) of `p`; every pass row-parallel, and the +/// two independent blurs of each step run side by side. fn guided_coeffs(p: &Plane, sigma: f32, eps: f32) -> (Plane, Plane) { - let mean = gaussian(p, sigma); - let corr = gaussian(&p.map(|v| v * v), sigma); + let (mean, corr) = par_join(|| gaussian(p, sigma), || gaussian(&p.map(|v| v * v), sigma)); let a = corr.zip_map(&mean, |c, m| { let var = (c - m * m).max(0.0); var / (var + eps) }); let b = mean.zip_map(&a, |m, a| m - a * m); - (gaussian(&a, sigma), gaussian(&b, sigma)) + par_join(|| gaussian(&a, sigma), || gaussian(&b, sigma)) } /// `q = a·p + b` per pixel. @@ -192,46 +192,54 @@ fn plane_at(slot: &mut Option<(u32, Arc)>, sigma: f32, f: impl FnOnce() - } /// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure), -/// reusing whatever `planes` already holds for it. +/// reusing whatever `planes` already holds for it. Missing planes are computed side by side (each +/// one alone scales poorly: the guided filters work on small subsampled grids). pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared { let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone(); let ppl = px_per_long as f32; + let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); let tone_active = s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0); - let base = if tone_active { - // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved). + // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved). + let base_sigma = tone_active.then(|| { let sigma = (0.015 * ppl).max(1.0); - let sigma = if q == Quality::Draft { sigma.min(24.0) } else { sigma }; - plane_at(&mut planes.base, sigma, || timed("base", || guided_fast(&log_l, sigma, 0.35))) - } else { - log_l.clone() - }; - let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); - let clarity_blur = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| { - let sigma = (0.012 * ppl).max(1.0); - plane_at(&mut planes.clarity, sigma, || timed("clarity", || guided_fast(&log_l, sigma, 0.8))) + if q == Quality::Draft { sigma.min(24.0) } else { sigma } }); - let texture_blur = - (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness)).then(|| { - let sigma = (0.0018 * ppl).max(0.6); - plane_at(&mut planes.texture, sigma, || timed("texture", || gaussian(&log_l, sigma))) - }); - let (dark, air) = if s.effects.dehaze != 0.0 || local_any(|a| a.dehaze) { - let sigma = (0.02 * ppl).max(1.0); - match &planes.dark { - Some((k, d, air)) if *k == sigma.to_bits() => (Some(d.clone()), *air), - _ => { - let d = timed("dehaze", || { - let min_c = img.map(|c| c[0].min(c[1]).min(c[2])); - Arc::new(gaussian(&min_c, sigma)) - }); - let air = airlight(&d); - planes.dark = Some((sigma.to_bits(), d.clone(), air)); - (Some(d), air) - } - } - } else { - (None, 1.0) + let clarity_sigma = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0)); + let texture_sigma = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness)) + .then(|| (0.0018 * ppl).max(0.6)); + let dark_sigma = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); + + let Planes { base: sb, clarity: sc, texture: st, dark: sd, .. } = planes; + let l = &log_l; + let (base, (clarity_blur, (texture_blur, dark))) = par_join( + || base_sigma.map(|sg| plane_at(sb, sg, || timed("base", || guided_fast(l, sg, 0.35)))), + || { + par_join( + || clarity_sigma.map(|sg| plane_at(sc, sg, || timed("clarity", || guided_fast(l, sg, 0.8)))), + || { + par_join( + || texture_sigma.map(|sg| plane_at(st, sg, || timed("texture", || gaussian(l, sg)))), + || { + dark_sigma.map(|sg| match sd { + Some((k, d, air)) if *k == sg.to_bits() => (d.clone(), *air), + _ => { + let d = timed("dehaze", || Arc::new(gaussian(&img.map(|c| c[0].min(c[1]).min(c[2])), sg))); + let air = airlight(&d); + *sd = Some((sg.to_bits(), d.clone(), air)); + (d, air) + } + }) + }, + ) + }, + ) + }, + ); + let base = base.unwrap_or_else(|| log_l.clone()); + let (dark, air) = match dark { + Some((d, air)) => (Some(d), air), + None => (None, 1.0), }; let ev = s.light.exposure as f32; let gain = 2f32.powf(ev); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 4e358ee10..15f3e940c 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -29,6 +29,19 @@ pub fn par_rows(data: &mut [T], row_len: usize, f: impl Fn(usize, &mut } } +/// Run `a` and `b` potentially in parallel (sequentially without the `parallel` feature). For +/// independent passes that each scale poorly on their own (small images, short rows). +pub fn par_join(a: impl FnOnce() -> A + Send, b: impl FnOnce() -> B + Send) -> (A, B) { + #[cfg(feature = "parallel")] + { + rayon::join(a, b) + } + #[cfg(not(feature = "parallel"))] + { + (a(), b()) + } +} + /// Interleaved row-major image. #[derive(Clone, Debug, PartialEq)] pub struct Image { From 4195556551710c2b244deae21d8560bb4fe9d063 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 02:01:33 -0400 Subject: [PATCH 5/5] M5.2: GPU pipeline design note (wgpu compute, CPU oracle, stage-cache mapping) Co-Authored-By: Claude Opus 5.5 --- docs/gpu-pipeline.md | 67 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/gpu-pipeline.md diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md new file mode 100644 index 000000000..94b99179d --- /dev/null +++ b/docs/gpu-pipeline.md @@ -0,0 +1,67 @@ +# GPU develop pipeline — design note (M5.2, not implemented yet) + +The CPU pipeline (`crates/pipeline`) is the reference ("oracle"). A `lightcraft-gpu` crate (L3) will +evaluate the same stages with wgpu compute shaders (WGSL), on native (Metal/Vulkan/DX12) and on the +web (WebGPU). This note records the plan so the CPU work in M5 stays compatible with it. + +## Why, and what the CPU path already does +- CPU numbers (24 MP source, 2560 px preview, ~2.5 MP loupe; see `crates/engine/examples/render_bench.rs`): + the per-pixel stage (`finish`) costs ~50 ns/px of CPU time with typical edits (+~65 ns/px with + vibrance/saturation: the OkLCh round trip), noise reduction + spatial planes another ~80 ns/px + when they have to be recomputed. With all cores and the stage cache this meets the CPU budgets + for tone/colour drags (~7 ms draft, ~18 ms full at 2.5 MP); a GPU makes cold full-quality + renders, NR drags, 4–8K displays and 1:1 zoom of 60 MP files interactive, and frees the CPU for + previews/import. +- The CPU path is already structured the way the GPU path wants it: + - **Stage cache** (`StageCache`): resampled source → white-balanced/denoised image → spatial + planes (log luminance, guided base, clarity/texture bands, dark channel) → per-pixel stage. Each + stage is keyed by the settings that feed it. On the GPU the same keys decide which textures are + still valid; a tone/colour/exposure drag re-runs one full-screen compute pass. + - **Exposure after the spatial filters** (shift-equivariant on log luminance), so exposure is a + per-pixel uniform, not a cache invalidation. + - **Draft quality** during drags (smaller output, capped base radius), full on release. + +## Architecture +- `lightcraft-gpu` owns a `wgpu::Device` (shared with eframe's when running in the desktop app, so + the loupe texture never leaves the GPU; headless device for CLI/MCP/export). +- Resident per-photo state: the source pyramid as `rgba16float` textures (levels /2, /4, … down to + the thumbnail), uploaded once per photo; the stage textures mirror `StageCache` entries + (`r32float` planes, `rgba16float` images). +- Kernels (one WGSL module each, workgroups of 16×16, storage textures): + 1. `sample`: orientation + crop/rotate + flips from the nearest pyramid level (bilinear on the + level ≥ output scale — matches the CPU's area-prefiltered resample within tolerance). + 2. `wb`: 3×3 matrix (+ spots as instanced quads: copy/blend from the source offset). + 3. `nr`: guided filter on log luminance (box sums via two-pass separable running sums in shared + memory, or a summed-area table for large radii) + chroma Gaussian. + 4. `planes`: log luminance; guided base / clarity (fast guided filter: coefficients on a /s + subsampled grid, bilinear upsample — exactly the CPU `guided_fast` structure); texture + Gaussian (3 box passes); dark channel Gaussian + airlight (parallel reduction: histogram of the + dark channel → 99.5th percentile). + 5. `masks`: one pass per mask shape (analytic shapes evaluate per pixel; brushes rasterized as + instanced soft discs; range masks read the image/planes). + 6. `finish`: the per-pixel stage, a straight port of `finish.rs` (tone LUT and curve LUTs as 1D + textures, colour ops in OkLCh, gamut map, sRGB encode, grain hash). Output `rgba8unorm` for + display, `rgba16float`/`rgba32float` for export. + 7. `histogram`: atomics into a storage buffer (256 bins × 4). +- Readback only for export and for `ui.screenshot`/MCP renders. + +## Correctness: CPU oracle and equivalence tests +- Every kernel has a CPU twin; tests render the synthetic scenes (`lightcraft-scenes`) on both and + require max |Δ| ≤ 1/1023 in display-encoded values (architecture §1.6), plus the existing pipeline + tests run against the GPU renderer behind a feature flag. Tests skip (not fail) when no adapter is + available (CI machines), with a software adapter (wgpu's GL/lavapipe) where possible. +- Float determinism: avoid fast-math-sensitive constructs (fused multiply-add differences are within + tolerance); reductions (airlight, histogram) use integer atomics. + +## Scheduling +- One render queue per view; a new revision cancels queued work for the old one (CPU already drops + superseded jobs per slot). Drafts during drags at display size (GPU can afford full size), full + quality (larger NR/guided radii at source resolution for 1:1) on release. +- Fallback order: GPU → CPU (`render_cached`) when no adapter, on device loss, or for wasm builds + without WebGPU. + +## Open questions +- f16 vs f32 storage for planes (bandwidth vs. guided-filter precision: start with f32). +- Sharing the device with eframe on the web (single WebGPU device per canvas). +- Large exports (60 MP+) exceed texture limits on some adapters (8192/16384): tile with overlap + equal to the largest filter radius (the planes' radii are known per settings).