M0.4: develop pipeline v0 (framing, WB, exposure, guided-filter local tone, clarity/texture, dehaze, masks, filmic tone map, OkLCh colour tools, gamut map, curves, vignette, grain, auto tone/WB)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 11:17:38 -04:00
parent 728a272681
commit 90c910a5f6
11 changed files with 1769 additions and 0 deletions

View File

@ -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 }

View File

@ -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}");
}

136
crates/pipeline/src/auto.rs Normal file
View File

@ -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<f32> = 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:?}");
}
}

View File

@ -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);
}
}

View File

@ -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::<Vec<_>>();
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<Vig> {
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
}

View File

@ -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);
}
}

142
crates/pipeline/src/lib.rs Normal file
View File

@ -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<Plane>,
pub texture_blur: Option<Plane>,
pub dark: Option<Plane>,
pub masks: Vec<masks::Evaluated>,
/// 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<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
par_rows(data, w, f)
}
#[cfg(test)]
mod tests;

View File

@ -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<f32> = 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::<f32>() / a.len() as f32;
assert!(err < 0.08, "{err}");
}
}

View File

@ -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<Evaluated> {
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<Point> = 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);
}
}

View File

@ -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::<f32>() / 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::<f32>() / 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);
}

104
crates/pipeline/src/tone.rs Normal file
View File

@ -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<f32>,
}
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));
}
}