diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml new file mode 100644 index 00000000..ff997b5c --- /dev/null +++ b/crates/pipeline/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "lightcraft-pipeline" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft develop pipeline: scene-referred, float, resolution independent (CPU reference) (L2)" + +[lints] +workspace = true + +[dependencies] +lightcraft-geom = { workspace = true } +lightcraft-color = { workspace = true } +lightcraft-raster = { workspace = true } +lightcraft-develop = { workspace = true } +rayon = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +lightcraft-scenes = { workspace = true } +proptest = { workspace = true } +image = { workspace = true } diff --git a/crates/pipeline/examples/develop_sheet.rs b/crates/pipeline/examples/develop_sheet.rs new file mode 100644 index 00000000..f5d2787d --- /dev/null +++ b/crates/pipeline/examples/develop_sheet.rs @@ -0,0 +1,37 @@ +//! Render a few scenes with default and edited settings side by side (PNG, via the pipeline). +//! `cargo run --release -p lightcraft-pipeline --example develop_sheet -- out.png` +use lightcraft_develop::DevelopSettings; +use lightcraft_pipeline::{RenderRequest, SourceInfo, render}; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "develop.png".into()); + let lib = lightcraft_scenes::demo_library(); + let picks = [0usize, 2, 3, 6, 8, 5]; + let (cw, ch) = (420usize, 280usize); + let mut sheet = image::RgbImage::new((cw * 2) as u32, (ch * picks.len()) as u32); + for (row, &i) in picks.iter().enumerate() { + let src = lib[i].render_fit(900); + let mut edited = DevelopSettings::default(); + edited.light.shadows = 45.0; + edited.light.highlights = -55.0; + edited.light.contrast = 15.0; + edited.effects.clarity = 25.0; + edited.effects.dehaze = 12.0; + edited.color.vibrance = 30.0; + edited.vignette.amount = -25.0; + edited.grading.shadows = lightcraft_develop::Wheel { hue: 210.0, sat: 18.0, lum: 0.0 }; + edited.grading.highlights = lightcraft_develop::Wheel { hue: 40.0, sat: 14.0, lum: 0.0 }; + for (col, s) in [DevelopSettings::default(), edited].iter().enumerate() { + let r = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(cw, ch)).image; + let (ox, oy) = (col * cw + (cw - r.width) / 2, row * ch + (ch - r.height) / 2); + for y in 0..r.height { + for x in 0..r.width { + let p = r.get(x, y); + sheet.put_pixel((ox + x) as u32, (oy + y) as u32, image::Rgb([p[0], p[1], p[2]])); + } + } + } + } + sheet.save(&out).unwrap(); + println!("{out}"); +} diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs new file mode 100644 index 00000000..f2e86203 --- /dev/null +++ b/crates/pipeline/src/auto.rs @@ -0,0 +1,136 @@ +//! Auto tone and auto white balance (histogram / grey-world statistics on a proxy). + +use lightcraft_color::cct::xy_to_temp_tint; +use lightcraft_color::{REC2020, Xy, bradford, luminance_2020}; +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::Rgb32f; +use serde::Serialize; + +use crate::SourceInfo; +use crate::local::effective_wb; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)] +pub struct AutoTone { + pub exposure: f64, + pub contrast: f64, + pub highlights: f64, + pub shadows: f64, + pub whites: f64, + pub blacks: f64, + pub vibrance: f64, + pub saturation: f64, +} + +fn percentile(sorted: &[f32], q: f32) -> f32 { + if sorted.is_empty() { + return 0.0; + } + sorted[((sorted.len() - 1) as f32 * q.clamp(0.0, 1.0)) as usize] +} + +/// Compute auto tone values for `src` under the current white balance (ignores current tone values). +pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTone { + let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box); + let mut base = DevelopSettings { wb: s.wb, ..DevelopSettings::default() }; + base.light.exposure = 0.0; + crate::local::scene_linear_pre(&mut img, info, &base); + let mut ev: Vec = img.data.iter().map(|c| (luminance_2020(*c).max(1e-6) / 0.18).log2()).collect(); + ev.sort_by(|a, b| a.total_cmp(b)); + let median = percentile(&ev, 0.5); + let exposure = (-median * 0.85 - 0.1).clamp(-4.0, 4.0); + let (p01, p05, p95, p995) = + (percentile(&ev, 0.01) + exposure, percentile(&ev, 0.05) + exposure, percentile(&ev, 0.95) + exposure, percentile(&ev, 0.995) + exposure); + let highlights = if p995 > 2.2 { -((p995 - 2.2) * 38.0).min(90.0) } else { 0.0 }; + let shadows = if p05 < -4.0 { ((-4.0 - p05) * 22.0).min(70.0) } else { 0.0 }; + let spread = p95 - p05; + let contrast = ((6.5 - spread) * 6.0).clamp(-20.0, 30.0); + let whites = if p995 < 1.8 { ((1.8 - p995) * 25.0).min(40.0) } else { -((p995 - 3.5).max(0.0) * 10.0).min(30.0) }; + let blacks = if p01 > -5.0 { -((p01 + 5.0) * 10.0).min(35.0) } else { ((-7.0 - p01).max(0.0) * 8.0).min(20.0) }; + AutoTone { + exposure: (exposure as f64 * 100.0).round() / 100.0, + contrast: contrast.round() as f64, + highlights: highlights.round() as f64, + shadows: shadows.round() as f64, + whites: whites.round() as f64, + blacks: blacks.round() as f64, + vibrance: 12.0, + saturation: 3.0, + } +} + +/// Grey-world white balance weighted towards mid-tone, low-chroma pixels. Returns (temp, tint). +pub fn auto_wb(src: &Rgb32f, info: &SourceInfo) -> (f64, f64) { + let img = lightcraft_raster::resample::fit(src, 256, 256, lightcraft_raster::resample::Filter::Box); + let (mut acc, mut wsum) = ([0.0f64; 3], 0.0f64); + for c in &img.data { + let y = luminance_2020(*c); + if !(0.01..=2.0).contains(&y) { + continue; + } + let mx = c[0].max(c[1]).max(c[2]); + let mn = c[0].min(c[1]).min(c[2]); + let chroma = (mx - mn) / (mx + 1e-6); + let w = (1.0 - chroma).powi(2) as f64 * (1.0 - ((y.log2() + 2.5) / 4.0).abs().min(1.0)) as f64; + for i in 0..3 { + acc[i] += c[i] as f64 * w; + } + wsum += w; + } + if wsum <= 0.0 { + return (info.as_shot_temp, info.as_shot_tint); + } + let avg = acc.map(|v| v / wsum); + let xyz = REC2020.to_xyz().apply(avg); + let shot = lightcraft_color::cct::temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint); + let seen = bradford(REC2020.white, shot).apply(xyz); + let (t, tint) = xy_to_temp_tint(Xy::from_xyz(seen)); + (t.clamp(2000.0, 50000.0).round(), tint.clamp(-150.0, 150.0).round()) +} + +/// Temperature/tint currently in effect (for UI display). +pub fn current_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) { + effective_wb(info, s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dark_image_gets_positive_exposure() { + let img = Rgb32f::from_fn(64, 64, |x, _| [0.01 + x as f32 * 0.0003; 3]); + let a = auto_tone(&img, &SourceInfo::default(), &DevelopSettings::default()); + assert!(a.exposure > 1.5, "{a:?}"); + let bright = Rgb32f::from_fn(64, 64, |x, _| [0.8 + x as f32 * 0.01; 3]); + let b = auto_tone(&bright, &SourceInfo::default(), &DevelopSettings::default()); + assert!(b.exposure < -1.0, "{b:?}"); + } + + #[test] + fn neutral_image_keeps_as_shot_wb() { + let img = Rgb32f::from_fn(32, 32, |x, y| [0.05 + (x + y) as f32 * 0.004; 3]); + let (t, tint) = auto_wb(&img, &SourceInfo::default()); + assert!((t - 6500.0).abs() < 150.0, "{t}"); + assert!(tint.abs() < 6.0, "{tint}"); + } + + #[test] + fn blue_cast_is_corrected_by_higher_temp() { + // A bluish cast should be neutralised by telling the pipeline the light was bluer (higher K). + let img = Rgb32f::from_fn(32, 32, |_, _| [0.16, 0.18, 0.24]); + let (t, _) = auto_wb(&img, &SourceInfo::default()); + assert!(t > 7000.0, "{t}"); + let mut s = DevelopSettings::default(); + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = t; + let (t2, tint2) = auto_wb(&img, &SourceInfo::default()); + let _ = (t2, tint2); + let mut out = img.clone(); + let (tt, ti) = auto_wb(&img, &SourceInfo::default()); + s.wb.temp = tt; + s.wb.tint = ti; + crate::local::scene_linear_pre(&mut out, &SourceInfo::default(), &s); + let c = out.get(0, 0); + assert!((c[0] - c[2]).abs() < 0.02, "{c:?}"); + } +} diff --git a/crates/pipeline/src/colorops.rs b/crates/pipeline/src/colorops.rs new file mode 100644 index 00000000..b271e560 --- /dev/null +++ b/crates/pipeline/src/colorops.rs @@ -0,0 +1,226 @@ +//! Colour tools in OkLCh on display-linear Rec.2020 values: vibrance, saturation, the 8-band colour +//! mixer, B&W mix, and 3-way colour grading. + +use std::f32::consts::{PI, TAU}; +use std::sync::OnceLock; + +use lightcraft_color::perceptual::{hsv_to_rgb, lab_to_lch, lch_to_lab, oklab_from_2020, oklab_to_2020}; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_develop::{DevelopSettings, MIXER_HUES}; + +/// OkLCh hue angle (radians) of a pure sRGB colour with HSV hue `deg`. +pub fn oklch_hue_of_srgb_hue(deg: f64) -> f32 { + let c = hsv_to_rgb(deg as f32, 1.0, 1.0); + let lin = c.map(lightcraft_color::transfer::srgb_to_linear); + let m = SRGB.to_space(&REC2020).apply_f32(lin); + lab_to_lch(oklab_from_2020(m))[2] +} + +fn band_hues() -> &'static [f32; 8] { + static H: OnceLock<[f32; 8]> = OnceLock::new(); + H.get_or_init(|| MIXER_HUES.map(oklch_hue_of_srgb_hue)) +} + +#[inline] +fn wrap(a: f32) -> f32 { + (a + PI).rem_euclid(TAU) - PI +} + +/// Partition-of-unity weights of hue `h` over the 8 bands (raised cosine between neighbours). +#[inline] +pub fn band_weights(h: f32) -> [f32; 8] { + let hues = band_hues(); + let mut w = [0.0f32; 8]; + for i in 0..8 { + let a = hues[i]; + let b = hues[(i + 1) % 8]; + let span = wrap(b - a).rem_euclid(TAU); + let d = wrap(h - a).rem_euclid(TAU); + if d <= span { + let t = d / span; + let s = 0.5 - 0.5 * (t * PI).cos(); + w[i] += 1.0 - s; + w[(i + 1) % 8] += s; + break; + } + } + w +} + +#[derive(Clone, Copy)] +struct WheelK { + a: f32, + b: f32, + lum: f32, +} + +pub struct ColorOps { + vibrance: f32, + saturation: f32, + hue: [f32; 8], + sat: [f32; 8], + lum: [f32; 8], + mixer: bool, + bw: Option<[f32; 8]>, + grading: Option<([WheelK; 4], f32, f32)>, + skin: f32, +} + +fn wheel(w: &lightcraft_develop::Wheel) -> WheelK { + let h = oklch_hue_of_srgb_hue(w.hue); + let s = (w.sat / 100.0) as f32 * 0.09; + WheelK { a: s * h.cos(), b: s * h.sin(), lum: (w.lum / 100.0) as f32 * 0.12 } +} + +impl ColorOps { + pub fn new(s: &DevelopSettings) -> ColorOps { + let bands = s.mixer.bands(); + let g = &s.grading; + ColorOps { + vibrance: (s.color.vibrance / 100.0) as f32, + saturation: (s.color.saturation / 100.0) as f32, + hue: bands.map(|b| (b.hue / 100.0) as f32 * 0.5), + sat: bands.map(|b| (b.sat / 100.0) as f32), + lum: bands.map(|b| (b.lum / 100.0) as f32 * 0.18), + mixer: !s.mixer.is_neutral(), + bw: crate::is_bw(s).then(|| s.bw_mix.bands().map(|v| (v / 100.0) as f32)), + grading: (!g.is_neutral()).then(|| { + ( + [wheel(&g.shadows), wheel(&g.midtones), wheel(&g.highlights), wheel(&g.global)], + (g.blending / 100.0) as f32, + (g.balance / 100.0) as f32, + ) + }), + skin: oklch_hue_of_srgb_hue(25.0), + } + } + + pub fn is_identity(&self) -> bool { + self.vibrance == 0.0 && self.saturation == 0.0 && !self.mixer && self.bw.is_none() && self.grading.is_none() + } + + /// `local_sat` (−1..1) and `local_hue` (radians) come from masks. + #[inline] + pub fn apply(&self, rgb: [f32; 3], local_sat: f32, local_hue: f32) -> [f32; 3] { + if self.is_identity() && local_sat == 0.0 && local_hue == 0.0 { + return rgb; + } + let lab = oklab_from_2020(rgb); + let [mut l, mut c, mut h] = lab_to_lch(lab); + if self.mixer { + let w = band_weights(h); + let (mut dh, mut ds, mut dl) = (0.0, 0.0, 0.0); + for i in 0..8 { + dh += w[i] * self.hue[i]; + ds += w[i] * self.sat[i]; + dl += w[i] * self.lum[i]; + } + let chroma_w = (c / 0.12).min(1.0); + h += dh * chroma_w; + c *= (1.0 + ds).max(0.0); + l += dl * chroma_w * l.max(0.05).sqrt(); + } + if self.vibrance != 0.0 { + let low = 1.0 - (c / 0.22).clamp(0.0, 1.0); + let skin = if self.vibrance > 0.0 { 1.0 - 0.6 * (-(wrap(h - self.skin) / 0.35).powi(2)).exp() } else { 1.0 }; + c *= (1.0 + self.vibrance * low * low * skin * 1.2).max(0.0); + } + if self.saturation != 0.0 || local_sat != 0.0 { + c *= (1.0 + self.saturation + local_sat).max(0.0); + } + h += local_hue; + if let Some(bw) = &self.bw { + let w = band_weights(h); + let mix: f32 = (0..8).map(|i| w[i] * bw[i]).sum(); + l = (l + mix * (c / 0.2).min(1.0) * 0.25).max(0.0); + c = 0.0; + } + let mut lab = lch_to_lab([l, c, h]); + if let Some((wheels, blending, balance)) = &self.grading { + let m = 0.5 - balance * 0.25; + let width = 0.15 + blending * 0.5; + let ws = 1.0 - smooth(m - width, m + width * 0.25, lab[0]); + let wh = smooth(m - width * 0.25, m + width, lab[0]); + let wm = (1.0 - ws - wh).max(0.0); + for (k, wt) in wheels.iter().zip([ws, wm, wh, 1.0]) { + lab[1] += k.a * wt; + lab[2] += k.b * wt; + lab[0] += k.lum * wt; + } + } + oklab_to_2020(lab) + } +} + +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_color::perceptual::lab_to_lch; + + #[test] + fn weights_partition_unity() { + for i in 0..360 { + let h = (i as f32).to_radians() - PI; + let w = band_weights(h); + let s: f32 = w.iter().sum(); + assert!((s - 1.0).abs() < 1e-4, "{i}: {s}"); + } + // at a band centre, that band dominates + let w = band_weights(band_hues()[5]); + assert!(w[5] > 0.99); + } + + #[test] + fn neutral_is_identity() { + let ops = ColorOps::new(&DevelopSettings::default()); + assert!(ops.is_identity()); + assert_eq!(ops.apply([0.2, 0.3, 0.4], 0.0, 0.0), [0.2, 0.3, 0.4]); + } + + #[test] + fn saturation_and_bw() { + let mut s = DevelopSettings::default(); + s.color.saturation = -100.0; + let g = ColorOps::new(&s).apply([0.4, 0.1, 0.05], 0.0, 0.0); + assert!((g[0] - g[1]).abs() < 1e-3 && (g[1] - g[2]).abs() < 1e-3, "{g:?}"); + let mut s = DevelopSettings { treatment: lightcraft_develop::Treatment::Bw, ..Default::default() }; + let ops = ColorOps::new(&s); + let b = ops.apply([0.05, 0.1, 0.5], 0.0, 0.0); + assert!((b[0] - b[2]).abs() < 1e-3); + // raising blue in the B&W mix brightens blue things + s.bw_mix.blue = 80.0; + let b2 = ColorOps::new(&s).apply([0.05, 0.1, 0.5], 0.0, 0.0); + assert!(b2[1] > b[1]); + } + + #[test] + fn mixer_targets_its_band() { + let mut s = DevelopSettings::default(); + s.mixer.blue.sat = -100.0; + let ops = ColorOps::new(&s); + let blue = [0.02, 0.05, 0.4]; + let red = [0.4, 0.03, 0.02]; + let cb = lab_to_lch(oklab_from_2020(ops.apply(blue, 0.0, 0.0)))[1]; + let cr0 = lab_to_lch(oklab_from_2020(red))[1]; + let cr = lab_to_lch(oklab_from_2020(ops.apply(red, 0.0, 0.0)))[1]; + assert!(cb < 0.02, "{cb}"); + assert!((cr - cr0).abs() < 0.01); + } + + #[test] + fn grading_tints_shadows_only() { + let mut s = DevelopSettings::default(); + s.grading.shadows = lightcraft_develop::Wheel { hue: 220.0, sat: 60.0, lum: 0.0 }; + let ops = ColorOps::new(&s); + let dark = ops.apply([0.01, 0.01, 0.01], 0.0, 0.0); + let bright = ops.apply([0.8, 0.8, 0.8], 0.0, 0.0); + assert!(dark[2] > dark[0]); + assert!((bright[2] - bright[0]).abs() < 0.02); + } +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs new file mode 100644 index 00000000..9873bad7 --- /dev/null +++ b/crates/pipeline/src/finish.rs @@ -0,0 +1,325 @@ +//! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass. + +use lightcraft_color::spline::{Lut1, MonotoneCurve}; +use lightcraft_color::transfer::linear_to_srgb; +use lightcraft_color::{REC2020, SRGB, luminance_2020}; +use lightcraft_develop::{DevelopSettings, ToneCurve, VignetteStyle}; +use lightcraft_geom::Point; +use lightcraft_raster::Rgba8; + +use crate::colorops::ColorOps; +use crate::geometry::Frame; +use crate::local::{airlight, log_lum}; +use crate::tone::ToneMap; +use crate::{Prepared, SourceInfo, for_rows}; + +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Parametric region curve (encoded domain) composed with the master point curve. +fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> { + let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0; + let master = !ToneCurve::point_curve_is_identity(&c.master); + let chans = [&c.red, &c.green, &c.blue].map(|p| !ToneCurve::point_curve_is_identity(p)); + if !parametric && !master && !chans.iter().any(|b| *b) { + return None; + } + const N: usize = 1024; + let (s1, s2, s3) = ((c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32); + let regions = [(0.0, s1, c.shadows), (s1, s2, c.darks), (s2, s3, c.lights), (s3, 1.0, c.highlights)]; + let mut base = Lut1::from_fn(N, |x| { + let mut d = 0.0; + for (a, b, amt) in regions { + if amt == 0.0 { + continue; + } + let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); + let t = ((x - ctr) / half).clamp(-1.0, 1.0); + let win = 0.5 + 0.5 * (t * std::f32::consts::PI).cos(); + d += (amt / 100.0) as f32 * 0.22 * win; + } + (x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0) + }); + // keep monotone + for i in 1..N { + base.v[i] = base.v[i].max(base.v[i - 1]); + } + let to_pts = |p: &[Point]| p.iter().map(|q| (q.x, q.y)).collect::>(); + if master { + base = MonotoneCurve::new(&to_pts(&c.master)).to_lut(N).compose(&base); + } + let per = [&c.red, &c.green, &c.blue]; + Some(std::array::from_fn(|i| if chans[i] { MonotoneCurve::new(&to_pts(per[i])).to_lut(N).compose(&base) } else { base.clone() })) +} + +struct Vig { + amount: f32, + start: f32, + width: f32, + aspect_mix: f32, + power: f32, + highlights: f32, + style: VignetteStyle, +} + +fn vignette(s: &DevelopSettings) -> Option { + let v = &s.vignette; + (v.amount != 0.0).then(|| { + let r = (v.roundness / 100.0) as f32; + Vig { + amount: (v.amount / 100.0) as f32, + start: 0.15 + (v.midpoint / 100.0) as f32 * 0.95, + width: 0.05 + (v.feather / 100.0) as f32 * 1.1, + aspect_mix: ((r + 1.0) / 2.0).clamp(0.0, 1.0), + power: if r >= 0.0 { 2.0 } else { 2.0 + (-r) * 6.0 }, + highlights: (v.highlights / 100.0) as f32, + style: v.style, + } + }) +} + +#[inline] +fn grain_noise(x: f32, y: f32, seed: u32) -> f32 { + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = (x - x0, y - y0); + let h = |i: i32, j: i32| { + let mut v = (i as u32).wrapping_mul(0x8da6_b343) ^ (j as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f); + v ^= v >> 13; + v = v.wrapping_mul(0x5bd1_e995); + v ^= v >> 15; + (v & 0xffff) as f32 / 32768.0 - 1.0 + }; + let (i, j) = (x0 as i32, y0 as i32); + let (u, v) = (fx * fx * (3.0 - 2.0 * fx), fy * fy * (3.0 - 2.0 * fy)); + let a = h(i, j) + (h(i + 1, j) - h(i, j)) * u; + let b = h(i, j + 1) + (h(i + 1, j + 1) - h(i, j + 1)) * u; + a + (b - a) * v +} + +pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, _info: &SourceInfo) -> Rgba8 { + let (w, h) = (p.img.width, p.img.height); + let tone = ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks); + let ops = ColorOps::new(s); + let curves = curve_luts(&s.curve); + let vig = if s.section_enabled("effects") { vignette(s) } else { None }; + let to_srgb: [[f32; 3]; 3] = REC2020.to_space(&SRGB).to_f32(); + let hl = (s.light.highlights / 100.0) as f32; + let sh = (s.light.shadows / 100.0) as f32; + let (clar, tex, dehaze) = if s.section_enabled("effects") { + ((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32) + } else { + (0.0, 0.0, 0.0) + }; + 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); + 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) + }); + let out_to_norm = frame.out_to_norm(w, h); + let aspect = w as f32 / h as f32; + + 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]; + + // --- 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); + let (mut l_tex, mut l_clar, mut l_dehaze, mut l_sat, mut l_hue, mut l_sharp) = (0.0f32, 0.0, 0.0, 0.0, 0.0, 0.0); + let mut tint_col: Option<([f32; 3], f32)> = None; + for m in &p.masks { + let a = m.alpha.data[i]; + if a <= 0.0 { + continue; + } + let j = &m.adjust; + l_exp += a * j.exposure as f32; + l_temp += a * (j.temp / 100.0) as f32; + l_tint += a * (j.tint / 100.0) as f32; + l_con += a * (j.contrast / 100.0) as f32; + l_hl += a * (j.highlights / 100.0) as f32; + l_sh += a * (j.shadows / 100.0) as f32; + l_wh += a * (j.whites / 100.0) as f32; + l_bl += a * (j.blacks / 100.0) as f32; + l_tex += a * (j.texture / 100.0) as f32; + l_clar += a * (j.clarity / 100.0) as f32; + l_dehaze += a * (j.dehaze / 100.0) as f32; + l_sat += a * (j.saturation / 100.0) as f32; + l_hue += a * (j.hue / 100.0) as f32 * 0.6; + l_sharp += a * (j.sharpness / 100.0) as f32; + if j.color_sat > 0.0 { + let hue = crate::colorops::oklch_hue_of_srgb_hue(j.color_hue); + tint_col = Some(([hue.cos(), hue.sin(), 0.0], a * (j.color_sat / 100.0) as f32)); + } + } + + // --- dehaze (scene linear) + let dz = dehaze + l_dehaze; + if dz != 0.0 + && let Some(dark) = &p.dark + { + let d = (dark.data[i] / air).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)); + } else { + let k = (-dz).min(1.0) * 0.7 * (0.35 + 0.65 * d); + c = c.map(|v| v + (air * 0.9 - v) * k); + } + } + + // --- local exposure / temp / tint + if l_exp != 0.0 { + let g = 2f32.powf(l_exp); + c = c.map(|v| v * g); + } + if l_temp != 0.0 || l_tint != 0.0 { + let y0 = luminance_2020(c); + c = [c[0] * (1.0 + 0.3 * l_temp), c[1] * (1.0 - 0.22 * l_tint), c[2] * (1.0 - 0.3 * l_temp).max(0.0)]; + let y1 = luminance_2020(c).max(1e-9); + c = c.map(|v| v * y0 / y1); + } + + // --- 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 mut delta = 0.0f32; + let (hh, ss) = (hl + l_hl, sh + l_sh); + if hh != 0.0 || ss != 0.0 { + let ws = 1.0 - smooth(-4.8, 0.3, base); + let wh = smooth(-1.0, 2.8, base); + delta += ss * 1.7 * ws * ws.sqrt() + hh * 1.7 * wh; + } + if l_wh != 0.0 { + delta += l_wh * 0.8 * smooth(0.5, 3.0, l1); + } + if l_bl != 0.0 { + delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1)); + } + if l_con != 0.0 { + delta += l_con * 0.14 * l1.clamp(-6.0, 4.0); + } + let cl = clar + l_clar; + if cl != 0.0 + && let Some(b) = &p.clarity_blur + { + let det = (l0 - 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); + } + let tx = tex + l_tex; + let sp = l_sharp * 0.6 + sharpen; + if (tx != 0.0 || sp != 0.0) + && let Some(b) = &p.texture_blur + { + let det = l0 - 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 { + let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 }; + delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; + } + } + if delta != 0.0 { + let g = 2f32.powf(delta); + c = c.map(|v| v * g); + } + + // --- tone map on luminance, highlight desaturation + let yl = luminance_2020(c); + let o = tone.apply(yl); + let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] }; + let mx = d[0].max(d[1]).max(d[2]); + if mx > 1.0 { + let t = ((mx - 1.0) / (mx - o).max(1e-6)).clamp(0.0, 1.0); + d = d.map(|v| v + (o - v) * t); + } + + // --- colour + d = ops.apply(d, l_sat, l_hue); + if let Some((dir, amt)) = tint_col { + let lab = lightcraft_color::perceptual::oklab_from_2020(d); + d = lightcraft_color::perceptual::oklab_to_2020([lab[0], lab[1] + dir[0] * 0.08 * amt, lab[2] + dir[1] * 0.08 * amt]); + } + + // --- vignette (display linear, post-crop) + if let Some(v) = &vig { + let u = (x as f32 + 0.5) / w as f32 * 2.0 - 1.0; + let vv = (y as f32 + 0.5) / h as f32 * 2.0 - 1.0; + let sx = 1.0 + (aspect - 1.0) * v.aspect_mix; + let sy = 1.0 + (1.0 / aspect - 1.0) * v.aspect_mix; + let (ax, ay) = ((u * sx.max(1.0) / sx.max(sy)).abs(), (vv * sy.max(1.0) / sx.max(sy)).abs()); + let dist = (ax.powf(v.power) + ay.powf(v.power)).powf(1.0 / v.power); + let t = smooth(v.start, v.start + v.width, dist); + if t > 0.0 { + let lum = luminance_2020(d).clamp(0.0, 1.0); + if v.amount < 0.0 { + let mut f = 1.0 + v.amount * t; + if v.style == VignetteStyle::HighlightPriority { + f += (1.0 - f) * v.highlights * smooth(0.4, 1.0, lum); + } + if v.style == VignetteStyle::PaintOverlay { + d = d.map(|c| c * (1.0 - (-v.amount) * t) + 0.0); + } else { + d = d.map(|c| c * f); + } + } else { + d = d.map(|c| c + (1.0 - c) * v.amount * t * 0.85); + } + } + } + + // --- gamut map to sRGB (desaturate towards luminance until in range) + let m = &to_srgb; + let mut r = [ + m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2], + m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2], + m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2], + ]; + let yy = (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]).clamp(0.0, 1.0); + let mut t = 1.0f32; + for c in r { + if c < 0.0 { + t = t.min(yy / (yy - c).max(1e-9)); + } else if c > 1.0 { + t = t.min((1.0 - yy) / (c - yy).max(1e-9)); + } + } + if t < 1.0 { + r = r.map(|c| yy + (c - yy) * t); + } + + // --- encode, curves, grain + let mut e = r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))); + if let Some(l) = &curves { + e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; + } + if let Some((amt, cell, rough, seed)) = grain { + let n = out_to_norm.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let (gx, gy) = ((n.x * frame.ow) as f32 / frame.ow.max(frame.oh) as f32, (n.y * frame.oh) as f32 / frame.ow.max(frame.oh) as f32); + let sc = p.px_per_long as f32 / cell; + let mut g = grain_noise(gx * sc, gy * sc, seed); + g = g * (1.0 - rough * 0.5) + grain_noise(gx * sc * 2.3, gy * sc * 2.3, seed ^ 0x55) * rough * 0.7; + let lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); + e = e.map(|v| v + k); + } + *px = [enc(e[0]), enc(e[1]), enc(e[2]), 255]; + } + }); + out +} + +#[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 +} diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs new file mode 100644 index 00000000..dfc3fd5d --- /dev/null +++ b/crates/pipeline/src/geometry.rs @@ -0,0 +1,168 @@ +//! Output framing: user orientation, crop + straighten, flips — and the mapping between output +//! pixels and normalized source coordinates (used by masks, spots and on-canvas tools). + +use lightcraft_develop::DevelopSettings; +use lightcraft_geom::{Affine, CropGeometry, Orientation, Point, Rect}; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Rgb32f, par_rows}; + +/// The geometric frame of a render. +#[derive(Clone, Debug)] +pub struct Frame { + /// Source (EXIF-oriented) dimensions in pixels. + pub src_w: usize, + pub src_h: usize, + /// User orientation (Rotate Left/Right, Flip) on top of the source. + pub orient: Orientation, + /// Oriented dimensions. + pub ow: f64, + pub oh: f64, + pub crop: CropGeometry, + pub flip_h: bool, + pub flip_v: bool, +} + +impl Frame { + pub fn new(src_w: usize, src_h: usize, s: &DevelopSettings, apply_crop: bool) -> Frame { + let orient = s.orientation; + let (ow, oh) = if orient.swaps_axes() { (src_h as f64, src_w as f64) } else { (src_w as f64, src_h as f64) }; + let crop = if apply_crop { s.crop.geometry } else { CropGeometry::default() }; + Frame { src_w, src_h, orient, ow, oh, crop, flip_h: apply_crop && s.crop.flip_h, flip_v: apply_crop && s.crop.flip_v } + } + + /// Aspect ratio (w/h) of the output. + pub fn aspect(&self) -> f64 { + let r = self.crop.rect_px(self.ow, self.oh); + r.width() / r.height() + } + + pub fn fit(&self, max_w: usize, max_h: usize) -> (usize, usize) { + let a = self.aspect(); + let (mw, mh) = (max_w.max(1) as f64, max_h.max(1) as f64); + let (w, h) = if mw / mh > a { (mh * a, mh) } else { (mw, mw / a) }; + ((w.round() as usize).max(1), (h.round() as usize).max(1)) + } + + /// Output pixels per unit of the oriented image's long edge. + pub fn px_per_long(&self, out_w: usize) -> f64 { + let r = self.crop.rect_px(self.ow, self.oh); + out_w as f64 / r.width() * self.ow.max(self.oh) + } + + /// Affine from output pixel coordinates to oriented-image pixel coordinates. + pub fn out_to_oriented(&self, out_w: usize, out_h: usize) -> Affine { + let (w, h) = (out_w as f64, out_h as f64); + let mut flip = Affine::IDENTITY; + if self.flip_h { + flip = Affine([-1.0, 0.0, 0.0, 1.0, w, 0.0]) * flip; + } + if self.flip_v { + flip = Affine([1.0, 0.0, 0.0, -1.0, 0.0, h]) * flip; + } + self.crop.output_to_source(self.ow, self.oh, w, h) * flip + } + + /// Map an output pixel to normalized oriented-image coordinates (0..1). + pub fn out_to_norm(&self, out_w: usize, out_h: usize) -> Affine { + Affine::scale(1.0 / self.ow, 1.0 / self.oh) * self.out_to_oriented(out_w, out_h) + } + + /// Normalized oriented coords → output pixel (inverse of `out_to_norm`). + pub fn norm_to_out(&self, out_w: usize, out_h: usize) -> Affine { + self.out_to_norm(out_w, out_h).inverse().unwrap_or(Affine::IDENTITY) + } + + /// Convert normalized oriented coordinates to "long-edge units" (isotropic), used by mask shapes. + pub fn norm_to_long(&self, p: Point) -> Point { + let l = self.ow.max(self.oh); + Point::new(p.x * self.ow / l, p.y * self.oh / l) + } + + /// Sample the source into a `w × h` output buffer: orientation, crop, rotation, flips — one + /// bilinear resample from a pre-filtered (area-downscaled) copy, so minification never aliases. + pub fn sample(&self, src: &Rgb32f, w: usize, h: usize) -> Rgb32f { + let oriented = if self.orient == Orientation::Normal { None } else { Some(src.oriented(self.orient)) }; + let o = oriented.as_ref().unwrap_or(src); + let crop_px = self.crop.rect_px(self.ow, self.oh); + let k = crop_px.width() / w as f64; + let (base, s) = if k > 1.25 { + let nw = ((o.width as f64 / k).round() as usize).max(1); + let nh = ((o.height as f64 / k).round() as usize).max(1); + (resize(o, nw, nh, Filter::Mitchell), nw as f64 / o.width as f64) + } else { + (o.clone(), 1.0) + }; + let xf = Affine::scale(s, base.height as f64 / o.height as f64) * self.out_to_oriented(w, h); + let identity_like = self.crop.is_identity() && !self.flip_h && !self.flip_v && base.width == w && base.height == h; + if identity_like { + return base; + } + let mut out = Rgb32f::new(w, h); + par_rows(&mut out.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + *px = base.sample_bilinear(p.x as f32, p.y as f32); + } + }); + out + } + + /// The crop rectangle as a quad in normalized oriented coordinates (for overlays). + pub fn crop_quad_norm(&self) -> [Point; 4] { + let r = self.crop.rect_px(self.ow, self.oh); + let back = Affine::rotate_about(-self.crop.angle.to_radians(), Point::new(self.ow / 2.0, self.oh / 2.0)); + r.corners().map(|p| { + let q = back.apply(p); + Point::new(q.x / self.ow, q.y / self.oh) + }) + } +} + +/// Whole-image rectangle in normalized coordinates. +pub const FULL: Rect = Rect::UNIT; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_frame_is_identity() { + let s = DevelopSettings::default(); + let f = Frame::new(300, 200, &s, true); + assert_eq!(f.fit(3000, 3000), (3000, 2000)); + let m = f.out_to_norm(300, 200); + let p = m.apply(Point::new(150.0, 100.0)); + assert!((p.x - 0.5).abs() < 1e-9 && (p.y - 0.5).abs() < 1e-9); + assert!((f.px_per_long(300) - 300.0).abs() < 1e-9); + } + + #[test] + fn rotation_swaps_aspect() { + let s = DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; + let f = Frame::new(300, 200, &s, true); + assert_eq!(f.fit(1000, 1000), (667, 1000)); + } + + #[test] + fn crop_changes_aspect_and_mapping() { + let mut s = DevelopSettings::default(); + s.crop.geometry.rect = Rect::new(0.5, 0.0, 1.0, 1.0); + let f = Frame::new(400, 200, &s, true); + assert_eq!(f.fit(1000, 1000), (1000, 1000)); + let p = f.out_to_norm(100, 100).apply(Point::new(0.0, 0.0)); + assert!((p.x - 0.5).abs() < 1e-9 && p.y.abs() < 1e-9); + let back = f.norm_to_out(100, 100).apply(Point::new(0.75, 0.5)); + assert!((back.x - 50.0).abs() < 1e-6 && (back.y - 50.0).abs() < 1e-6); + } + + #[test] + fn flip_mirrors() { + let mut s = DevelopSettings::default(); + s.crop.flip_h = true; + let src = Rgb32f::from_fn(4, 1, |x, _| [x as f32, 0.0, 0.0]); + let f = Frame::new(4, 1, &s, true); + let out = f.sample(&src, 4, 1); + assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5); + assert!((out.get(3, 0)[0] - 0.0).abs() < 1e-5); + } +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs new file mode 100644 index 00000000..7247fb1f --- /dev/null +++ b/crates/pipeline/src/lib.rs @@ -0,0 +1,142 @@ +//! The LightCraft develop pipeline (CPU reference implementation). +//! +//! Input: a scene-referred, linear Rec.2020 source image (already EXIF-oriented) at any resolution +//! (full size or a proxy), plus [`DevelopSettings`]. Output: a display-encoded sRGB image at the +//! requested size, and its histogram. +//! +//! Stage order (see `docs/pipeline.md`): +//! 1. geometry — user orientation, crop + straighten, flips; one resample at output resolution +//! 2. scene-linear — white balance, exposure, dehaze, local tone (highlights/shadows), texture, +//! clarity, local adjustments (masks) +//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation +//! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh) +//! 5. display — gamut map to sRGB, encode, tone curves (parametric + point), vignette, grain +//! +//! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a +//! 60 MP export look alike. +#![forbid(unsafe_code)] + +pub mod auto; +mod colorops; +mod finish; +pub mod geometry; +mod local; +pub mod masks; +mod tone; + +use lightcraft_develop::{DevelopSettings, Treatment}; +use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows}; + +pub use tone::ToneMap; + +/// Facts about the source the settings are interpreted against. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SourceInfo { + /// Raw sources use absolute Kelvin white balance; rendered sources use a relative scale around + /// their as-shot white. + pub raw: bool, + pub as_shot_temp: f64, + pub as_shot_tint: f64, +} + +impl Default for SourceInfo { + fn default() -> Self { + Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0 } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Quality { + /// Interactive drags: skips the most expensive refinements. + Draft, + #[default] + Full, +} + +#[derive(Clone, Copy, Debug)] +pub struct RenderRequest { + /// Output must fit in `max_w × max_h` (aspect preserved). + pub max_w: usize, + pub max_h: usize, + pub quality: Quality, + /// Show the crop frame's content only (true) or the whole uncropped image (crop tool active). + pub apply_crop: bool, +} + +impl RenderRequest { + pub fn fit(max_w: usize, max_h: usize) -> Self { + Self { max_w, max_h, quality: Quality::Full, apply_crop: true } + } +} + +pub struct Rendered { + pub image: Rgba8, + pub histogram: Histogram, +} + +/// Everything the per-pixel stage needs, precomputed at output resolution. +pub(crate) struct Prepared { + pub img: Rgb32f, + /// Log2 luminance relative to middle grey. + pub log_l: Plane, + pub base: Plane, + pub clarity_blur: Option, + pub texture_blur: Option, + pub dark: Option, + pub masks: Vec, + /// Output pixels per unit of the source long edge. + pub px_per_long: f64, +} + +/// Output size for a source of `src_w × src_h` under `s`, fitting `max_w × max_h`. +pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &RenderRequest) -> (usize, usize) { + geometry::Frame::new(src_w, src_h, s, req.apply_crop).fit(req.max_w, req.max_h) +} + +/// 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(); + let t0 = std::time::Instant::now(); + let lap = |what: &str, t: &mut std::time::Instant| { + if prof { + eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); + *t = std::time::Instant::now(); + } + }; + let mut t = t0; + let frame = geometry::Frame::new(src.width, src.height, s, req.apply_crop); + let (w, h) = frame.fit(req.max_w, req.max_h); + let mut img = frame.sample(src, w, h); + lap("sample", &mut t); + let px_per_long = frame.px_per_long(w); + + local::scene_linear_pre(&mut img, info, s); + lap("wb/exposure", &mut t); + let prep = local::prepare(img, s, &frame, px_per_long, req.quality); + lap("prepare", &mut t); + let image = finish::finish(&prep, s, &frame, info); + lap("finish", &mut t); + let histogram = Histogram::of_srgb8(&image); + lap("histogram", &mut t); + Rendered { image, histogram } +} + +/// Convenience: render a before/after pair side by side is up to the UI; this renders "before" +/// (default look, keeping the crop so framing matches). +pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { + let mut b = DevelopSettings { crop: s.crop, orientation: s.orientation, ..DevelopSettings::default() }; + b.wb = lightcraft_develop::WhiteBalance { mode: lightcraft_develop::WbMode::AsShot, ..b.wb }; + b +} + +pub(crate) fn is_bw(s: &DevelopSettings) -> bool { + s.treatment == Treatment::Bw || s.profile.id == "lc.mono" +} + +/// Parallel map over output rows with index. +pub(crate) fn for_rows(data: &mut [T], w: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { + par_rows(data, w, f) +} + +#[cfg(test)] +mod tests; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs new file mode 100644 index 00000000..8a73ffc1 --- /dev/null +++ b/crates/pipeline/src/local.rs @@ -0,0 +1,206 @@ +//! Scene-linear preparation: white balance + exposure, and the spatial planes the per-pixel stage +//! needs (edge-aware base layer for highlights/shadows, clarity/texture bands, dehaze veil). + +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 crate::geometry::Frame; +use crate::{Prepared, Quality, SourceInfo, for_rows, masks}; + +/// 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) { + 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 + } else { + let set = wb_matrix(&REC2020, temp_tint_to_xy(t, tint)); + let shot = wb_matrix(&REC2020, temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint)); + let m = set.mul(&shot.inverse().unwrap_or(lightcraft_color::Mat3::IDENTITY)); + // Normalize so neutral luminance is preserved (WB shouldn't change exposure). + let g = m.apply([1.0, 1.0, 1.0]); + 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() { + let mut c = *p; + if let Some(m) = &m { + c = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + } + *p = [(c[0] * gain).max(0.0), (c[1] * gain).max(0.0), (c[2] * gain).max(0.0)]; + } + }); +} + +/// The white balance actually in effect (presets resolve to their Kelvin values for raw files). +pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) { + use lightcraft_develop::WbMode; + match s.wb.mode { + WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint), + m if info.raw => m.preset().unwrap_or((s.wb.temp, s.wb.tint)), + _ => (s.wb.temp, s.wb.tint), + } +} + +/// 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 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]; + } + q +} + +/// Fast guided filter (He & Sun 2015): coefficients computed on a `s×` subsampled plane and +/// bilinearly upsampled; the output keeps full-resolution edges. Equivalent to [`guided`] for +/// large windows at a fraction of the cost. +pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { + use lightcraft_raster::resample::{Filter, resize}; + let s = (sigma / 3.0).floor().clamp(1.0, 16.0) as usize; + if s <= 1 { + return guided(p, sigma, eps); + } + 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 +} + +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 = img.map(log_lum); + let ppl = px_per_long as f32; + let tone_active = + s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0); + let base = if tone_active { + // Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved). + let sigma = (0.015 * ppl).max(1.0); + let sigma = if q == Quality::Draft { sigma.min(24.0) } else { sigma }; + 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(|| 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(|| gaussian(&log_l, (0.0018 * ppl).max(0.6))); + let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| { + let min_c = img.map(|c| c[0].min(c[1]).min(c[2])); + gaussian(&min_c, (0.02 * ppl).max(1.0)) + }); + let 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 } +} + +/// Airlight estimate: bright end of the dark channel. +pub fn airlight(dark: &Plane) -> f32 { + let mut v: Vec = dark.data.iter().step_by(7).copied().collect(); + if v.is_empty() { + return 1.0; + } + let k = ((v.len() as f32) * 0.995) as usize; + let k = k.min(v.len() - 1); + v.select_nth_unstable_by(k, |a, b| a.total_cmp(b)); + v[k].max(0.05) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guided_preserves_step_edges_and_flattens_texture() { + let p = Plane::from_fn(80, 20, |x, y| if x < 40 { -2.0 } else { 2.0 } + if (x + y) % 2 == 0 { 0.05 } else { -0.05 }); + let q = guided(&p, 4.0, 0.3); + // edge kept + assert!(q.get(35, 10) < -1.5 && q.get(45, 10) > 1.5); + // checkerboard texture removed away from the edge + assert!((q.get(10, 10) - q.get(11, 10)).abs() < 0.02); + } + + #[test] + fn wb_as_shot_is_identity_and_warming_adds_red() { + let s = DevelopSettings::default(); + let info = SourceInfo::default(); + let mut img = Rgb32f::filled(4, 4, [0.3, 0.3, 0.3]); + scene_linear_pre(&mut img, &info, &s); + assert!((img.get(0, 0)[0] - 0.3).abs() < 1e-6); + let mut warm = s.clone(); + warm.wb.mode = lightcraft_develop::WbMode::Custom; + warm.wb.temp = 9000.0; + let mut img2 = Rgb32f::filled(4, 4, [0.3, 0.3, 0.3]); + scene_linear_pre(&mut img2, &info, &warm); + let c = img2.get(0, 0); + assert!(c[0] > c[2], "{c:?}"); + // luminance preserved + assert!((luminance_2020(c) - 0.3).abs() < 0.01); + } + + #[test] + fn exposure_doubles() { + let mut s = DevelopSettings::default(); + s.light.exposure = 1.0; + let mut img = Rgb32f::filled(2, 2, [0.1, 0.2, 0.3]); + scene_linear_pre(&mut img, &SourceInfo::default(), &s); + assert!((img.get(1, 1)[2] - 0.6).abs() < 1e-5); + } +} + +#[cfg(test)] +mod fast_tests { + use super::*; + + #[test] + fn fast_guided_close_to_reference() { + let p = Plane::from_fn(160, 120, |x, y| ((x as f32 * 0.13).sin() + (y as f32 * 0.07).cos()) * 2.0 + if x > 80 { 1.5 } else { 0.0 }); + let a = guided(&p, 12.0, 0.3); + let b = guided_fast(&p, 12.0, 0.3); + let err: f32 = a.data.iter().zip(&b.data).map(|(u, v)| (u - v).abs()).sum::() / a.len() as f32; + assert!(err < 0.08, "{err}"); + } +} diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs new file mode 100644 index 00000000..910f2874 --- /dev/null +++ b/crates/pipeline/src/masks.rs @@ -0,0 +1,268 @@ +//! Mask evaluation: each mask becomes an alpha plane at output resolution. +//! +//! Shapes are defined in normalized oriented-image coordinates; sizes are in "long-edge units". +//! Components combine with Add (max), Subtract (a·(1−c)) and Intersect (a·c). + +use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape}; +use lightcraft_geom::Point; +use lightcraft_raster::{Plane, Rgb32f}; + +use crate::for_rows; +use crate::geometry::Frame; + +pub struct Evaluated { + pub alpha: Plane, + pub adjust: LocalAdjustments, +} + +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane) -> Vec { + masks + .iter() + .filter(|m| m.visible && !m.components.is_empty()) + .map(|m| { + 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); + if comp.invert { + c.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + if first && comp.op != MaskOp::Intersect { + alpha = c; + first = false; + continue; + } + for (a, c) in alpha.data.iter_mut().zip(&c.data) { + *a = match comp.op { + MaskOp::Add => a.max(*c), + MaskOp::Subtract => *a * (1.0 - c), + MaskOp::Intersect => *a * c, + }; + } + first = false; + } + if m.invert { + alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + let amt = (m.adjust.amount / 100.0) as f32; + if (amt - 1.0).abs() > 1e-6 { + alpha.data.iter_mut().for_each(|v| *v *= amt); + } + Evaluated { alpha, adjust: m.adjust } + }) + .collect() +} + +/// Per-pixel positions in long-edge units for output pixel centres. +fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(Point, usize) -> f32 + Sync + Send) { + let m = frame.out_to_norm(w, h); + let (ow, oh, l) = (frame.ow, frame.oh, frame.ow.max(frame.oh)); + for_rows(&mut out.data, w, |y, row| { + for (x, v) in row.iter_mut().enumerate() { + let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + *v = f(Point::new(n.x * ow / l, n.y * oh / l), y * w + x); + } + }); +} + +pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane) -> Plane { + let mut out = Plane::new(w, h); + let to_long = |p: Point| frame.norm_to_long(p); + match shape { + MaskShape::Linear { start, end } => { + let (a, b) = (to_long(*start), to_long(*end)); + let d = b - a; + let len2 = d.dot(d).max(1e-12); + for_each_pos(frame, w, h, &mut out, |p, _| { + let t = ((p - a).dot(d) / len2) as f32; + 1.0 - smooth(0.0, 1.0, t) + }); + } + MaskShape::Radial { center, rx, ry, angle, feather, invert } => { + let c = to_long(*center); + let (s, co) = (-angle.to_radians()).sin_cos(); + let f = (*feather / 100.0).clamp(0.0, 1.0) as f32; + let (rx, ry) = (rx.max(1e-6), ry.max(1e-6)); + let inv = *invert; + for_each_pos(frame, w, h, &mut out, |p, _| { + let d = p - c; + let (x, y) = (d.x * co - d.y * s, d.x * s + d.y * co); + let r = ((x / rx).powi(2) + (y / ry).powi(2)).sqrt() as f32; + let a = 1.0 - smooth(1.0 - f.max(0.01), 1.0, r); + if inv { 1.0 - a } else { a } + }); + } + MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, &mut out), + MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => { + 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); + *v = smooth(lo - lf, lo, y) * (1.0 - smooth(hi, hi + hf, y)); + } + } + MaskShape::ColorRange { samples, refine } => { + 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 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()) + .fold(f32::MAX, f32::min); + *v = 1.0 - smooth(tol * 0.5, tol, d); + } + } + MaskShape::Sky => { + // Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or + // unsaturated, and connected to the top of the frame. + let m = frame.out_to_norm(w, h); + 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 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))); + } + smooth_plane(&mut out, 0.01 * frame_px(frame, w)); + } + MaskShape::Subject | MaskShape::Object { .. } | MaskShape::People { .. } => { + // Saliency heuristic: centre-weighted local contrast (replaced by the segmenter in M12). + let m = frame.out_to_norm(w, h); + let blur = lightcraft_raster::blur::gaussian(log_l, 0.03 * frame_px(frame, w)); + 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 d = (((n.x - 0.5) / 0.35).powi(2) + ((n.y - 0.55) / 0.4).powi(2)).sqrt() as f32; + let contrast = (log_l.data[i] - blur.data[i]).abs(); + *v = (1.0 - smooth(0.6, 1.0, d)) * (0.5 + 0.5 * smooth(0.05, 0.6, contrast)); + } + 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); + s.data.iter_mut().for_each(|v| *v = 1.0 - *v); + out = s; + } + MaskShape::DepthRange { .. } | MaskShape::Landscape { .. } => {} + } + out +} + +fn frame_px(frame: &Frame, w: usize) -> f32 { + frame.px_per_long(w) as f32 +} + +fn smooth_plane(p: &mut Plane, sigma: f32) { + *p = lightcraft_raster::blur::gaussian(p, sigma.max(0.5)); +} + +/// A rough display mapping for colour picking (so samples taken on screen match). +fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] { + c.map(|v| v / (1.0 + v)) +} + +/// Stamp brush strokes into `out` (max-combine; erase strokes subtract). +fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, out: &mut Plane) { + let to_out = frame.norm_to_out(w, h); + let ppl = frame.px_per_long(w); + for s in strokes { + let r = (s.size * ppl).max(0.5); + let hard = r * (1.0 - (s.feather / 100.0).clamp(0.0, 1.0)); + let flow = (s.flow / 100.0).clamp(0.0, 1.0) as f32; + let dens = (s.density / 100.0).clamp(0.0, 1.0) as f32; + // Densify the path so dabs overlap (spacing r/4). + let mut dabs: Vec = Vec::new(); + for (i, p) in s.points.iter().enumerate() { + let q = to_out.apply(*p); + if i > 0 { + let prev = to_out.apply(s.points[i - 1]); + let dist = prev.dist(q); + let n = (dist / (r / 4.0).max(0.5)).ceil() as usize; + for k in 1..n { + dabs.push(prev.lerp(q, k as f64 / n as f64)); + } + } + dabs.push(q); + } + let mut stroke_alpha = Plane::new(w, h); + for d in &dabs { + let (x0, x1) = (((d.x - r).floor().max(0.0)) as usize, ((d.x + r).ceil().max(0.0) as usize).min(w)); + let (y0, y1) = (((d.y - r).floor().max(0.0)) as usize, ((d.y + r).ceil().max(0.0) as usize).min(h)); + for y in y0..y1 { + for x in x0..x1 { + let dd = Point::new(x as f64 + 0.5, y as f64 + 0.5).dist(*d); + if dd > r { + continue; + } + let a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) }; + let v = &mut stroke_alpha.data[y * w + x]; + // flow accumulates within a stroke up to density + *v = (*v + a * flow * (1.0 - *v)).min(dens); + } + } + } + for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) { + *o = if s.erase { *o * (1.0 - a) } else { o.max(*a) }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_develop::{DevelopSettings, MaskComponent}; + + fn frame(w: usize, h: usize) -> Frame { + Frame::new(w, h, &DevelopSettings::default(), true) + } + + #[test] + fn linear_gradient_ramps() { + 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); + assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); + assert!((a.get(50, 25) - 0.5).abs() < 0.05); + } + + #[test] + fn radial_inside_and_invert() { + let f = frame(100, 100); + 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); + 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); + assert!(e[0].alpha.get(50, 50) < 0.01); + } + + #[test] + fn brush_and_subtract() { + let f = frame(200, 100); + let img = Rgb32f::new(200, 100); + let l = Plane::new(200, 100); + let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; + let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; + let m = Mask { + 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 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)); + assert!(a.get(40, 5) < 0.01); + } +} diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs new file mode 100644 index 00000000..f6760dfb --- /dev/null +++ b/crates/pipeline/src/tests.rs @@ -0,0 +1,135 @@ +use lightcraft_develop::{DevelopSettings, controls}; +use lightcraft_raster::Rgb32f; + +use crate::{RenderRequest, SourceInfo, render}; + +fn mean_luma(img: &lightcraft_raster::Rgba8) -> f32 { + img.data.iter().map(|p| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32).sum::() / img.len() as f32 +} + +fn scene() -> Rgb32f { + lightcraft_scenes::demo_library()[0].render(240, 160) +} + +#[test] +fn default_render_is_sane() { + let r = render(&scene(), &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(240, 240)); + assert_eq!((r.image.width, r.image.height), (240, 160)); + let m = mean_luma(&r.image); + assert!((40.0..220.0).contains(&m), "{m}"); + assert!(r.histogram.total > 0); +} + +#[test] +fn grey_ramp_stays_neutral() { + let src = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]); + let r = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(64, 8)); + for p in &r.image.data { + assert!((p[0] as i32 - p[1] as i32).abs() <= 1 && (p[1] as i32 - p[2] as i32).abs() <= 1, "{p:?}"); + } +} + +#[test] +fn every_slider_changes_or_keeps_output_without_panicking() { + let src = scene(); + let base = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(96, 96)).image; + for c in controls::CONTROLS { + for v in [c.min, c.max] { + let mut s = DevelopSettings::default(); + controls::set(&mut s, c.id, v); + let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(96, 96)); + assert!(r.image.width > 0, "{}", c.id); + let _ = &base; + } + } +} + +#[test] +fn exposure_is_monotone() { + let src = scene(); + let mut prev = -1.0; + for ev in [-3.0, -1.5, 0.0, 1.0, 2.5] { + let mut s = DevelopSettings::default(); + s.light.exposure = ev; + let m = mean_luma(&render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(96, 96)).image); + assert!(m > prev, "{ev}: {m} <= {prev}"); + prev = m; + } +} + +#[test] +fn directional_sliders() { + let src = scene(); + let at = |id: &str, v: f64| { + let mut s = DevelopSettings::default(); + controls::set(&mut s, id, v); + mean_luma(&render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(96, 96)).image) + }; + let base = at("light.exposure", 0.0); + assert!(at("light.shadows", 100.0) > base); + assert!(at("light.highlights", -100.0) < base); + assert!(at("light.whites", 100.0) > base); + assert!(at("light.blacks", -100.0) < base); + assert!(at("vignette.amount", -100.0) < base); + assert!(at("vignette.amount", 100.0) > base); + assert!(at("effects.dehaze", -100.0) != base); +} + +#[test] +fn resolution_independence_of_local_contrast() { + // Clarity at two preview sizes should give similar results after downscaling. + let src = lightcraft_scenes::demo_library()[3].render(480, 320); + let mut s = DevelopSettings::default(); + s.effects.clarity = 80.0; + s.light.shadows = 60.0; + let small = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(120, 120)).image; + let big = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(480, 480)).image; + let bl = big.to_linear(); + let down = lightcraft_raster::resample::resize(&bl, small.width, small.height, lightcraft_raster::resample::Filter::Box).to_srgb8(); + let err: f32 = small.data.iter().zip(&down.data).map(|(a, b)| (a[1] as f32 - b[1] as f32).abs()).sum::() / small.len() as f32; + assert!(err < 9.0, "mean abs error {err}"); +} + +#[test] +fn crop_and_straighten_output_size() { + let mut s = DevelopSettings::default(); + s.crop.geometry = lightcraft_geom::crop_fit_angle(240.0, 160.0, 8.0, Some(1.0)); + let r = render(&scene(), &SourceInfo::default(), &s, &RenderRequest::fit(200, 200)); + assert_eq!((r.image.width, r.image.height), (200, 200)); +} + +#[test] +fn mask_brightens_only_inside() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = Rgb32f::filled(100, 100, [0.1, 0.1, 0.1]); + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 10.0, invert: false }, + }], + adjust: lightcraft_develop::LocalAdjustments { exposure: 2.0, ..Default::default() }, + ..Default::default() + }); + let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(100, 100)).image; + assert!(r.get(50, 50)[0] > r.get(5, 5)[0] + 40); +} + +#[test] +#[ignore] +fn perf_report() { + let src = lightcraft_scenes::demo_library()[0].render(3000, 2000); + let mut s = DevelopSettings::default(); + s.light.shadows = 40.0; + s.effects.clarity = 20.0; + s.effects.dehaze = 10.0; + s.color.vibrance = 20.0; + let t = std::time::Instant::now(); + let n = 5; + for _ in 0..n { + let _ = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(2560, 1440)); + } + eprintln!("render 2160x1440 from 6 MP: {:.1} ms", t.elapsed().as_secs_f64() * 1000.0 / n as f64); +} diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs new file mode 100644 index 00000000..925e9473 --- /dev/null +++ b/crates/pipeline/src/tone.rs @@ -0,0 +1,104 @@ +//! The global tone map: scene luminance → display-linear luminance. +//! +//! Built in the log domain around middle grey (0.18): contrast scales log-exposure about grey, +//! whites move the shoulder (white point), blacks move the toe. The shoulder is an extended +//! Reinhard curve so highlights roll off smoothly instead of clipping. + +pub const GREY: f32 = 0.18; +const LUT_MIN_EV: f32 = -14.0; +const LUT_MAX_EV: f32 = 10.0; +const LUT_N: usize = 4096; + +#[derive(Clone, Debug)] +pub struct ToneMap { + lut: Vec, +} + +impl ToneMap { + /// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units). + pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap { + let c = (contrast / 100.0) as f32; + let slope = if c >= 0.0 { 1.0 + 0.55 * c } else { 1.0 + 0.4 * c }; + // White point: scene luminance (after contrast) that maps to display 1.0. + let white_ev = 2.9 - 1.6 * (whites as f32 / 100.0); + let wl = GREY * 2f32.powf(white_ev); + let pre = 1.0 + GREY / wl; // keep grey near grey + let b = (blacks / 100.0) as f32; + let lut = (0..LUT_N) + .map(|i| { + let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32; + let y = GREY * 2f32.powf(ev * slope) * pre; + // extended Reinhard with white point wl: y(1 + y/wl²)/(1 + y) + let mut o = y * (1.0 + y / (wl * wl)) / (1.0 + y); + o = o.min(1.0); + // Toe: blacks < 0 crushes, > 0 lifts. + if b < 0.0 { + // Smooth max(0, o − k) (a soft knee), renormalized so 1 stays 1. + let k = -b * 0.035; + let e = 0.004; + let soft = |v: f32| ((v - k) + ((v - k) * (v - k) + e * e).sqrt()) * 0.5; + o = (soft(o) - soft(0.0)) / (soft(1.0) - soft(0.0)); + } else if b > 0.0 { + let k = b * 0.03; + o = k + (1.0 - k) * o; + } + o.clamp(0.0, 1.0) + }) + .collect(); + ToneMap { lut } + } + + /// Scene luminance → display-linear luminance. + #[inline] + pub fn apply(&self, y: f32) -> f32 { + if y <= 0.0 { + return 0.0; + } + let ev = (y / GREY).log2(); + let f = ((ev - LUT_MIN_EV) / (LUT_MAX_EV - LUT_MIN_EV)).clamp(0.0, 1.0) * (LUT_N - 1) as f32; + let i = (f as usize).min(LUT_N - 2); + let t = f - i as f32; + let v = self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t; + if ev < LUT_MIN_EV { v * (y / (GREY * 2f32.powf(LUT_MIN_EV))) } else { v } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monotone_and_bounded() { + for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] { + let t = ToneMap::new(c, w, b); + let mut prev = -1.0; + for i in 0..2000 { + let y = 1e-5 * 1.012f32.powi(i); + let o = t.apply(y); + assert!((0.0..=1.0).contains(&o)); + assert!(o >= prev - 1e-6, "{c} {w} {b} at {y}: {o} < {prev}"); + prev = o; + } + } + } + + #[test] + fn grey_stays_near_grey_and_highlights_roll_off() { + let t = ToneMap::new(0.0, 0.0, 0.0); + let g = t.apply(0.18); + assert!((0.15..0.24).contains(&g), "{g}"); + assert!(t.apply(1.0) < 0.95 && t.apply(1.0) > 0.6); + assert!(t.apply(8.0) > 0.97); + } + + #[test] + fn sliders_move_the_right_way() { + let base = ToneMap::new(0.0, 0.0, 0.0); + let contrast = ToneMap::new(60.0, 0.0, 0.0); + assert!(contrast.apply(0.05) < base.apply(0.05)); + assert!(contrast.apply(0.8) > base.apply(0.8)); + assert!(ToneMap::new(0.0, 60.0, 0.0).apply(0.8) > base.apply(0.8)); + assert!(ToneMap::new(0.0, 0.0, -60.0).apply(0.01) < base.apply(0.01)); + assert!(ToneMap::new(0.0, 0.0, 60.0).apply(0.01) > base.apply(0.01)); + } +}