diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index 2c1b0c92..f855b08b 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -9,9 +9,10 @@ //! with the guided filter); negative amounts add haze toward the atmospheric light. //! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes //! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided -//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of -//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone -//! weighted) and Texture (small radius) add band-pass local contrast. +//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about +//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add +//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them: +//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`). //! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation. //! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits) //! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch, @@ -54,6 +55,38 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> { use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear}; +mod local_tone; +mod whites_blacks; + +/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8, +/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Encoding(f32); + +impl Encoding { + pub(crate) fn of(gamma: f32) -> Self { + Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 }) + } + pub(crate) fn decode(self, v: f32) -> f32 { + if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) } + } + pub(crate) fn encode(self, v: f32) -> f32 { + if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) } + } +} + +fn srgb_encode(v: f32) -> f32 { + linear_to_srgb(v) +} + +fn srgb_decode(v: f32) -> f32 { + srgb_to_linear(v) +} + +fn luma_linear(c: [f32; 3]) -> f32 { + luma(c) +} + /// One colour grading wheel. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] @@ -136,6 +169,9 @@ pub struct CameraRaw { pub seed: u32, /// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it). pub pixel_scale: f32, + /// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8 + /// for ProPhoto). Set from the document's profile; Whites and Blacks act on light. + pub encoding_gamma: f32, } impl Default for CameraRaw { @@ -191,6 +227,7 @@ impl Default for CameraRaw { vignette_style: "highlightPriority".into(), seed: 0, pixel_scale: 1.0, + encoding_gamma: 0.0, } } } @@ -491,7 +528,11 @@ impl CameraRaw { self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0 } fn tone_neutral(&self) -> bool { - [self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0) + self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0 + } + /// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage). + fn local_tone_neutral(&self) -> bool { + [self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0) } fn hsl_neutral(&self) -> bool { self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0) @@ -561,16 +602,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo if p.dehaze != 0.0 { dehaze(px, w, h, p.dehaze / 100.0); } - // 3. Tone on luminance. - if !p.tone_neutral() { + // 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`). + if p.highlights != 0.0 || p.shadows != 0.0 { + local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma)); + } + if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) { let l: Vec = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect(); let lc: Vec = l.iter().map(|v| v.min(1.0)).collect(); - let base = if p.highlights != 0.0 || p.shadows != 0.0 { - let r = ((long * 0.015) as usize).max(2); - Some(guided_fast(&lc, &lc, w, h, r, 0.02)) - } else { - None - }; let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005)); let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps))); let k_con = p.contrast / 100.0 * 0.6; @@ -580,12 +618,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let i = y * w + x; let l0 = l[i]; let mut lv = lc[i]; - if let Some(b) = &base { - let bv = b[i].clamp(0.0, 1.0); - let hm = smoothstep(0.45, 0.95, bv); - let sm = 1.0 - smoothstep(0.05, 0.5, bv); - lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm); - } if let Some(c) = &clar { let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2); lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw; @@ -593,12 +625,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo if let Some(t) = &tex { lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1); } - if p.whites != 0.0 { - lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv); - } - if p.blacks != 0.0 { - lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv)); - } if k_con != 0.0 { let xx = lv.clamp(0.0, 1.0); lv += -k_con * (tau * xx).sin() / tau; @@ -611,6 +637,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo } }); } + // 3b. Whites and Blacks: global curves adapted to the image's black and white points. + if p.whites != 0.0 || p.blacks != 0.0 { + let enc = Encoding::of(p.encoding_gamma); + let (bp, wp) = luminance_points(px, w, h, enc); + let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp); + whites_blacks::apply(px, w, xw, xb, enc); + } // 4–7: per-pixel colour stages. let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral(); if need_color { @@ -732,6 +765,36 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo } } +/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the +/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a +/// preview proxy and the full image agree). +fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) { + let f = w.max(h).div_ceil(256).max(1); + let (sw, sh) = (w.div_ceil(f), h.div_ceil(f)); + let mut sum = vec![[0.0f64; 4]; sw * sh]; + for y in 0..h { + for x in 0..w { + let q = px[y * w + x]; + let s = &mut sum[(y / f) * sw + x / f]; + for c in 0..3 { + s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0)); + } + s[3] += 1.0; + } + } + let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect(); + if cells.is_empty() { + return (0.0, 1.0); + } + let pct = |mut v: Vec, q: f32| { + v.sort_by(f32::total_cmp); + v[((v.len() - 1) as f32 * q).round() as usize] + }; + let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03); + let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995); + (linear_to_srgb(bp), linear_to_srgb(wp)) +} + fn value_noise(x: f32, y: f32, seed: u64) -> f32 { let (xi, yi) = (x.floor(), y.floor()); let (tx, ty) = (x - xi, y - yi); diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs new file mode 100644 index 00000000..7844f8d7 --- /dev/null +++ b/crates/algo/src/camera_raw/local_tone.rs @@ -0,0 +1,428 @@ +//! Shadows and Highlights as a local Laplacian filter. +//! +//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris, +//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a +//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et +//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference +//! values, each pyramid coefficient interpolated between them), on the log2 luminance: +//! +//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and +//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights** +//! pulls pixels brighter than their surroundings toward them. Differences under a threshold +//! are left alone, so fine detail stays. +//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the +//! brightest content under Shadows and the darkest under Highlights). +//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the +//! image size. +//! +//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos +//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the +//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was +//! 3.7–14.3. + +use super::{Encoding, luma}; +use crate::photo_util::par_rows; + +/// Spacing of the fast filter's reference values in stops. Its remapping changes within about +/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth +/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a +/// ramp's slope varies by under a third, as the remapping itself makes it). +const REF_STEP: f32 = 0.125; +/// The filter runs on a copy at most this many pixels across; the change it makes is brought back +/// to full size by a guided upsampling, so a preview and the full image agree. +const WORK_SIDE: usize = 1600; +/// Knots of the strength curves over the surroundings' place in the image range (0 … 1). +const KNOTS: usize = 5; + +/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as +/// a function of its own place in the image range (`own`) times a factor of its surroundings' +/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on +/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid +/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong. +const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 }; +const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 }; + +/// See [`SHADOWS`]. +struct Lift { + own: [f32; KNOTS], + around: [f32; KNOTS], + sigma: f32, + down: f32, +} + +/// Highlights: strengths per unit slider over the surroundings' place in the image range, for +/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in +/// stops. +struct Side { + pull: [f32; KNOTS], + push: [f32; KNOTS], + sigma: f32, +} + +/// One channel, `w × h`. +struct Plane { + w: usize, + h: usize, + v: Vec, +} + +/// Mirrors `i` into `0..n` (the edge sample is not repeated). +fn mirror(i: isize, n: usize) -> usize { + let n = n as isize; + if n == 1 { + return 0; + } + let p = 2 * (n - 1); + let m = i.rem_euclid(p); + (if m >= n { p - m } else { m }) as usize +} + +const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0]; + +/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution). +fn blur_step(p: &Plane, step: usize) -> Plane { + let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step)); + // Columns first at the kept rows, then rows at the kept columns. + let mut tmp = vec![0.0f32; p.w * h2]; + par_rows(&mut tmp, p.w, 1, |y2, row| { + let y = (y2 * step) as isize; + for (x, o) in row.iter_mut().enumerate() { + *o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum(); + } + }); + let mut out = vec![0.0f32; w2 * h2]; + par_rows(&mut out, w2, 1, |y2, row| { + let src = &tmp[y2 * p.w..(y2 + 1) * p.w]; + for (x2, o) in row.iter_mut().enumerate() { + let x = (x2 * step) as isize; + *o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum(); + } + }); + Plane { w: w2, h: h2, v: out } +} + +/// The next coarser level. +fn down(p: &Plane) -> Plane { + blur_step(p, 2) +} + +/// `p` brought up to `w × h`: zeros between samples, then the blur times four. +fn up(p: &Plane, w: usize, h: usize) -> Plane { + let mut z = Plane { w, h, v: vec![0.0; w * h] }; + for y in 0..p.h.min(h.div_ceil(2)) { + for x in 0..p.w.min(w.div_ceil(2)) { + z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0; + } + } + blur_step(&z, 1) +} + +/// Levels down to about 2 pixels on the short side. +fn depth(w: usize, h: usize) -> usize { + let mut s = w.min(h); + let mut n = 1; + while s > 2 { + s = s.div_ceil(2); + n += 1; + } + n +} + +fn gaussian(p: Plane, n: usize) -> Vec { + let mut g = vec![p]; + for _ in 1..n { + let next = down(&g[g.len() - 1]); + g.push(next); + } + g +} + +/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept. +fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { + let n = depth(u.w, u.h); + let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n); + // References span the image's tones; the deepest 0.1 % (noise down to the floor) share the + // lowest reference instead of stretching the spacing. + let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v)); + let lo = quantile(&u.v, 0.001).min(hi); + if hi - lo <= 1e-6 || (hi - lo).is_nan() { + return Plane { w: u.w, h: u.h, v: u.v.clone() }; + } + let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96); + let step = (hi - lo) / (refs - 1) as f32; + let mut lap: Vec> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect(); + for k in 0..refs { + let r = lo + step * k as f32; + let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() }; + par_rows(&mut m.v, u.w, 1, |_, row| { + for v in row.iter_mut() { + *v = remap(*v, r); + } + }); + let gr = gaussian(m, n); + for l in 0..n - 1 { + let coarse = up(&gr[l + 1], gr[l].w, gr[l].h); + let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w); + par_rows(&mut lap[l], lw, 1, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * lw + x; + let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0); + if wgt > 0.0 { + *o += wgt * (fine[i] - coarse.v[i]); + } + } + }); + } + } + let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() }; + for l in (0..n - 1).rev() { + let mut next = up(&o, g[l].w, g[l].h); + for (a, b) in next.v.iter_mut().zip(&lap[l]) { + *a += b; + } + o = next; + } + o +} + +/// The `q` quantile (0 … 1) of `v`. +fn quantile(v: &[f32], q: f32) -> f32 { + let mut s = v.to_vec(); + s.sort_by(f32::total_cmp); + s[((s.len() - 1) as f32 * q).round() as usize] +} + +/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across. +fn range(u: &Plane) -> (f32, f32) { + let f = u.w.max(u.h).div_ceil(256).max(1); + let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f)); + let mut sum = vec![(0.0f64, 0u32); sw * sh]; + for y in 0..u.h { + for x in 0..u.w { + let c = &mut sum[(y / f) * sw + x / f]; + c.0 += f64::from(u.v[y * u.w + x]); + c.1 += 1; + } + } + let cells: Vec = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect(); + (quantile(&cells, 0.01), quantile(&cells, 0.99)) +} + +/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it. +fn excess(d: f32, sigma: f32) -> f32 { + d - sigma * (d / sigma).tanh() +} + +fn knot(t: f32, k: &[f32; KNOTS]) -> f32 { + let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32; + let i = (x as usize).min(KNOTS - 2); + k[i] + (k[i + 1] - k[i]) * (x - i as f32) +} + +/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their +/// surroundings move toward them (or away, for positive values) by the part of the difference +/// past `sigma`, then the darkest content is put back where it was. +fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane { + let (lo, hi) = range(u); + let span = (hi - lo).max(1.0); + let strengths = if amount < 0.0 { &side.pull } else { &side.push }; + let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma)); + let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01); + Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() } +} + +/// The Shadows pass on the log luminance `u` (`amount` −1 … 1). +fn shadows_pass(u: &Plane, amount: f32) -> Plane { + let (lo, hi) = range(u); + let span = (hi - lo).max(1.0); + let k = if amount > 0.0 { amount } else { amount * SHADOWS.down }; + let o = llf(u, |x, g| { + let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0); + if below == 0.0 { + return x; + } + let w = below * below * (3.0 - 2.0 * below); + x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w + }); + // Keep the brightest content. + let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99); + Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() } +} + +/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`. +pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) { + if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 { + return; + } + const FLOOR: f32 = 1.0 / 65536.0; + let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() }; + // The filter on a copy at most WORK_SIDE across (box-averaged in log). + let f = w.max(h).div_ceil(WORK_SIDE).max(1); + let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) }; + let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() }; + if highlights != 0.0 { + o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS); + } + if shadows != 0.0 { + o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0); + } + // The change in log luminance, at full size. + let change: Vec = if f == 1 { + o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect() + } else { + let d: Vec = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect(); + guided_upsample(&small, &d, &u) + }; + par_rows(px, w, 1, |y, row| { + for (x, q) in row.iter_mut().enumerate() { + let gain = change[y * w + x].exp2(); + for c in 0..3 { + q[c] = enc.encode(enc.decode(q[c]) * gain); + } + } + }); +} + +/// `u` averaged over `f × f` blocks. +fn shrink(u: &Plane, f: usize) -> Plane { + let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f)); + let mut v = vec![0.0f32; sw * sh]; + par_rows(&mut v, sw, 1, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let (x0, y0) = (x * f, y * f); + let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h)); + let mut s = 0.0f64; + for yy in y0..y1 { + for xx in x0..x1 { + s += f64::from(u.v[yy * u.w + xx]); + } + } + *o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32; + } + }); + Plane { w: sw, h: sh, v } +} + +/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local +/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun, +/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one. +fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec { + let (sw, sh) = (small.w, small.h); + let r = 2; + let eps = 0.01; + let mean = |v: &[f32]| super::box_mean(v, sw, sh, r); + let ud: Vec = small.v.iter().zip(d).map(|(a, b)| a * b).collect(); + let uu: Vec = small.v.iter().map(|a| a * a).collect(); + let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu)); + let a: Vec = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect(); + let b: Vec = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect(); + let (ma, mb) = (mean(&a), mean(&b)); + let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32); + let mut out = vec![0.0f32; full.w * full.h]; + par_rows(&mut out, full.w, 1, |y, row| { + let sy = (y as f32 + 0.5) * fy - 0.5; + for (x, o) in row.iter_mut().enumerate() { + let sx = (x as f32 + 0.5) * fx - 0.5; + let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy); + let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy); + *o = av * full.v[y * full.w + x] + bv; + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const SRGB: Encoding = Encoding(0.0); + + fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> { + (0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect() + } + + #[test] + fn a_flat_image_is_unchanged() { + let mut px = grey(64, 48, |_, _| 0.4); + apply(&mut px, 64, 48, 100.0, -100.0, SRGB); + assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5)); + } + + #[test] + fn shadows_raise_a_dark_area_without_a_halo() { + // A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely + // moves either): the bright half stays, and no halo forms on either side of the edge. + let (w, h) = (256, 128); + let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 }); + apply(&mut px, w, h, 100.0, 0.0, SRGB); + let row = &px[64 * w..65 * w]; + assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002); + let (far, near) = (row[10][0], row[w / 2 - 1][0]); + assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}"); + } + + #[test] + fn grey_moves_as_in_photoshop() { + // Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100 + // takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255). + let n = 256; + let image = |c: f32| { + grey(n, n, |x, y| { + if x >= n - 26 && y >= n - 26 { + 0.97 + } else if x >= n - 26 && y < 26 { + 0.015 + } else { + c + } + }) + }; + for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] { + let mut px = image(0.5); + apply(&mut px, n, n, s, hl, SRGB); + let got = px[175 * n + 100][0] * 255.0; + assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}"); + } + } + + #[test] + fn highlights_lower_a_bright_area_and_keep_the_darkest() { + let (w, h) = (192, 192); + let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 }); + apply(&mut px, w, h, 0.0, -100.0, SRGB); + assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]); + assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]); + } + + #[test] + fn smooth_gradients_stay_smooth() { + // A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated + // between references too far apart drew contour lines across such ramps (Shadows +100 on + // a D4 raw): the output must rise steadily, without ripples. + let (w, h) = (640, 48); + let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2())); + for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] { + let mut p = px.clone(); + apply(&mut p, w, h, s, hl, SRGB); + let row: Vec = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect(); + let steps: Vec = row.windows(2).map(|d| d[1] - d[0]).collect(); + assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses"); + let jumps: Vec = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect(); + let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b)); + // The ramp rises 0.0125 stops per pixel; its slope may change by a third at most. + assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel"); + } + px.clear(); + } + + #[test] + fn the_result_does_not_depend_on_the_image_size() { + let at = |n: usize| { + let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 }); + apply(&mut px, n, n, 70.0, -60.0, SRGB); + [px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]] + }; + let (a, b) = (at(96), at(384)); + assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}"); + } +} diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs new file mode 100644 index 00000000..382ade50 --- /dev/null +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -0,0 +1,418 @@ +//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them. +//! +//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen +//! histograms went through the filter by script and came back exactly; no Adobe code or data +//! was used. +//! +//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites +//! first. On an image whose darkest and lightest percent reach black and white the curve of a +//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`]. +//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every +//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is +//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on +//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and +//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low +//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks +//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`. +//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black +//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about +//! 0.15); its curves there are not rows of the full-range family. +//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel +//! by the curve's change of its luminance (closest to Photoshop on saturated colours). + +use super::Encoding; +use crate::photo_util::par_rows; + +/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded). +const STEPS: usize = 33; +/// The first row's internal amount; rows are 10 apart. +const WHITES_FROM: f32 = -100.0; +const BLACKS_FROM: f32 = -100.0; +// Measured levels; some happen to lie near π/8 and π/4. +/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1. +#[allow(clippy::approx_constant)] +static WHITES: [[f32; 33]; 38] = [ + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028, + 0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042, + 0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064, + 0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094, + 0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136, + 0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189, + 0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254, + 0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332, + 0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000, + ], + [ + 0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421, + 0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000, + ], + [ + 0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520, + 0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000, + ], + [ + 0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625, + 0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667, + 0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741, + 0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845, + 0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983, + 0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161, + 0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387, + 0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670, + 0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015, + 0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424, + 0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893, + 0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417, + 0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964, + 0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475, + 0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], +]; +/// Blacks curves at internal amounts -100, …, 100 in steps of 10. +#[allow(clippy::approx_constant)] +static BLACKS: [[f32; 33]; 21] = [ + [ + 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571, + 0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885, + 0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094, + 0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243, + 0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352, + 0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433, + 0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492, + 0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535, + 0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000, + ], + [ + 0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567, + 0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000, + ], + [ + 0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597, + 0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000, + ], + [ + 0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625, + 0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000, + ], + [ + 0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684, + 0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000, + ], + [ + 0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741, + 0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000, + ], + [ + 0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796, + 0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000, + ], + [ + 0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849, + 0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000, + ], + [ + 0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900, + 0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000, + ], + [ + 0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949, + 0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000, + ], + [ + 0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996, + 0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000, + ], + [ + 0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042, + 0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000, + ], + [ + 0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085, + 0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000, + ], + [ + 0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127, + 0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000, + ], +]; +const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000]; +const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000]; +static AW: [[f32; 8]; 9] = [ + [1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000], + [1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875], + [2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938], + [2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437], + [2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250], + [1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625], + [1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875], + [1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562], + [0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562], +]; +static AB: [[f32; 8]; 9] = [ + [0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000], + [1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938], + [1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250], + [1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375], + [1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375], + [1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125], + [1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375], + [1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500], + [1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312], +]; + +/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated). +fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 { + if v >= 1.0 { + return 1.0; + } + let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32); + let i = (r as usize).min(rows.len() - 2); + let t = r - i as f32; + let at = |row: &[f32; STEPS]| { + let p = v.max(0.0) * (STEPS - 1) as f32; + let j = (p as usize).min(STEPS - 2); + let s = p - j as f32; + row[j] + (row[j + 1] - row[j]) * s + }; + at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t +} + +/// Whites at internal amount `x` (−100 … 270) of grey level `v`. +pub(crate) fn whites(v: f32, x: f32) -> f32 { + curve(&WHITES, WHITES_FROM, x, v) +} + +/// Blacks at internal amount `x` (−100 … 100) of grey level `v`. +pub(crate) fn blacks(v: f32, x: f32) -> f32 { + curve(&BLACKS, BLACKS_FROM, x, v) +} + +/// The least Blacks adaptation on photographs. +const PHOTO_BLACKS: f32 = 1.2; + +/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`. +fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 { + let idx = |grid: &[f32], v: f32| { + let v = v.clamp(grid[0], grid[grid.len() - 1]); + let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2); + (i, (v - grid[i]) / (grid[i + 1] - grid[i])) + }; + let (i, s) = idx(&BPS, bp); + let (j, t) = idx(&WPS, wp); + let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t; + row(i) * (1.0 - s) + row(i + 1) * s +} + +/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st +/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded). +pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) { + let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites }; + // On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where + // their black point is 0 (ten public-domain photos: 1.08–1.36). + let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks }; + (w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0)) +} + +/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per +/// row) encoded with `enc`. Values above 1 (float documents) keep their excess. +pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) { + if w == 0.0 && b == 0.0 { + return; + } + // A grey level's new level, both sRGB-encoded. + let grey = |v: f32| { + let v = if w != 0.0 { whites(v, w) } else { v }; + if b != 0.0 { blacks(v, b) } else { v } + }; + let per_channel = w > 0.0 || b < 0.0; + par_rows(px, width, 1, |_, row| { + for q in row.iter_mut() { + let lin = [0, 1, 2].map(|c| enc.decode(q[c])); + if per_channel { + for c in 0..3 { + let over = (lin[c] - 1.0).max(0.0); + q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over); + } + } else { + let y = super::luma_linear(lin).max(0.0); + if y <= 1e-7 { + continue; + } + let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0); + let k = y2 / y; + for c in 0..3 { + q[c] = enc.encode(lin[c] * k); + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_range_rows_match_photoshop() { + // Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels). + assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6); + assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6); + assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6); + assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6); + // The compressing directions keep black and white. + assert_eq!(whites(1.0, -100.0), 1.0); + assert_eq!(blacks(0.0, 100.0), 0.0); + } + + #[test] + fn the_clipping_directions_adapt_to_the_image() { + // A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6]. + let (w, _) = amounts(50.0, 0.0, 0.05, 0.6); + assert!((w - 94.5).abs() < 2.0, "{w}"); + // A raised black point strengthens Blacks −40 to −58 on [0.6, 1]. + let (_, b) = amounts(0.0, -40.0, 0.6, 1.0); + assert!((b + 58.0).abs() < 1.0, "{b}"); + // Never below the photographs' 1.2. + assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3); + // The compressing directions never adapt. + assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0)); + } +} diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 31d69300..379ca240 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -369,7 +369,28 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result { })) } +/// The transfer curve Camera Raw decodes `doc`'s values with ([`CameraRaw::encoding_gamma`]): +/// ProPhoto's gamma 1.8 (raws open in it), Adobe RGB's 563/256, 1 for linear sRGB, else 0 for +/// the sRGB curve. +pub fn encoding_gamma(doc: &Document) -> f32 { + use photocraft_cms::Builtin; + let profile = crate::color_cmds::document_profile(doc); + [(Builtin::ProPhotoCompat, 1.8), (Builtin::AdobeRgbCompat, 563.0 / 256.0), (Builtin::LinearSrgb, 1.0)] + .into_iter() + .find(|(b, _)| profile.same_colors(b.profile())) + .map_or(0.0, |(_, g)| g) +} + fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result { + // The document's transfer curve goes with the settings, so a Smart Filter keeps it. + let mut p = p.clone(); + if p.get("encodingGamma").is_none() + && let Some(g) = s.active().map(|d| encoding_gamma(&d.doc)).filter(|g| *g > 0.0) + && let Value::Object(m) = &mut p + { + m.insert("encodingGamma".into(), json!((f64::from(g) * 1e4).round() / 1e4)); + } + let p = &p; let cr = raw_params(RAW, p)?; // New settings are strict; stored Smart Filters re-apply through the lenient `raw_params`. cr.validate().map_err(|e| bad(RAW, e))?; @@ -583,6 +604,32 @@ mod tests { assert!((p[0] - p[2]).abs() < 0.01, "{p:?}"); } + #[test] + fn camera_raw_decodes_light_with_the_documents_curve() { + // A raw opens in ProPhoto (gamma 1.8): the filter keeps that with its settings, so Whites, + // Blacks, Shadows and Highlights work on the same light as when it was applied. + let mut s = session(16); + s.execute("edit.assignProfile", json!({"profile": "prophoto-compat"})).unwrap(); + assert_eq!(encoding_gamma(&s.active().unwrap().doc), 1.8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute(RAW, json!({"shadows": 40, "whites": -30})).unwrap(); + let d = s.active().unwrap(); + let filters = match &d.doc.layer(d.active_layer.unwrap()).unwrap().content { + LayerContent::Smart(so) => so.smart_filters.clone(), + _ => panic!("smart object expected"), + }; + assert_eq!(filters[0].params["encodingGamma"], json!(1.8)); + // sRGB documents use the sRGB curve, the default, and store nothing. + let mut s = session(8); + assert_eq!(encoding_gamma(&s.active().unwrap().doc), 0.0); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute(RAW, json!({"shadows": 40})).unwrap(); + let d = s.active().unwrap(); + if let LayerContent::Smart(so) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content { + assert!(so.smart_filters[0].params.get("encodingGamma").is_none()); + } + } + #[test] fn camera_raw_rejects_an_area_too_large_to_develop() { // #962: `area.width() * area.height()` overflowed u32 on a legal 65536×65536 canvas, diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 7e1f9164..26e62d25 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; +pub use photocraft_raw::{Tone, retone}; use crate::flat::image_to_document; use crate::{ImportResult, IoError}; @@ -41,8 +42,9 @@ fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { Ok(img.oriented(o)?) } -/// Camera Raw's Temperature, Tint and Exposure, applied while developing the sensor data -/// (the open-time Camera Raw dialog) instead of to the developed RGB pixels. +/// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, +/// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the +/// developed RGB pixels. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -51,11 +53,23 @@ pub struct RawTuning { pub tint: f32, /// EV, as `CameraRaw::exposure`. pub exposure: f32, + /// −100…100, as `CameraRaw::shadows`: in linear light before the tone curve, as Camera Raw + /// does for raw files ([`photocraft_raw::Tone`]). + pub shadows: f32, + /// −100…100, as `CameraRaw::highlights`. + pub highlights: f32, + /// −100…100, as `CameraRaw::clarity`. + pub clarity: f32, + /// −100…100, as `CameraRaw::texture`. + pub texture: f32, + /// −100…100, as `CameraRaw::dehaze`. + pub dehaze: f32, } /// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure, -/// Temperature / Tint as gains on the camera's as-shot white-balance multipliers (the same -/// gains the Camera Raw filter applies to RGB). The second value is `false` when the file has +/// Shadows, Highlights, Clarity, Texture and Dehaze in linear light before the tone curve, Temperature / Tint as gains on the +/// camera's as-shot white-balance multipliers (the same gains the Camera Raw filter applies to +/// RGB). The second value is `false` when the file has /// no as-shot white balance: Temperature / Tint were then not applied, and the caller keeps /// them as RGB adjustments. pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<(ImportResult, bool), IoError> { @@ -65,13 +79,18 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< return Err(IoError::Raw(RawError::Malformed(format!("exposure {} EV is out of range", tuning.exposure)))); } let sensor = photocraft_raw::decode(bytes, &limits()).map_err(IoError::Raw)?; - let mut opts = DevelopOptions { limits: limits(), ..Default::default() }; - // Use the same camera rendering as normal import. Fit at the camera's default WB and - // exposure before applying user controls, so the fit cannot cancel their adjustments. - if sensor.color.calibrations.is_empty() { - opts.camera_look = fit_preview_look(bytes, &sensor, &opts).ok(); - } - opts.exposure = exposure; + let slider = |v: f32| if v.is_finite() { v.clamp(-100.0, 100.0) } else { 0.0 }; + let tone = photocraft_raw::Tone { + shadows: slider(tuning.shadows), + highlights: slider(tuning.highlights), + clarity: slider(tuning.clarity), + texture: slider(tuning.texture), + dehaze: slider(tuning.dehaze), + }; + // The colour rendering the default open used (a look fitted to the camera's JPEG for files + // without colour calibration), so re-developing changes only what the sliders change. + let camera_look = default_look(bytes, &sensor); + let mut opts = DevelopOptions { limits: limits(), exposure, tone, camera_look, ..Default::default() }; let wb_applied = match sensor.camera_wb { Some(m) => { let g = wb_gains(tuning.temperature, tuning.tint); @@ -86,10 +105,45 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< } else { "estimated white balance".into() }; - let summary = format!("developed in Camera Raw (exposure {:+.2} EV, {wb})", tuning.exposure); + let tone = [("shadows", tone.shadows), ("highlights", tone.highlights), ("clarity", tone.clarity), ("texture", tone.texture), ("dehaze", tone.dehaze)] + .iter() + .filter(|(_, v)| *v != 0.0) + .map(|(k, v)| format!(", {k} {v:+.0}")) + .collect::(); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb})", tuning.exposure); Ok((developed_document(name, format, dev, &summary)?, wb_applied)) } +/// The tone curve `bytes` develop with by default: the fitted look's for a file without colour +/// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw +/// this crate decodes). For previews of the open-time raw-stage sliders +/// ([`photocraft_raw::retone`]), in linear-light knots. +pub fn tone_curve(bytes: &[u8]) -> Vec<[f32; 2]> { + let Ok(sensor) = photocraft_raw::decode(bytes, &limits()) else { return Vec::new() }; + match default_look(bytes, &sensor) { + Some(look) => look.tone.as_ref().map(look_knots).unwrap_or_default(), + None => sensor.tone_curve, + } +} + +/// A fitted look's tone curve as knots for [`photocraft_raw::retone`] (which reads them +/// log-linearly): sampled densely enough that the two agree within a fraction of a code value. +fn look_knots(t: &photocraft_raw::ToneCurve) -> Vec<[f32; 2]> { + const N: usize = 256; + (1..=N).map(|i| (i as f64 / N as f64).powi(2)).map(|x| [x as f32, t.eval(x) as f32]).collect() +} + +/// The look a default open develops `bytes` with: fitted to the embedded JPEG when the file has +/// no colour calibration, is not monochrome and the fit clearly helps (as [`import_raw_with`] +/// does), else `None`. It is fitted at the camera's own white balance and exposure, so a +/// re-develop's sliders are not fitted away. +fn default_look(bytes: &[u8], sensor: &Sensor) -> Option { + if !sensor.color.calibrations.is_empty() || sensor.is_monochrome() { + return None; + } + fit_preview_look(bytes, sensor, &DevelopOptions { limits: limits(), ..Default::default() }).ok() +} + /// "Canon" + "Canon EOS 80D" and "NIKON CORPORATION" + "NIKON D3200" read as the model alone: /// the model already starts with the make's first word. fn camera_name(make: Option<&str>, model: Option<&str>) -> String { diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index d0bc370d..7a11f4fb 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -214,7 +214,11 @@ fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting assert!(fitted(&normal.warnings)); let sensor = decode(&bytes, &Default::default()).unwrap(); let look = fit_preview_look(&bytes, &sensor, &DevelopOptions::default()).unwrap(); - for tuning in [RawTuning::default(), RawTuning { exposure: 0.5, ..Default::default() }, RawTuning { exposure: -0.5, temperature: 20.0, tint: -15.0 }] { + for tuning in [ + RawTuning::default(), + RawTuning { exposure: 0.5, ..Default::default() }, + RawTuning { exposure: -0.5, temperature: 20.0, tint: -15.0, ..Default::default() }, + ] { let (got, applied) = import_raw_tuned("camera.nef", &bytes, &tuning).unwrap(); assert!(applied && fitted(&got.warnings) && !fallback(&got.warnings), "{:?}", got.warnings); assert_eq!(got.document.depth, SampleType::U16); @@ -283,3 +287,28 @@ fn truncated_raws_with_a_fittable_preview_do_not_panic() { let _ = photocraft_io::raw::import_raw_tuned("x.nef", &b[..n], &Default::default()); } } + +/// The open-time Camera Raw re-develop keeps the colour rendering the default open used: a raw +/// without calibration gets the same look fitted to its JPEG, so neutral settings give the same +/// pixels, and the previews' tone curve is the look's. +#[test] +fn a_re_develop_keeps_the_fitted_look() { + let (w, h) = (480, 320); + let cam = patches(w, h); + let nef = nef_uncompressed(&cam, w, h, Some(camera_rendering(&cam, w, h))); + let opened = import("DSC_0003.NEF", &nef).unwrap(); + let (again, _) = photocraft_io::raw::import_raw_tuned("DSC_0003.NEF", &nef, &Default::default()).unwrap(); + assert!(fitted(&again.warnings), "{:?}", again.warnings); + let pixels = |d: &photocraft_doc::Document| { + let s = d.layers[0].surface().unwrap(); + let mut px = vec![[0.0f32; 4]; w * h]; + s.read_rgba_into(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &mut px); + px + }; + assert_eq!(pixels(&opened.document), pixels(&again.document)); + let curve = photocraft_io::raw::tone_curve(&nef); + assert!(curve.len() > 2 && curve.windows(2).all(|k| k[1][0] > k[0][0] && k[1][1] >= k[0][1]), "{curve:?}"); + // The camera's brightening curve: mid-grey comes out lighter. + let mid = curve.iter().find(|k| k[0] >= 0.18).unwrap(); + assert!(mid[1] > mid[0], "{mid:?}"); +} diff --git a/crates/raw/README.md b/crates/raw/README.md index 7bcfdcfe..461a0ed0 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -122,6 +122,14 @@ brightened shadows locally (Active D-Lighting), which one global curve cannot fo (preview decode included) takes about 50–70 ms on a 24 MP file. It is an estimate of the camera's look, not a measured calibration: picture styles and hue-dependent rendering are approximated. +Cameras with a profile measured against Photoshop's Camera Raw (`src/cameras.rs`: the Nikon D4) carry +a calibration, so no look is fitted for them. On 6 D4 NEFs against Photoshop 25.4's default Camera +Raw rendering (quarter size, medians over the frames) the measured profile is 0.048 stop from its +luminance with a mean CIE76 ΔE of 1.8; the look fitted to the embedded JPEG would be 0.30 stop and +ΔE 6.4, as it follows the camera's picture style instead. The open-time re-develop +(`photocraft-io`'s `import_raw_tuned`) and the tone curve its previews use (`tone_curve`) take the +same look as the default open, so changing a slider does not change the colour rendering. + ## Tools `cargo run --release -p photocraft-raw --example rawinfo -- [--dump] [--demosaic ahd] [--png DIR] FILE...` diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 45eb542c..7439433c 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -11,10 +11,13 @@ //! (2 px of optical black on the left, 50 + 8 on the right show as a purple stripe otherwise). The //! data is already black-subtracted (the darkest image pixels reach 0). Green saturates at //! 15546–15672 while red and blue reach 15687–16383, so one level of 15520 makes clipped -//! highlights white without shifting the white balance. Matrix and curve were fitted to the -//! camera's embedded JPEGs (Picture Control Standard, Active D-Lighting off; 52 frames, smooth -//! areas, 4 × 4 means): on 15 held-out frames the development is 7.2/255 per channel off the -//! camera's JPEG on average, against 34 without a profile. +//! highlights white without shifting the white balance. Matrix, curve and black point copy +//! Photoshop Camera Raw's default rendering of these files, measured black-box: 29 NEFs opened +//! in Photoshop 25.4 against our own linear development of the same data (8 × 8 means, pixels +//! clipped in neither). The matrix is our first ForwardMatrix (fitted to the camera's embedded +//! JPEGs) times a linear-ProPhoto look matrix whose rows sum to 1; the curve applies per channel +//! after it; the black point follows the frame's darkest tones ([`crate::tone`]). Median +//! luminance difference to Camera Raw: 0.04 stop (0.29 with the camera-JPEG curve). //! //! Bodies whose full profile is not measured yet can still get their measured image area — the //! same alignment method, see [`IMAGE_AREAS`] — so their frames crop like the camera's JPEG. @@ -22,39 +25,63 @@ use crate::color::{Calibration, ColorInfo, IDENTITY, Mat3}; use crate::sensor::{Rect, Sensor}; -const D4_FORWARD: Mat3 = [[0.644598, 0.0703598, 0.249262], [0.342249, 0.518929, 0.138822], [0.0568265, -0.269568, 1.03795]]; +const D4_FORWARD: Mat3 = [[0.639454, 0.260359, 0.0643991], [0.273094, 0.796492, -0.0695779], [0.00965321, -0.131427, 0.94699]]; +/// A third of the 0.1th percentile of the luminance, as Camera Raw renders D4 NEFs by default. +const D4_BLACK_POINT: f32 = 0.33; const D4_COLOR: Mat3 = [[0.895936, -0.218092, -0.185988], [-1.09736, 2.06898, -0.0131889], [-0.26977, 0.393884, 0.687829]]; -const D4_TONE: [[f32; 2]; 31] = [ - [0.005524272, 0.00285028], - [0.006569503, 0.00357083], - [0.0078125, 0.00447912], - [0.009290681, 0.00605813], - [0.01104854, 0.00841445], - [0.01313901, 0.011536], - [0.015625, 0.0165399], - [0.01858136, 0.0230234], - [0.02209709, 0.0319205], - [0.02627801, 0.0434858], - [0.03125, 0.0582138], - [0.03716272, 0.0771963], - [0.04419417, 0.0993894], - [0.05255603, 0.125058], - [0.0625, 0.157207], - [0.07432544, 0.194929], - [0.08838835, 0.236109], - [0.1051121, 0.279155], - [0.125, 0.329365], - [0.1486509, 0.388405], - [0.1767767, 0.457944], - [0.2102241, 0.52456], - [0.25, 0.598051], - [0.2973018, 0.668794], - [0.3535534, 0.749954], - [0.4204482, 0.815443], - [0.5, 0.870167], - [0.5946036, 0.907869], - [std::f32::consts::FRAC_1_SQRT_2, 0.939351], - [0.8408964, 0.970567], +const D4_TONE: [[f32; 2]; 53] = [ + [0.00012207031, 7.44377e-05], + [0.00014516688, 8.44049e-05], + [0.00017263349, 9.57067e-05], + [0.00020529698, 0.000108523], + [0.00024414062, 0.000123055], + [0.00029033376, 0.000123202], + [0.00034526698, 0.000123348], + [0.00041059396, 0.000142387], + [0.00048828125, 0.000164366], + [0.0005806675, 0.00020246], + [0.00069053395, 0.000249382], + [0.0008211879, 0.000310083], + [0.0009765625, 0.000385559], + [0.001161335, 0.000474115], + [0.0013810679, 0.00058301], + [0.0016423758, 0.000717142], + [0.001953125, 0.000882133], + [0.00232267, 0.00109054], + [0.0027621358, 0.00134818], + [0.0032847517, 0.00163655], + [0.00390625, 0.0019866], + [0.00464534, 0.00235945], + [0.0055242716, 0.00280228], + [0.0065695033, 0.00342616], + [0.0078125, 0.00418894], + [0.00929068, 0.00540942], + [0.011048543, 0.0069855], + [0.013139007, 0.00959286], + [0.015625, 0.0131734], + [0.01858136, 0.0181471], + [0.022097087, 0.0249987], + [0.026278013, 0.0336416], + [0.03125, 0.0452726], + [0.03716272, 0.0591889], + [0.044194173, 0.077383], + [0.052556027, 0.0990869], + [0.0625, 0.126878], + [0.07432544, 0.161688], + [0.088388346, 0.206049], + [0.10511205, 0.256563], + [0.125, 0.31946], + [0.14865088, 0.382637], + [0.17677669, 0.458308], + [0.2102241, 0.531942], + [0.25, 0.617405], + [0.29730177, 0.683279], + [0.35355338, 0.756182], + [0.4204482, 0.809073], + [0.5, 0.865664], + [0.59460354, 0.900869], + [std::f32::consts::FRAC_1_SQRT_2, 0.937507], + [0.8408964, 0.956434], [1.0, 1.0], ]; @@ -71,6 +98,8 @@ struct Profile { forward: Mat3, color: Mat3, tone: &'static [[f32; 2]], + /// Camera Raw's adaptive black-point factor ([`Sensor::black_point`]). + black_point: f32, } const PROFILES: &[Profile] = &[Profile { @@ -83,6 +112,7 @@ const PROFILES: &[Profile] = &[Profile { forward: D4_FORWARD, color: D4_COLOR, tone: &D4_TONE, + black_point: D4_BLACK_POINT, }]; /// A measured image area of a body whose remaining profile has not been @@ -134,6 +164,7 @@ pub(crate) fn apply(s: &mut Sensor) { ..ColorInfo::default() }; s.tone_curve = p.tone.to_vec(); + s.black_point = p.black_point; } #[cfg(test)] @@ -164,6 +195,7 @@ mod tests { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings: vec!["NEF: black level not recorded in a documented tag; assumed 0".into()], } } @@ -181,6 +213,7 @@ mod tests { assert_eq!(s.color.calibrations.len(), 1); assert_eq!(s.color.calibrations[0].forward_matrix, Some(D4_FORWARD)); assert_eq!(s.tone_curve.len(), D4_TONE.len()); + assert_eq!(s.black_point, D4_BLACK_POINT); assert!(s.warnings.is_empty(), "the D4's black level is known: {:?}", s.warnings); // The profile gives a colour conversion for the as-shot white balance. assert!(s.color.balanced_to_xyz_d50([0.5, 1.0, 0.7]).is_some()); diff --git a/crates/raw/src/cr2.rs b/crates/raw/src/cr2.rs index 5b360122..8e851c5d 100644 --- a/crates/raw/src/cr2.rs +++ b/crates/raw/src/cr2.rs @@ -293,6 +293,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index 8b69cc29..ebe8656a 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -1,12 +1,15 @@ //! Raw development: linearize, subtract black, scale to white, white //! balance, clip highlights, demosaic, camera → XYZ (D50) → linear ProPhoto, -//! exposure, gamma 1.8 encode to 16 bits, orientation. +//! exposure, Dehaze, Shadows / Highlights / Clarity / Texture and the black +//! point (`tone.rs`), the profile's tone curve, gamma 1.8 encode to 16 bits, +//! orientation. use crate::color::{self, Mat3}; use crate::demosaic::{Demosaic, PAD, Padded, Pattern, demosaic, demosaic_cfa}; use crate::error::{RawError, Result}; use crate::look::{CameraLook, ToneCurve}; use crate::sensor::Sensor; +use crate::tone::{Haze, Tone, ToneMap, black_level}; use crate::{Limits, RawFormat, par}; /// White balance choice. @@ -30,6 +33,9 @@ pub struct DevelopOptions { pub exposure: f64, /// Apply the file's orientation (rotate / flip the pixels). pub orient: bool, + /// Camera Raw's Shadows, Highlights, Clarity, Texture and Dehaze, applied in linear light + /// before the tone curve. + pub tone: Tone, pub limits: Limits, /// A colour rendering fitted to the camera's own JPEG ([`crate::fit_look`]). When set it /// replaces the file's colour calibration (or the neutral fallback) and adds its tone curve. @@ -43,6 +49,7 @@ impl Default for DevelopOptions { white_balance: WhiteBalance::default(), exposure: 0.0, orient: true, + tone: Tone::default(), limits: Limits::default(), camera_look: None, } @@ -330,7 +337,24 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { } } - // Camera → ProPhoto, exposure, the look's tone curve, gamma 1.8, 16 bits. + // Camera → ProPhoto, exposure, Dehaze, Shadows / Highlights / Clarity / Texture and the black + // point, the look's (or the profile's) tone curve, gamma 1.8, 16 bits. + let y_row = color::rgb_to_xyz(color::ROMM, color::D50_XY)[1].map(|v| v as f32); + let lum = |v: &[f32; 3]| y_row[0] * v[0] + y_row[1] * v[1] + y_row[2] * v[2]; + // Monochrome: the one sample, grey; no colour matrix. + let pro = |p: &[f32]| if mono { [grey_gain * p[0]; 3] } else { [0, 1, 2].map(|k| out_m[k][0] * p[0] + out_m[k][1] * p[1] + out_m[k][2] * p[2]) }; + let haze = Haze::new(w, h, |i| pro(&rgb[i * 3..i * 3 + 3]), opts.tone.dehaze); + // Linear ProPhoto of pixel `i` after Dehaze. + let linear = |i: usize, p: &[f32]| { + let v = pro(p); + haze.as_ref().map_or(v, |z| z.apply(i % w, i / w, v)) + }; + let mut map = ToneMap::new(w, h, |i| lum(&linear(i, &rgb[i * 3..i * 3 + 3])), opts.tone, s.black_point); + if let (Some(m), Some(_)) = (&mut map, &haze) { + // The black point belongs to the default rendering: Dehaze does not move it (taken after + // Dehaze it fell to almost 0, and Dehaze darkened less than Photoshop's). + m.black = black_level(w, h, |i| lum(&pro(&rgb[i * 3..i * 3 + 3])), s.black_point); + } let mut out = vec![0u16; w * h * 3]; let lut = match look { Some(l) => GammaLut::with_tone(l.tone.as_ref()), @@ -339,13 +363,16 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { par::chunks_mut(&mut out, band * w * 3, |b, chunk| { let start = b * band * w * 3; let src = &rgb[start..start + chunk.len()]; - for (o, p) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0) { - if mono { - *o = [lut.encode(grey_gain * p[0]); 3]; - continue; + for (i, (o, p)) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0).enumerate() { + let mut v = linear(b * band * w + i, p); + if let Some(m) = &map { + let g = m.gain(i % w, b * band + i / w, lum(&v)); + // The black point first, as Camera Raw: lifting before it would flatten the + // deepest shadows' contrast. + v = v.map(|c| m.unblack(c) * g); } for k in 0..3 { - o[k] = lut.encode(out_m[k][0] * p[0] + out_m[k][1] * p[1] + out_m[k][2] * p[2]); + o[k] = lut.encode(v[k]); } } }); @@ -453,7 +480,7 @@ impl GammaLut { /// `curve` at linear `v`: linear interpolation between the points in log₂ of the input, a line /// through 0 below the first point, the last output beyond the last. -fn tone(curve: &[[f32; 2]], v: f32) -> f32 { +pub(crate) fn tone(curve: &[[f32; 2]], v: f32) -> f32 { let (Some(first), Some(last)) = (curve.first(), curve.last()) else { return v }; if v <= first[0] { return if first[0] > 0.0 { v * first[1] / first[0] } else { first[1] }; diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 5ff9fd14..e8422b54 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -271,6 +271,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 }, gain_maps, tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 9a702f2d..dc1805d4 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -64,6 +64,7 @@ mod sensor; mod sony; mod tiff; mod tiffep; +mod tone; #[cfg(feature = "testgen")] pub mod testgen; @@ -76,6 +77,7 @@ pub use crate::look::{CameraLook, LookRejected, PROXY_EDGE, Reference, ToneCurve pub use crate::opcodes::GainMap; pub use crate::preview::{Preview, PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_previews}; pub use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; +pub use crate::tone::{Tone, retone}; use crate::tiff::{Tiff, tag}; diff --git a/crates/raw/src/orf.rs b/crates/raw/src/orf.rs index ba159267..9271aff9 100644 --- a/crates/raw/src/orf.rs +++ b/crates/raw/src/orf.rs @@ -150,6 +150,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/raf.rs b/crates/raw/src/raf.rs index dee720b6..cb794889 100644 --- a/crates/raw/src/raf.rs +++ b/crates/raw/src/raf.rs @@ -302,6 +302,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { baseline_exposure, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/rw2.rs b/crates/raw/src/rw2.rs index fe1e0b2a..231cd097 100644 --- a/crates/raw/src/rw2.rs +++ b/crates/raw/src/rw2.rs @@ -170,6 +170,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index a775586d..e8c65ebf 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -157,6 +157,10 @@ pub struct Sensor { /// output]` pairs in linear ProPhoto, applied per channel after the colour conversion and /// exposure. Below the first point the curve is a line through 0; empty: no curve. pub tone_curve: Vec<[f32; 2]>, + /// Camera Raw's adaptive black point, for profiles that copy its default rendering: this + /// factor times the 0.1th percentile of the image's linear luminance is subtracted before the + /// tone curve (see `tone.rs`). 0: no black point. + pub black_point: f32, pub warnings: Vec, } diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index b940d6bc..4a75491f 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -223,6 +223,7 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result bool { + self.gain_neutral() && self.dehaze == 0.0 + } + + /// No luminance gain: Shadows, Highlights, Clarity and Texture at 0. + fn gain_neutral(&self) -> bool { + self.work_neutral() && self.texture == 0.0 + } + + /// Nothing to compute on the work copy: Shadows, Highlights and Clarity at 0. + fn work_neutral(&self) -> bool { + self.shadows == 0.0 && self.highlights == 0.0 && self.clarity == 0.0 + } +} + +/// Long side of the copy the base is computed on, and the guided filter's radius and ε there. +const WORK_SIDE: usize = 1232; +const BASE_RADIUS: f32 = 32.0; +const BASE_EPS: f32 = 1.0; +/// ε (stops²) of the guided upsampling that carries the change to full size. +const UPSAMPLE_EPS: f32 = 1.0; +/// The curves' first knot (stops from the anchor) and knot spacing. +const LO: f32 = -12.0; +const STEP: f32 = 0.25; + +// log₂ gain at +100 / −100 against B − anchor, knots LO, LO + STEP, … (measured; flat or +// extended linearly beyond the measured range, never steeper than 0.8 stop per stop so tones +// keep their order). +const SHADOWS_UP: [f32; 73] = [ + 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, + 2.142, 2.142, 2.142, 2.142, 2.138, 2.117, 2.074, 2.010, 1.918, 1.797, 1.668, 1.546, 1.429, 1.315, 1.204, 1.093, 0.979, 0.860, 0.734, 0.604, 0.482, 0.378, + 0.288, 0.211, 0.157, 0.127, 0.104, 0.078, 0.056, 0.046, 0.044, 0.044, 0.044, 0.044, 0.044, 0.041, 0.030, 0.014, 0.003, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, +]; +const SHADOWS_DOWN: [f32; 73] = [ + -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, + -2.365, -2.365, -2.365, -2.365, -2.346, -2.270, -2.155, -2.078, -2.054, -2.031, -1.973, -1.872, -1.739, -1.604, -1.483, -1.372, -1.266, -1.161, -1.053, + -0.940, -0.821, -0.696, -0.570, -0.455, -0.356, -0.270, -0.197, -0.146, -0.117, -0.098, -0.076, -0.057, -0.048, -0.046, -0.046, -0.046, -0.046, -0.046, + -0.043, -0.032, -0.014, -0.003, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, +]; +const HIGHLIGHTS_UP: [f32; 73] = [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.001, 0.002, 0.003, 0.004, 0.008, 0.017, 0.030, 0.043, 0.055, 0.070, 0.085, 0.097, 0.106, + 0.114, 0.120, 0.127, 0.142, 0.167, 0.199, 0.239, 0.301, 0.381, 0.470, 0.569, 0.677, 0.796, 0.925, 1.056, 1.176, 1.268, 1.319, 1.339, 1.347, 1.351, 1.357, + 1.364, 1.372, 1.380, 1.388, 1.396, 1.404, 1.412, +]; +#[allow(clippy::approx_constant)] // measured values, one of them happens to be close to e +const HIGHLIGHTS_DOWN: [f32; 73] = [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, -0.004, -0.020, -0.046, -0.068, -0.086, -0.104, -0.116, -0.118, -0.118, -0.118, -0.118, -0.118, + -0.119, -0.120, -0.123, -0.127, -0.131, -0.144, -0.169, -0.204, -0.245, -0.301, -0.375, -0.452, -0.537, -0.639, -0.753, -0.872, -0.997, -1.132, -1.294, + -1.475, -1.637, -1.751, -1.836, -1.936, -2.059, -2.191, -2.323, -2.454, -2.586, -2.718, -2.850, +]; + +/// Clarity: the work-copy radii of the bases between its bands, and their guided-filter ε. +const CLARITY_RADII: [f32; 4] = [2.0, 8.0, 32.0, 128.0]; +const CLARITY_EPS: f32 = 1.0; +/// Texture: long side of its copy, the Gaussians' σ there, and the guided upsampling's ε (small: +/// the change must follow fine detail into the full-size pixels). +const TEXTURE_SIDE: usize = 2464; +const TEXTURE_SIGMAS: [f32; 6] = [0.5, 1.0, 2.0, 4.0, 8.0, 16.0]; +const TEXTURE_UPSAMPLE_EPS: f32 = 1e-4; +/// Dehaze: dark-channel window radius and guided smoothing radius (work copy), its ε, the +/// smallest transmission, and the haze weight at +100 and −100. +const HAZE_RADIUS: f32 = 8.0; +const HAZE_SMOOTH: f32 = 64.0; +const HAZE_EPS: f32 = 1e-3; +const HAZE_T0: f32 = 0.1; +const HAZE_UP: f32 = 0.8; +const HAZE_DOWN: f32 = 0.22; +/// Linear ProPhoto → linear Display P3 (both D50): the dark channel was measured in Photoshop's +/// Display P3 output. +const PRO_TO_P3: [[f32; 3]; 3] = [[1.632638, -0.379816, -0.252821], [-0.153846, 1.166859, -0.013013], [0.010511, -0.062886, 1.052375]]; + +/// A change of log₂ luminance computed on a reduced copy, carried to full size along the image's +/// own edges (guided upsampling): a local linear model `change = a · log₂ Y + b` per reduced pixel. +struct Upsampled { + sw: usize, + sh: usize, + a: Vec, + b: Vec, + /// log₂ luminance floor (the reduced copy's 0.1th percentile). + floor: f32, +} + +impl Upsampled { + /// The change at full-size pixel (`x`, `y`) of a `w × h` image, of linear luminance `lum`. + fn change(&self, w: usize, h: usize, x: usize, y: usize, lum: f32) -> f32 { + let l = if lum > 0.0 { lum.log2().max(self.floor) } else { self.floor }; + bilinear(&self.a, self.sw, self.sh, w, h, x, y) * l + bilinear(&self.b, self.sw, self.sh, w, h, x, y) + } +} + +/// `v` (`sw × sh`) at full-size pixel (`x`, `y`) of a `w × h` image, bilinearly. +fn bilinear(v: &[f32], sw: usize, sh: usize, w: usize, h: usize, x: usize, y: usize) -> f32 { + let sx = ((x as f32 + 0.5) * sw as f32 / w as f32 - 0.5).clamp(0.0, (sw - 1) as f32); + let sy = ((y as f32 + 0.5) * sh as f32 / h as f32 - 0.5).clamp(0.0, (sh - 1) as f32); + let (x0, y0) = (sx as usize, sy as usize); + let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); + let (fx, fy) = (sx - x0 as f32, sy - y0 as f32); + let top = v[y0 * sw + x0] * (1.0 - fx) + v[y0 * sw + x1] * fx; + let bottom = v[y1 * sw + x0] * (1.0 - fx) + v[y1 * sw + x1] * fx; + top * (1.0 - fy) + bottom * fy +} + +/// Box means of `f` over `k × k` cells, `k` the smallest factor that brings the long side to +/// `side` or less: (`sw`, `sh`, the means). +fn reduce(w: usize, h: usize, side: usize, f: impl Fn(usize) -> [f32; N] + Sync) -> (usize, usize, Vec<[f32; N]>) { + let k = w.max(h).div_ceil(side).max(1); + let (sw, sh) = (w.div_ceil(k), h.div_ceil(k)); + let rows = par::map(sh, |sy| { + let (y0, y1) = (sy * k, ((sy + 1) * k).min(h)); + (0..sw) + .map(|sx| { + let (x0, x1) = (sx * k, ((sx + 1) * k).min(w)); + let mut s = [0.0f64; N]; + for y in y0..y1 { + for x in x0..x1 { + let v = f(y * w + x); + for c in 0..N { + s[c] += f64::from(v[c].max(0.0)); + } + } + } + let n = ((x1 - x0) * (y1 - y0)) as f64; + s.map(|v| (v / n) as f32) + }) + .collect::>() + }); + (sw, sh, rows.concat()) +} + +/// The luminance change of one image (Shadows, Highlights, Clarity on the work copy; Texture on +/// a half-size copy) and the black point. +pub(crate) struct ToneMap { + w: usize, + h: usize, + work: Option, + texture: Option, + /// Linear black point to subtract (0: none). + pub(crate) black: f32, +} + +impl ToneMap { + /// `lum(i)`: linear luminance of full-size pixel `i` (row-major, `w × h`). `black_point`: the + /// camera profile's black-point factor (see [`crate::Sensor::black_point`]). `None` when + /// nothing changes. Dehaze is not part of it (see [`Haze`]). + pub(crate) fn new(w: usize, h: usize, lum: impl Fn(usize) -> f32 + Sync, tone: Tone, black_point: f32) -> Option { + if w == 0 || h == 0 || (tone.gain_neutral() && black_point <= 0.0) { + return None; + } + let (sw, sh, y) = reduce(w, h, WORK_SIDE, |i| [lum(i)]); + let y: Vec = y.into_iter().map(|[v]| v).collect(); + let p01 = quantile(&y, 0.001).max(2f32.powi(-20)); + let black = if black_point > 0.0 { black_point * p01 } else { 0.0 }; + let work = (!tone.work_neutral()).then(|| { + let l: Vec = y.iter().map(|v| v.max(p01).log2()).collect(); + let n = l.len() as f64; + let geo = (l.iter().map(|v| f64::from(*v)).sum::() / n) as f32; + let scale = sw.max(sh) as f32 / WORK_SIDE as f32; + let mut change = vec![0.0f32; l.len()]; + if tone.shadows != 0.0 || tone.highlights != 0.0 { + let pm = ((l.iter().map(|v| f64::from(v.exp2()).powf(1.5)).sum::() / n).log2() / 1.5) as f32; + let r = ((BASE_RADIUS * scale).round() as usize).max(1); + let base = guided(&l, &l, sw, sh, r, BASE_EPS); + for (c, b) in change.iter_mut().zip(&base) { + *c += shadows(tone.shadows, b - pm) + highlights(tone.highlights, b - geo); + } + } + if tone.clarity != 0.0 { + clarity(&mut change, &l, sw, sh, geo, scale, tone.clarity); + } + // Fit change ≈ a · l + b locally, averaged; evaluated at full size. The large ε lets the + // change follow strong edges only: inside texture it stays smooth, so fine detail keeps + // its contrast as in Camera Raw (with ε 0.01 shadow detail lost a fifth of it; with ε 4 the change bled ~16 px over 3-stop edges). + let (a, b) = linear_fit(&l, &change, sw, sh, 2, UPSAMPLE_EPS); + Upsampled { sw, sh, a, b, floor: p01.log2() } + }); + let texture = (tone.texture != 0.0).then(|| texture(w, h, &lum, tone.texture)); + Some(ToneMap { w, h, work, texture, black }) + } + + /// The gain at full-size pixel (`x`, `y`) of linear luminance `lum`. + pub(crate) fn gain(&self, x: usize, y: usize, lum: f32) -> f32 { + if self.work.is_none() && self.texture.is_none() { + return 1.0; + } + let c = [&self.work, &self.texture].iter().filter_map(|u| u.as_ref()).map(|u| u.change(self.w, self.h, x, y, lum)).sum::(); + c.clamp(-6.0, 6.0).exp2() + } + + /// Black-point subtraction of one linear value. + #[inline] + pub(crate) fn unblack(&self, v: f32) -> f32 { + if self.black > 0.0 { ((v - self.black) / (1.0 - self.black)).max(0.0) } else { v } + } +} + +/// The black point of an image of luminance `lum` for the profile's factor (0: none): `factor` +/// times the 0.1th percentile of the work copy's linear luminance. +pub(crate) fn black_level(w: usize, h: usize, lum: impl Fn(usize) -> f32 + Sync, factor: f32) -> f32 { + if w == 0 || h == 0 || factor <= 0.0 { + return 0.0; + } + let (_, _, y) = reduce(w, h, WORK_SIDE, |i| [lum(i)]); + let y: Vec = y.into_iter().map(|[v]| v).collect(); + factor * quantile(&y, 0.001).max(2f32.powi(-20)) +} + +/// The slider's table for its direction and its weight 0…1. +fn direction(s: f32, up: &'static T, down: &'static T) -> (f32, &'static T) { + let s = s.clamp(-100.0, 100.0) / 100.0; + if s >= 0.0 { (s, up) } else { (-s, down) } +} + +/// A table on evenly spaced knots `lo`, `lo + step`, …, read linearly, flat beyond its ends. +fn even(c: &[f32; N], lo: f32, step: f32, x: f32) -> f32 { + let t = ((x - lo) / step).clamp(0.0, (N - 1) as f32); + let i = (t as usize).min(N - 2); + c[i] + (c[i + 1] - c[i]) * (t - i as f32) +} + +/// Adds Clarity `s` to `change`: bands of `l` between guided bases, each scaled by a gain that +/// follows its base's tone (relative to `geo`), and a tone term of the coarsest base. +fn clarity(change: &mut [f32], l: &[f32], sw: usize, sh: usize, geo: f32, scale: f32, s: f32) { + let (k, t) = direction(s, &tables::CLARITY_UP, &tables::CLARITY_DOWN); + let mut prev = l.to_vec(); + for (band, r) in CLARITY_RADII.iter().enumerate() { + let base = guided(l, l, sw, sh, ((r * scale).round() as usize).max(1), CLARITY_EPS); + for ((c, p), b) in change.iter_mut().zip(&prev).zip(&base) { + *c += k * (p - b) * even(&t.bands[band], tables::CLARITY_TONE_LO, tables::CLARITY_TONE_STEP, b - geo); + } + prev = base; + } + for (c, b) in change.iter_mut().zip(&prev) { + *c += k * (even(&t.base, tables::CLARITY_BASE_LO, tables::CLARITY_BASE_STEP, b - geo) + t.offset); + } +} + +/// Texture `s` on a half-size copy: each Gaussian band of log₂ luminance through its measured +/// response (zero for flat areas), carried to full size. +fn texture(w: usize, h: usize, lum: &(impl Fn(usize) -> f32 + Sync), s: f32) -> Upsampled { + let (sw, sh, y) = reduce(w, h, TEXTURE_SIDE, |i| [lum(i)]); + let y: Vec = y.into_iter().map(|[v]| v).collect(); + let p01 = quantile(&y, 0.001).max(2f32.powi(-20)); + let l: Vec = y.iter().map(|v| v.max(p01).log2()).collect(); + let scale = sw.max(sh) as f32 / TEXTURE_SIDE as f32; + let (k, t) = direction(s, &tables::TEXTURE_UP, &tables::TEXTURE_DOWN); + let mut change = vec![0.0f32; l.len()]; + let mut prev = l.clone(); + for (band, sigma) in TEXTURE_SIGMAS.iter().enumerate() { + let base = gauss(&l, sw, sh, sigma * scale); + let zero = knots(&t[band], 0.0); + for ((c, p), b) in change.iter_mut().zip(&prev).zip(&base) { + *c += k * (knots(&t[band], p - b) - zero); + } + prev = base; + } + let (a, b) = linear_fit(&l, &change, sw, sh, 2, TEXTURE_UPSAMPLE_EPS); + Upsampled { sw, sh, a, b, floor: p01.log2() } +} + +/// Texture's band response on the knots [`tables::TEXTURE_KNOTS`], read linearly, flat beyond. +fn knots(c: &[f32; 11], d: f32) -> f32 { + let k = &tables::TEXTURE_KNOTS; + let d = d.clamp(k[0], k[10]); + let i = k.partition_point(|v| *v <= d).clamp(1, 10); + let f = (d - k[i - 1]) / (k[i] - k[i - 1]); + c[i - 1] + (c[i] - c[i - 1]) * f +} + +/// Gaussian blur of `v` (`w × h`), σ `sigma`, radius ⌈3σ⌉, mirrored edges. +fn gauss(v: &[f32], w: usize, h: usize, sigma: f32) -> Vec { + if sigma <= 0.05 { + return v.to_vec(); + } + let r = (3.0 * sigma).ceil() as isize; + let mut k: Vec = (-r..=r).map(|x| (-(x * x) as f32 / (2.0 * sigma * sigma)).exp()).collect(); + let sum: f32 = k.iter().sum(); + k.iter_mut().for_each(|v| *v /= sum); + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + (0..w as isize).map(|x| k.iter().enumerate().map(|(j, kv)| kv * src[mirror(x + j as isize - r, w)]).sum::()).collect::>() + }); + let tmp = rows.concat(); + let cols = par::map(w, |x| { + (0..h as isize).map(|y| k.iter().enumerate().map(|(j, kv)| kv * tmp[mirror(y + j as isize - r, h) * w + x]).sum::()).collect::>() + }); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out +} + +/// Minimum over a `(2r + 1)²` window, edges clamped. +fn min_filter(v: &[f32], w: usize, h: usize, r: usize) -> Vec { + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + (0..w).map(|x| src[x.saturating_sub(r)..=(x + r).min(w - 1)].iter().fold(f32::MAX, |a, b| a.min(*b))).collect::>() + }); + let tmp = rows.concat(); + let cols = + par::map(w, |x| (0..h).map(|y| (y.saturating_sub(r)..=(y + r).min(h - 1)).map(|yy| tmp[yy * w + x]).fold(f32::MAX, f32::min)).collect::>()); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out +} + +/// Camera Raw's Dehaze on linear ProPhoto RGB (see the module docs). +pub(crate) struct Haze { + w: usize, + h: usize, + sw: usize, + sh: usize, + /// Transmission per work-copy pixel (Dehaze > 0); empty for the veil of Dehaze < 0. + t: Vec, + /// Airlight, linear ProPhoto. + air: [f32; 3], + /// Weight of the uniform veil (Dehaze < 0). + veil: f32, +} + +impl Haze { + /// `px(i)`: linear ProPhoto RGB of full-size pixel `i`. `None` at 0. + pub(crate) fn new(w: usize, h: usize, px: impl Fn(usize) -> [f32; 3] + Sync, amount: f32) -> Option { + let amount = amount.clamp(-100.0, 100.0) / 100.0; + if w == 0 || h == 0 || amount == 0.0 { + return None; + } + let (sw, sh, small) = reduce(w, h, WORK_SIDE, px); + let p3 = |v: &[f32; 3]| PRO_TO_P3.map(|row| (row[0] * v[0] + row[1] * v[1] + row[2] * v[2]).max(0.0)); + let small_p3: Vec<[f32; 3]> = small.iter().map(p3).collect(); + // Airlight: mean colour of the 0.1 % of pixels with the brightest dark channel. + let dc: Vec = small_p3.iter().map(|c| c[0].min(c[1]).min(c[2])).collect(); + let top = (dc.len() / 1000).max(1); + let mut idx: Vec = (0..dc.len()).collect(); + idx.select_nth_unstable_by(top - 1, |a, b| dc[*b].total_cmp(&dc[*a])); + let mut air = [0.0f32; 3]; + for i in &idx[..top] { + for c in 0..3 { + air[c] += small[*i][c] / top as f32; + } + } + let air = air.map(|v| v.max(1e-6)); + if amount < 0.0 { + return Some(Haze { w, h, sw, sh, t: Vec::new(), air, veil: HAZE_DOWN * -amount }); + } + let air_p3 = p3(&air).map(|v| v.max(1e-6)); + let scale = sw.max(sh) as f32 / WORK_SIDE as f32; + let dark0: Vec = small_p3.iter().map(|c| (0..3).map(|k| c[k] / air_p3[k]).fold(f32::MAX, f32::min)).collect(); + let dark = min_filter(&dark0, sw, sh, ((HAZE_RADIUS * scale).round() as usize).max(1)); + let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32); + let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; + let air_y = lum(&air).max(1e-6); + let guide: Vec = small.iter().map(|c| lum(c) / air_y).collect(); + let smooth = guided(&guide, &dark, sw, sh, ((HAZE_SMOOTH * scale).round() as usize).max(1), HAZE_EPS); + let wgt = HAZE_UP * amount; + let t = smooth.iter().map(|d| (1.0 - wgt * d).max(HAZE_T0)).collect(); + Some(Haze { w, h, sw, sh, t, air, veil: 0.0 }) + } + + /// Dehaze at full-size pixel (`x`, `y`) of linear ProPhoto `v`. + pub(crate) fn apply(&self, x: usize, y: usize, v: [f32; 3]) -> [f32; 3] { + if self.t.is_empty() { + return [0, 1, 2].map(|c| v[c] * (1.0 - self.veil) + self.air[c] * self.veil); + } + let t = bilinear(&self.t, self.sw, self.sh, self.w, self.h, x, y).max(HAZE_T0); + [0, 1, 2].map(|c| ((v[c] - self.air[c]) / t + self.air[c]).max(0.0)) + } +} + +fn curve(c: &[f32; 73], d: f32) -> f32 { + let t = ((d - LO) / STEP).clamp(0.0, (c.len() - 1) as f32); + let i = (t as usize).min(c.len() - 2); + let f = t - i as f32; + c[i] + (c[i + 1] - c[i]) * f +} + +/// log₂ gain of Shadows `s` (−100…100) at `d` stops from its anchor. +fn shadows(s: f32, d: f32) -> f32 { + let s = s.clamp(-100.0, 100.0) / 100.0; + if s > 0.0 { + s * curve(&SHADOWS_UP, d) + } else if s < 0.0 { + -s * curve(&SHADOWS_DOWN, d) + } else { + 0.0 + } +} + +/// log₂ gain of Highlights `h` (−100…100) at `d` stops from its anchor. +fn highlights(h: f32, d: f32) -> f32 { + let h = h.clamp(-100.0, 100.0) / 100.0; + if h > 0.0 { + h * curve(&HIGHLIGHTS_UP, d) + } else if h < 0.0 { + -h * curve(&HIGHLIGHTS_DOWN, d) + } else { + 0.0 + } +} + +/// The `q` quantile (0…1) of `v`. +fn quantile(v: &[f32], q: f32) -> f32 { + let mut s = v.to_vec(); + let k = ((s.len() - 1) as f32 * q).round() as usize; + let (_, m, _) = s.select_nth_unstable_by(k, |a, b| a.total_cmp(b)); + *m +} + +/// Index `i` mirrored into `0..n` (half-sample symmetric: … 1 0 | 0 1 … n−1 | n−1 n−2 …). +fn mirror(i: isize, n: usize) -> usize { + let n = n as isize; + let p = i.rem_euclid(2 * n); + (if p < n { p } else { 2 * n - 1 - p }) as usize +} + +/// Box mean of radius `r` with mirrored edges. +fn box_mean(v: &[f32], w: usize, h: usize, r: usize) -> Vec { + let k = (2 * r + 1) as f64; + let ri = r as isize; + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + let mut acc: f64 = (-ri..=ri).map(|i| f64::from(src[mirror(i, w)])).sum(); + let mut out = vec![0.0f32; w]; + for (x, o) in out.iter_mut().enumerate() { + *o = (acc / k) as f32; + let x = x as isize; + acc += f64::from(src[mirror(x + ri + 1, w)]) - f64::from(src[mirror(x - ri, w)]); + } + out + }); + let tmp = rows.concat(); + let cols = par::map(w, |x| { + let at = |y: isize| f64::from(tmp[mirror(y, h) * w + x]); + let mut acc: f64 = (-ri..=ri).map(at).sum(); + let mut out = vec![0.0f32; h]; + for (y, o) in out.iter_mut().enumerate() { + *o = (acc / k) as f32; + let y = y as isize; + acc += at(y + ri + 1) - at(y - ri); + } + out + }); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out +} + +/// Local linear fit `p ≈ a · i + b` over boxes of radius `r` (regularized by `eps`), with `a` +/// and `b` box-averaged (K. He, J. Sun, X. Tang, *Guided Image Filtering*, 2010). +fn linear_fit(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> (Vec, Vec) { + let ip: Vec = i.iter().zip(p).map(|(a, b)| a * b).collect(); + let ii: Vec = i.iter().map(|a| a * a).collect(); + let (mi, mp, mip, mii) = (box_mean(i, w, h, r), box_mean(p, w, h, r), box_mean(&ip, w, h, r), box_mean(&ii, w, h, r)); + let a: Vec = (0..w * h).map(|k| (mip[k] - mi[k] * mp[k]) / ((mii[k] - mi[k] * mi[k]).max(0.0) + eps)).collect(); + let b: Vec = (0..w * h).map(|k| mp[k] - a[k] * mi[k]).collect(); + (box_mean(&a, w, h, r), box_mean(&b, w, h, r)) +} + +/// Guided filter of `p` with guide `i`. +fn guided(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { + let (a, b) = linear_fit(i, p, w, h, r, eps); + a.iter().zip(&b).zip(i).map(|((a, b), i)| a * i + b).collect() +} + +/// The raw-stage sliders ([`Tone`]) on an image [`develop`](crate::develop) already made (gamma-1.8 ProPhoto +/// RGBA, 0…1) with the profile tone `curve`, for a preview of the open-time setting: the curve is +/// undone, the change applied in linear light and the curve redone. The developed pixels already +/// carry the black point, so the change's map is computed from the luminance after it rather +/// than before; that only differs in the deepest shadows. +pub fn retone(px: &mut [[f32; 4]], w: usize, h: usize, curve: &[[f32; 2]], tone: Tone) { + if tone.is_neutral() || w == 0 || h == 0 || px.len() < w * h { + return; + } + let forward = |v: f32| crate::develop::tone(curve, v.clamp(0.0, 1.0)).clamp(0.0, 1.0).powf(1.0 / 1.8); + // The inverse from a table over s = √v (the encoding is steep near 0). + const N: usize = 4096; + let table: Vec = (0..=N).map(|i| forward((i as f32 / N as f32).powi(2))).collect(); + let inverse = |e: f32| { + let i = table.partition_point(|t| *t < e).clamp(1, N); + let (a, b) = (table[i - 1], table[i]); + let f = if b > a { ((e - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 }; + ((i - 1) as f32 + f) / N as f32 + }; + let mut lin: Vec<[f32; 3]> = px.iter().map(|p| [0, 1, 2].map(|k| inverse(p[k]).powi(2))).collect(); + if let Some(haze) = Haze::new(w, h, |i| lin[i], tone.dehaze) { + for (i, l) in lin.iter_mut().enumerate() { + *l = haze.apply(i % w, i / w, *l); + } + } + let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32); + let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; + let map = ToneMap::new(w, h, |i| lum(&lin[i]), tone, 0.0); + for (i, (p, l)) in px.iter_mut().zip(&lin).enumerate() { + let g = map.as_ref().map_or(1.0, |m| m.gain(i % w, i / w, lum(l))); + for k in 0..3 { + p[k] = forward(l[k] * g); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHADOWS: Tone = Tone { shadows: 100.0, ..Tone::NEUTRAL }; + const HIGHLIGHTS: Tone = Tone { highlights: -100.0, ..Tone::NEUTRAL }; + + /// Three vertical bands of linear luminance 2⁻⁷ | 2⁻⁴ | 2⁻¹, times `scale`. + fn bands(w: usize, h: usize, scale: f32) -> Vec { + (0..w * h).map(|i| scale * [2f32.powi(-7), 2f32.powi(-4), 0.5][(i % w) * 3 / w]).collect() + } + + fn gains(y: &[f32], w: usize, h: usize, tone: Tone) -> Vec { + let Some(m) = ToneMap::new(w, h, |i| y[i], tone, 0.0) else { return vec![1.0; w * h] }; + (0..w * h).map(|i| m.gain(i % w, i / w, y[i])).collect() + } + + #[test] + fn shadows_lift_the_dark_area_and_keep_the_bright_one() { + let (w, h) = (300, 120); + let g = gains(&bands(w, h, 1.0), w, h, SHADOWS); + let (dark, mid, bright) = (g[60 * w + 50], g[60 * w + 150], g[60 * w + 250]); + // 5 stops below the anchor: about +2 stops, as Camera Raw lifts such areas; 1 stop above it: almost nothing. + assert!(dark.log2() > 1.8 && dark.log2() < 2.3, "{dark}"); + assert!(mid > 1.0 && mid < dark, "{mid}"); + assert!(bright.log2() < 0.06, "{bright}"); + } + + #[test] + fn highlights_lower_the_bright_area_and_keep_the_dark_one() { + let (w, h) = (300, 120); + let g = gains(&bands(w, h, 1.0), w, h, HIGHLIGHTS); + let (dark, bright) = (g[60 * w + 50], g[60 * w + 250]); + // 3 stops above the anchor about −1 stop; 3 below a slight darkening (Camera Raw lowers a + // high-contrast frame as a whole a little too). + assert!(dark.log2() < 0.0 && dark.log2() > -0.15, "{dark}"); + assert!(bright.log2() < -0.8 && bright.log2() > -1.3, "{bright}"); + } + + #[test] + fn exposure_changes_nothing() { + let (w, h) = (300, 120); + for tone in [SHADOWS, HIGHLIGHTS, Tone { shadows: -60.0, highlights: 40.0, clarity: 50.0, texture: -40.0, ..Tone::NEUTRAL }] { + let (a, b) = (gains(&bands(w, h, 1.0), w, h, tone), gains(&bands(w, h, 0.25), w, h, tone)); + assert!(a.iter().zip(&b).all(|(a, b)| (a - b).abs() < 1e-3 * a), "{tone:?}"); + } + } + + #[test] + fn areas_move_as_a_whole_without_a_halo() { + // Next to an edge the change stays between the two areas' changes (no over- or undershoot) + // and close to the change inside the area. + let (w, h) = (600, 120); + let g: Vec = gains(&bands(w, h, 1.0), w, h, SHADOWS).iter().map(|g| g.log2()).collect(); + let row = &g[60 * w..61 * w]; + let (dark, mid) = (row[w / 6], row[w / 2]); + for (x, v) in row.iter().enumerate().take(w / 2).skip(w / 6) { + assert!(*v <= dark + 1e-3 && *v >= mid - 1e-3, "{x}: {v} outside {mid}…{dark}"); + } + // Within 0.3 stop: near strong edges the change matches Photoshop as well as elsewhere. + assert!(dark - row[w / 3 - 2] < 0.3 && row[w / 3 + 2] - mid < 0.3, "{} {} | {} {}", dark, row[w / 3 - 2], row[w / 3 + 2], mid); + } + + #[test] + fn the_result_does_not_depend_on_the_image_size() { + let probe = |w: usize, h: usize| { + let g = gains(&bands(w, h, 1.0), w, h, SHADOWS); + [w / 6, w / 2, 5 * w / 6].map(|x| g[(h / 2) * w + x].log2()) + }; + let (a, b) = (probe(4928, 1640), probe(1232, 410)); + assert!(a.iter().zip(&b).all(|(a, b)| (a - b).abs() < 0.02), "{a:?} vs {b:?}"); + } + + #[test] + fn the_black_point_follows_the_darkest_tones() { + let (w, h) = (300, 120); + let y = bands(w, h, 1.0); + let m = ToneMap::new(w, h, |i| y[i], Tone::default(), 0.33).unwrap(); + assert!((m.black - 0.33 * 2f32.powi(-7)).abs() < 1e-7, "{}", m.black); + assert!(m.unblack(m.black * 0.5) == 0.0 && (m.unblack(1.0) - 1.0).abs() < 1e-6); + assert!(ToneMap::new(w, h, |i| y[i], Tone::default(), 0.0).is_none()); + } + + /// log₂ luminance of a `w × h` image: `base` plus a vertical sine of `amp` stops and period `period` px. + fn stripes(w: usize, h: usize, base: f32, amp: f32, period: f32) -> Vec { + (0..w * h).map(|i| (base + amp * ((i % w) as f32 * std::f32::consts::TAU / period).sin()).exp2()).collect() + } + + /// Peak-to-peak log₂ luminance over the middle row's central half, after the gains. + fn swing(y: &[f32], g: &[f32], w: usize, h: usize) -> f32 { + let row = (h / 2) * w; + let v: Vec = (w / 4..3 * w / 4).map(|x| (y[row + x] * g[row + x]).log2()).collect(); + v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min) + } + + #[test] + fn clarity_raises_midtone_detail_and_minus_flattens_it() { + let (w, h) = (1232, 200); + let y = stripes(w, h, -2.5, 0.25, 40.0); + let before = swing(&y, &vec![1.0; w * h], w, h); + let up = swing(&y, &gains(&y, w, h, Tone { clarity: 100.0, ..Tone::NEUTRAL }), w, h); + let down = swing(&y, &gains(&y, w, h, Tone { clarity: -100.0, ..Tone::NEUTRAL }), w, h); + // Measured: +100 roughly doubles midtone detail, −100 flattens it by more than a third. + assert!(up > 1.5 * before && up < 3.5 * before, "{before} → {up}"); + assert!(down < 0.8 * before && down > 0.1 * before, "{before} → {down}"); + } + + #[test] + fn texture_raises_fine_detail_more_than_coarse_and_leaves_flat_areas() { + let (w, h) = (2464, 160); + let fine = stripes(w, h, -2.5, 0.1, 6.0); + let coarse = stripes(w, h, -2.5, 0.1, 600.0); + let gain = |y: &[f32]| swing(y, &gains(y, w, h, Tone { texture: 100.0, ..Tone::NEUTRAL }), w, h) / swing(y, &vec![1.0; w * h], w, h); + let (f, c) = (gain(&fine), gain(&coarse)); + assert!(f > 1.15, "fine detail ×{f}"); + assert!(c < 1.05 && f > c + 0.1, "coarse ×{c}, fine ×{f}"); + let flat = vec![0.2f32; 400 * 300]; + assert!(gains(&flat, 400, 300, Tone { texture: 100.0, ..Tone::NEUTRAL }).iter().all(|g| (g - 1.0).abs() < 1e-4)); + } + + #[test] + fn dehaze_lowers_the_haze_and_minus_adds_a_veil() { + // A pale, hazy sky (top) over a darker, colourful ground. + let (w, h) = (400, 300); + let px: Vec<[f32; 3]> = (0..w * h).map(|i| if i / w < h / 2 { [0.55, 0.6, 0.7] } else { [0.12, 0.08, 0.03] }).collect(); + let up = Haze::new(w, h, |i| px[i], 100.0).unwrap(); + let (sky, ground) = (up.apply(200, 40, px[40 * w + 200]), up.apply(200, 260, px[260 * w + 200])); + // The airlight itself stays; what is darker than it moves away from it. + assert!(sky.iter().zip(&px[40 * w + 200]).all(|(a, b)| (a - b).abs() < 0.02), "{sky:?}"); + assert!(ground[2] < 0.03 && ground[0] <= 0.12, "{ground:?}"); + let down = Haze::new(w, h, |i| px[i], -100.0).unwrap(); + let veiled = down.apply(200, 260, px[260 * w + 200]); + assert!((0..3).all(|c| veiled[c] > px[260 * w + 200][c]), "{veiled:?}"); + assert!(Haze::new(w, h, |i| px[i], 0.0).is_none()); + } + + #[test] + fn retone_changes_nothing_at_zero_and_matches_the_develop_stage() { + let (w, h) = (300, 120); + let y = bands(w, h, 1.0); + let curve = [[0.01, 0.02], [0.1, 0.25], [1.0, 1.0]]; + let enc = |v: f32| crate::develop::tone(&curve, v).powf(1.0 / 1.8); + let px: Vec<[f32; 4]> = y.iter().map(|v| [enc(*v), enc(*v), enc(*v), 1.0]).collect(); + let mut same = px.clone(); + retone(&mut same, w, h, &curve, Tone::default()); + assert_eq!(same, px); + let mut out = px.clone(); + retone(&mut out, w, h, &curve, SHADOWS); + let g = gains(&y, w, h, SHADOWS); + for i in [60 * w + 50, 60 * w + 150, 60 * w + 250] { + assert!((out[i][0] - enc(y[i] * g[i])).abs() < 0.003, "{i}: {} vs {}", out[i][0], enc(y[i] * g[i])); + } + } +} diff --git a/crates/raw/src/tone/tables.rs b/crates/raw/src/tone/tables.rs new file mode 100644 index 00000000..eb489236 --- /dev/null +++ b/crates/raw/src/tone/tables.rs @@ -0,0 +1,66 @@ +//! Measured tables for Clarity and Texture (see the parent module's docs). Generated by +//! photoshop-acr-re `tools/export_presence.py` from the fits in `tools/presfinal.py`. + +/// Clarity's tone knots for the band gains (relative to the frame's mean log₂ luminance) +/// and for the tone term. +pub(super) const CLARITY_TONE_LO: f32 = -8.0; +pub(super) const CLARITY_TONE_STEP: f32 = 2.0; +pub(super) const CLARITY_BASE_LO: f32 = -8.0; +pub(super) const CLARITY_BASE_STEP: f32 = 1.0; + +/// One direction of Clarity at full strength: per band (finest first) the gain of the band +/// against its base's tone, the tone term of the coarsest base, and a constant (log₂). +pub(super) struct Clarity { + pub(super) bands: [[f32; 7]; 4], + pub(super) base: [f32; 13], + pub(super) offset: f32, +} + +/// Clarity +100 (cross-validated effect correlation 0.81). +pub(super) const CLARITY_UP: Clarity = Clarity { + bands: [ + [0.9573, 0.9573, 0.9573, 1.5534, 1.3206, 1.0011, 1.1671], + [1.0920, 1.0920, 1.0920, 1.1946, 0.9268, 0.5702, 1.1336], + [0.7149, 0.7149, 0.7149, 0.9739, 0.8363, 0.5026, 0.6025], + [0.9620, 0.9620, 0.9620, 0.5886, 0.4937, 0.2263, 0.7602], + ], + base: [0.2924, 0.2924, 0.2924, 0.2924, 0.2924, 0.2924, 0.1063, 0.0520, 0.2993, 0.2779, -0.0507, -0.0348, -0.2987], + offset: -0.2342, +}; + +/// Clarity -100 (cross-validated effect correlation 0.92). +pub(super) const CLARITY_DOWN: Clarity = Clarity { + bands: [ + [-0.0449, -0.0449, -0.0449, 0.0021, -0.5728, -1.0000, -0.8334], + [-0.0734, -0.0734, -0.0734, -0.0653, -0.7011, -1.0000, -0.8558], + [-0.0462, -0.0462, -0.0462, -0.0394, -0.4121, -0.6502, -0.5797], + [0.0527, 0.0527, 0.0527, -0.0505, 0.0206, 0.0066, 0.0594], + ], + base: [-0.0179, -0.0179, -0.0179, -0.0179, -0.0179, -0.0179, -0.0079, -0.0138, 0.0165, 0.0167, 0.0509, 0.0485, 0.0390], + offset: 0.0107, +}; + +/// Texture's band-response knots (stops of band detail). +pub(super) const TEXTURE_KNOTS: [f32; 11] = [-2.0000, -1.0000, -0.5000, -0.2500, -0.1000, 0.0000, 0.1000, 0.2500, 0.5000, 1.0000, 2.0000]; + +/// Texture +100: per band (σ 0.5 … 16 on the half-size copy) its change against +/// the band's detail on [`TEXTURE_KNOTS`] (cross-validated correlation 0.89). +pub(super) const TEXTURE_UP: [[f32; 11]; 6] = [ + [-0.1951, -0.1951, -0.1951, -0.1951, -0.0637, -0.0122, 0.0595, 0.2096, 0.2096, 0.2096, 0.2096], + [-0.0598, -0.0598, -0.0598, -0.1444, -0.0747, -0.0089, 0.0477, 0.0790, 0.0494, 0.0494, 0.0494], + [-0.0648, -0.0648, -0.0648, -0.1096, -0.0822, -0.0196, 0.0420, 0.0509, 0.0331, 0.0331, 0.0331], + [-0.0886, -0.0886, -0.0886, -0.0958, -0.0526, 0.0136, 0.0836, 0.0817, 0.0851, 0.0851, 0.0851], + [-0.0728, -0.0728, -0.0728, -0.0525, -0.0203, 0.0305, 0.0752, 0.0759, 0.0762, 0.0408, 0.0408], + [-0.0140, -0.0140, 0.0051, 0.0180, 0.0141, 0.0207, 0.0181, 0.0092, 0.0050, -0.0051, -0.0051], +]; + +/// Texture -100: per band (σ 0.5 … 16 on the half-size copy) its change against +/// the band's detail on [`TEXTURE_KNOTS`] (cross-validated correlation 0.91). +pub(super) const TEXTURE_DOWN: [[f32; 11]; 6] = [ + [0.0194, 0.0194, 0.0194, 0.0194, -0.1470, -0.2059, -0.2565, -0.3623, -0.3623, -0.3623, -0.3623], + [0.1957, 0.1957, 0.1957, 0.2306, 0.1984, 0.1493, 0.0965, 0.0527, 0.0333, 0.0333, 0.0333], + [0.0426, 0.0426, 0.0426, 0.0942, 0.0664, 0.0104, -0.0384, -0.0452, -0.0167, -0.0167, -0.0167], + [0.0725, 0.0725, 0.0725, 0.0795, 0.0659, 0.0050, -0.0538, -0.0603, -0.0689, -0.0689, -0.0689], + [0.0771, 0.0771, 0.0771, 0.0492, 0.0319, -0.0107, -0.0507, -0.0533, -0.0512, -0.0225, -0.0225], + [0.0137, 0.0137, 0.0027, -0.0062, -0.0018, -0.0046, -0.0051, 0.0038, 0.0086, 0.0170, 0.0170], +]; diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 8b77e7a0..c939714d 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -64,6 +64,9 @@ pub struct CameraRawDialog { pub render_ms: f64, /// Set for the open-time dialog ([`Target::OpenRaw`]). raw_open: Option, + /// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there, + /// in linear light before this curve, and the preview undoes it to show the same. + raw_curve: Option>, } impl CameraRawDialog { @@ -129,6 +132,11 @@ impl CameraRawDialog { let mut px = self.proxy.clone(); let mut p = self.params.clone(); p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0); + if let Some(curve) = &self.raw_curve { + let tone = raw_stage(&p); + photocraft_io::raw::retone(&mut px, self.pw, self.ph, curve, tone); + (p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0); + } photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float); if let Some(mask) = &self.selection { for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) { @@ -174,9 +182,10 @@ enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open -/// re-develops it when Temperature, Tint or Exposure changed (those act on the sensor data, see -/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw -/// step, then marks the document unmodified. Cancel closes it. +/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or +/// Dehaze changed (those act on the sensor data, see [`photocraft_io::raw::import_raw_tuned`]) +/// and applies the other settings as one Camera Raw step, then marks the document unmodified. +/// Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -216,8 +225,15 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::None, Target::OpenRaw)?; + let curve = match (&raw.bytes, &raw.path) { + (Some(b), _) => Some(photocraft_io::raw::tone_curve(b)), + #[cfg(not(target_arch = "wasm32"))] + (None, Some(p)) => std::fs::read(p).ok().map(|b| photocraft_io::raw::tone_curve(&b)), + _ => None, + }; if let Some(d) = app.camera_raw.as_mut() { d.layer_name = raw.name.clone(); + d.raw_curve = curve; d.raw_open = Some(raw); } Ok(()) @@ -256,6 +272,11 @@ pub struct Redevelop { filter_step: bool, } +/// The sliders a raw open applies while developing, before the tone curve (as Camera Raw does). +fn raw_stage(p: &CameraRaw) -> photocraft_io::raw::Tone { + photocraft_io::raw::Tone { shadows: p.shadows, highlights: p.highlights, clarity: p.clarity, texture: p.texture, dehaze: p.dehaze } +} + /// Open: re-develop for raw-stage settings, apply the rest, keep the document unmodified. /// /// Re-developing reads and decodes the whole raw again, so in the desktop app (background jobs @@ -264,7 +285,16 @@ pub struct Redevelop { /// (tests, the web) it runs inline. fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) -> Result { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; - let tuning = photocraft_io::raw::RawTuning { temperature: params.temperature, tint: params.tint, exposure: params.exposure }; + let tuning = photocraft_io::raw::RawTuning { + temperature: params.temperature, + tint: params.tint, + exposure: params.exposure, + shadows: params.shadows, + highlights: params.highlights, + clarity: params.clarity, + texture: params.texture, + dehaze: params.dehaze, + }; // Without the file the raw-stage settings stay RGB adjustments, like the filter's. let source = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_engine::jobs::OpenSource::Bytes(b.clone())), @@ -346,6 +376,11 @@ fn finish_redevelop(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, crate::notices::io_warnings(app, &format!("Opened {}", raw.name), &without_develop_note(&warnings)); let mut rest = params.clone(); rest.exposure = 0.0; + rest.shadows = 0.0; + rest.highlights = 0.0; + rest.clarity = 0.0; + rest.texture = 0.0; + rest.dehaze = 0.0; if v.get("wb").and_then(Value::as_bool) == Some(true) { rest.temperature = 0.0; rest.tint = 0.0; @@ -482,6 +517,10 @@ fn open_pixels( let st = app.session.active().ok_or("no document")?; let canvas = st.doc.bounds(); let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); + let mut params = params; + if params.encoding_gamma == 0.0 { + params.encoding_gamma = photocraft_engine::lens_cmds::encoding_gamma(&st.doc); + } // Use the engine filter domain, including pixels outside the canvas: spatial Camera Raw // stages (vignette, grain, local contrast) must agree with the committed result. let area = canvas.union(&surf.content_bounds()); @@ -591,6 +630,7 @@ fn open_pixels( curve_rect: None, render_ms: 0.0, raw_open: None, + raw_curve: None, }; d.render(ctx); app.camera_raw = Some(d); @@ -937,7 +977,8 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { // Never tile a checker across the offscreen extent of a highly zoomed image. widgets::checker(preview.painter(), r.intersect(view), 8.0); preview.painter().image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - let detail_needed = r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32; + let raw_tone = d.raw_curve.is_some() && !raw_stage(&d.params).is_neutral(); + let detail_needed = !raw_tone && (r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32); d.detail.paint(&preview, r, d.preview_revision, d.show_before, &d.params, detail_needed, &d.display); if d.detail.pending() { ctx.request_repaint_after(std::time::Duration::from_millis(50)); @@ -1336,6 +1377,46 @@ mod tests { assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app)); } + #[test] + fn shadows_of_a_raw_open_act_on_the_raw_data_and_the_preview_shows_it() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0002.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + assert!(app.camera_raw.as_ref().unwrap().raw_curve.is_some(), "the preview knows how the raw was developed"); + let mean = |px: &[[f32; 4]]| px.iter().map(|p| p[0] + p[1] + p[2]).sum::() / px.len() as f32; + let preview_before = mean(&app.camera_raw.as_ref().unwrap().processed); + let before = mean_red(&app); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"shadows": 100}}})).unwrap().unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.render(&ctx); + assert!(mean(&d.processed) > preview_before + 0.01, "{preview_before} → {}", mean(&d.processed)); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], true, "{r}"); + assert!(r.get("filter").is_none(), "Shadows acts while developing, not as a later filter step: {r}"); + assert!(mean_red(&app) > before + 0.01, "{before} → {}", mean_red(&app)); + } + + #[test] + fn clarity_texture_and_dehaze_of_a_raw_open_act_on_the_raw_data() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0003.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let before = mean_red(&app); + let preview_before = app.camera_raw.as_ref().unwrap().processed.clone(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"clarity": 60, "texture": 40, "dehaze": 50}}})).unwrap().unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.render(&ctx); + assert_ne!(d.processed, preview_before, "the preview shows the raw-stage change"); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], true, "{r}"); + assert!(r.get("filter").is_none(), "they act while developing, not as a later filter step: {r}"); + assert!(mean_red(&app) < before, "Dehaze darkens: {before} → {}", mean_red(&app)); + } + #[test] fn background_redevelop_keeps_the_tab_position_and_view() { let (mut app, dng) = raw_app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3f0be8eb..72d1902c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -429,8 +429,9 @@ keeps the dialog open for correction. Nothing else writes document history. command line) of a camera raw developed from its sensor data shows this dialog first, titled "Camera Raw (name)", with **Open** and **Cancel**, as Photoshop opens raws in Adobe Camera Raw. `ui.inspect.cameraRaw.openingRaw` is then `{name, path}`. Commit (Open) re-develops the raw when -Temperature, Tint or Exposure changed (as-shot white-balance gains and develop exposure on the -sensor data; without an as-shot white balance, or without the file's bytes, they stay RGB +Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or Dehaze changed (as-shot +white-balance gains, develop exposure, and the other five in linear light before the camera's +tone curve, as Camera Raw does for raw files; without an as-shot white balance, or without the file's bytes, they stay RGB adjustments), applies the remaining settings as one `filter.cameraRaw` step and leaves the document unmodified; its result is `{document, redeveloped, filter?}`. Cancel closes the document. `app.open` and other automation opens never show the dialog. The preference