Merge M5 CPU performance (stage cache, full-res source level, parallel planes)

Integrated with M4 optics/Upright: render_impl resolves Upright and uses frame_for (lens + auto CA)
on the cached source; defringe runs in the cached linear stage and optics settings are part of its key.
Stage-cache equivalence test extended with optics/geometry steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 02:05:14 -04:00
commit 179aa90f01
16 files changed
+832 -133

No files matched your search

Generated
+2
View File
@@ -2079,6 +2079,7 @@ name = "lightcraft-engine"
version = "0.1.0"
dependencies = [
"ab_glyph",
"libc",
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
@@ -2158,6 +2159,7 @@ dependencies = [
name = "lightcraft-raster"
version = "0.1.0"
dependencies = [
"libc",
"lightcraft-color",
"lightcraft-geom",
"proptest",
+1
View File
@@ -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"
+3
View File
@@ -25,3 +25,6 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
ab_glyph = { workspace = true }
[dev-dependencies]
libc = { workspace = true }
+192
View File
@@ -0,0 +1,192 @@
//! 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); `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;
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<String> = 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("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;
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);
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()))));
}
}
+79 -15
View File
@@ -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.
//!
@@ -14,13 +15,13 @@ 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};
/// 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];
@@ -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<PhotoId, Arc<Rgb32f>>,
previews: Vec<(PhotoId, Arc<Rgb32f>)>,
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
full: Option<(PhotoId, Arc<Rgb32f>)>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
pub file_loader: Option<FileLoader>,
@@ -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() },
}
}
}
@@ -174,6 +195,9 @@ pub struct RenderJob {
pub key: u64,
/// Rendered-thumbnail cache and this job's key in it.
pub cache: Option<(Arc<PreviewCache>, 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<Arc<StageCache>>,
}
pub struct RenderResult {
@@ -186,6 +210,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<StageCache>) -> 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 +236,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()));
}
@@ -255,17 +297,13 @@ 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 key = settings.hash64()
^ ((max_w as u64) << 40)
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ ((level == SourceLevel::Preview) as u64) << 60;
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 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)
});
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.
@@ -312,3 +350,29 @@ pub struct ProbeInfo {
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + 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);
}
}
+57 -11
View File
@@ -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)
@@ -127,12 +128,15 @@ 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() {
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 +173,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));
@@ -181,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 {
@@ -194,7 +198,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 +219,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 +228,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 {
@@ -233,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);
}
@@ -302,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])];
}
@@ -322,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<Box<[f32; SRGB_LUT_N + 1]>> = 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);
}
}
+156 -19
View File
@@ -15,6 +15,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;
@@ -31,6 +36,8 @@ pub mod transform;
pub mod upright;
use lightcraft_develop::{DevelopSettings, Treatment};
use std::sync::{Arc, Mutex};
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows};
pub use tone::ToneMap;
@@ -83,15 +90,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<Plane>,
pub texture_blur: Option<Plane>,
pub dark: Option<Plane>,
pub img: Arc<Rgb32f>,
/// Log2 luminance relative to middle grey (before exposure: add `ev`).
pub log_l: Arc<Plane>,
pub base: Arc<Plane>,
pub clarity_blur: Option<Arc<Plane>>,
pub texture_blur: Option<Arc<Plane>>,
pub dark: Option<Arc<Plane>>,
/// Airlight of `dark` (before exposure).
pub air: f32,
pub masks: Vec<masks::Evaluated>,
/// 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,
}
@@ -110,9 +126,89 @@ pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_cro
frame
}
/// 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<Vec<CacheEntry>>,
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<CacheEntry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
fn get(&self, src: &Arc<Rgb32f>, geo: u64) -> Option<CacheEntry> {
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<Rgb32f>,
geo: u64,
sampled: Arc<Rgb32f>,
lin: Option<(u64, Arc<Rgb32f>)>,
planes: local::Planes,
}
fn hash_of(parts: impl std::hash::Hash) -> u64 {
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
BuildHasherDefault::<DefaultHasher>::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<Rgb32f>, 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<Rgb32f>),
}
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<std::time::Instant>| {
if let Some(t) = t {
@@ -120,22 +216,63 @@ 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 s = &*upright::resolve(src, info, s);
let frame = frame_for(src, info, s, req.apply_crop);
let src_img: &Rgb32f = match &src {
Src::Borrowed(r) => r,
Src::Shared(a) => a,
};
let s = &*upright::resolve(src_img, info, s);
let frame = frame_for(src_img, info, 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);
optics::defringe(&mut img, s, px_per_long / optics::DEFRINGE_REF_LONG);
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),
// defringe runs in this stage
format!("{:?}", s.optics),
[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);
optics::defringe(&mut img, s, px_per_long / optics::DEFRINGE_REF_LONG);
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);
+114 -65
View File
@@ -5,13 +5,24 @@ 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 lightcraft_raster::{Plane, Rgb32f};
use std::sync::Arc;
use lightcraft_raster::{Plane, Rgb32f, par_join};
use crate::geometry::Frame;
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() {
@@ -53,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 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 (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, 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, 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);
par_join(|| 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
}
@@ -84,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
@@ -130,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);
}
});
@@ -167,35 +166,85 @@ 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<Arc<Plane>>,
pub base: Option<(u32, Arc<Plane>)>,
pub clarity: Option<(u32, Arc<Plane>)>,
pub texture: Option<(u32, Arc<Plane>)>,
/// Dark channel and its airlight.
pub dark: Option<(u32, Arc<Plane>, f32)>,
}
/// Reuse `slot` if it was computed at `sigma`, else compute and store it.
fn plane_at(slot: &mut Option<(u32, Arc<Plane>)>, sigma: f32, f: impl FnOnce() -> Plane) -> Arc<Plane> {
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. 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<Rgb32f>, 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 };
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
if q == Quality::Draft { sigma.min(24.0) } else { sigma }
});
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 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);
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.
+14 -12
View File
@@ -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<Evaluated> {
pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec<Evaluated> {
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));
+81
View File
@@ -149,3 +149,84 @@ 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<Box<dyn Fn(&mut DevelopSettings)>> = 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),
// optics and geometry (merged after the stage cache): defringe lives in the cached linear stage
Box::new(|s| s.optics.defringe_purple_amount = 10.0),
Box::new(|s| s.optics.distortion = 30.0),
Box::new(|s| s.optics.remove_ca = true),
Box::new(|s| s.geometry.vertical = 20.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}");
}
+1
View File
@@ -20,3 +20,4 @@ parallel = ["dep:rayon"]
[dev-dependencies]
proptest = { workspace = true }
libc = { workspace = true }
+29 -11
View File
@@ -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))));
}
+29
View File
@@ -29,6 +29,19 @@ pub fn par_rows<T: Send>(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: Send, B: Send>(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<T> {
@@ -98,6 +111,22 @@ impl<T: Copy + Default> Image<T> {
});
out
}
/// Per-pixel combination of two images of the same size (row-parallel).
pub fn zip_map<U: Copy + Default + Sync, V: Copy + Default + Send>(&self, other: &Image<U>, f: impl Fn(T, U) -> V + Sync + Send) -> Image<V>
where
T: Sync,
{
assert_eq!((self.width, self.height), (other.width, other.height), "zip_map: size mismatch");
let mut out = Image::<V>::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);
+1
View File
@@ -143,6 +143,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));
+6
View File
@@ -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 {
@@ -44,6 +46,8 @@ pub struct Renderer {
/// Keep a CPU copy of every texture so the UI can be rasterized headlessly (set when the app
/// is driven by the control channel).
pub keep_pixels: bool,
/// Per-view intermediate results (loupe and "before"), so slider drags redo only what changed.
stages: HashMap<Slot, Arc<StageCache>>,
}
impl Default for Renderer {
@@ -56,6 +60,7 @@ impl Default for Renderer {
last_main_ms: 0.0,
completed: 0,
keep_pixels: false,
stages: HashMap::new(),
}
}
}
@@ -79,6 +84,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()));
}
+67
View File
@@ -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).