mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
commit
179aa90f01
16 files changed
+832
-133
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -25,3 +25,6 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
libc = { workspace = true }
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -20,3 +20,4 @@ parallel = ["dep:rayon"]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user