//! TRACES: FR-DEV-3j | FR-DEV-3e //! The DNG SDK's reference tone, as a rendering the view transform can //! choose (D21). //! //! The DNG specification's reference rendering runs a raw through the //! profile's `ProfileToneCurve`, or the ACR3 default for a profile with none. //! Half of what the curve does is *how* it is applied. The SDK's //! `RefBaselineRGBTone` runs it on the largest and the smallest channel, and //! places the middle channel at the fraction between them it had before. Hue //! is kept; saturation rises wherever the curve is steeper than the //! diagonal, which for the ACR3 curve is the shadows and the midtones. //! //! It runs in linear ProPhoto, as the SDK does, on values clipped to //! `[0, 1]`; its output is linear and goes to the output transform as the //! sigmoid's does. The curve is read from the profile buffer //! (`ops::camera_profile::profile_buffer`), which always carries one. //! //! [`apply_reference`] is the arithmetic on the CPU; the GPU test holds the //! shader to it. use crate::ops::camera_profile::{mul, working_prophoto}; use crate::view::{DEFAULT_WHITE, REFERENCE_CONTRAST, SCENE_GREY}; /// The input scale for a white point: 1 at the default, so sensor white is /// display white as in the SDK's reference; each stop of `white` above it halves the /// input. pub fn input_scale(white: f32) -> f32 { (DEFAULT_WHITE - white).exp2() } /// The power the input is bent by about middle grey: 1 at /// [`REFERENCE_CONTRAST`], where the curve is the reference's untouched. /// /// The default contrast sits above it, so a photograph out of the camera is /// bent by `DEFAULT_CONTRAST / REFERENCE_CONTRAST` — the extra contrast /// Lightroom's exports showed over the bare reference curve (D21 addendum). pub fn contrast_power(contrast: f32) -> f32 { contrast / REFERENCE_CONTRAST } /// The curve, its scale and its contrast applied to one ProPhoto colour. fn rgb_tone(curve: &[f32], p: [f32; 3]) -> [f32; 3] { let p = p.map(|v| v.clamp(0.0, 1.0)); let hi = p[0].max(p[1]).max(p[2]); let lo = p[0].min(p[1]).min(p[2]); let (c_hi, c_lo) = ( dr_types::tone::evaluate(curve, hi), dr_types::tone::evaluate(curve, lo), ); if hi - lo <= 1e-7 { return [c_hi; 3]; } p.map(|v| c_lo + (c_hi - c_lo) * (v - lo) / (hi - lo)) } /// TRACES: FR-DEV-3j /// The view transform's DNG reference rendering of one working-space colour. pub fn apply_reference(curve: &[f32], c: [f32; 3], contrast: f32, white: f32) -> [f32; 3] { let (to, back) = working_prophoto(); let scale = input_scale(white); let power = contrast_power(contrast); let mut p = mul(to, c).map(|v| v * scale); if power != 1.0 { p = p.map(|v| SCENE_GREY * (v.max(0.0) / SCENE_GREY).powf(power)); } mul(back, rgb_tone(curve, p)) } /// The WGSL, a helper the view transform asks for after /// `ops::camera_profile`'s ProPhoto constants. Mirrors [`apply_reference`]. pub const CAMERA_RAW_WGSL: &str = " fn camera_raw_curve(x: f32) -> f32 { let base = profile_curve_base(); let n = u32(profile_table[2].x); let s = clamp(x, 0.0, 1.0) * f32(n - 1u); let i = min(u32(s), n - 2u); return mix(profile_table[base + i].x, profile_table[base + i + 1u].x, s - f32(i)); } // The SDK's RGBTone: the curve on the largest and smallest channel, the // middle one kept at its fraction between them, so hue survives. fn camera_raw_tone(c: vec3, scale: f32, power: f32, grey: f32) -> vec3 { var p = PROFILE_FROM_WORKING * c * scale; if (power != 1.0) { p = grey * pow(max(p, vec3(0.0)) / grey, vec3(power)); } p = clamp(p, vec3(0.0), vec3(1.0)); let hi = max(p.r, max(p.g, p.b)); let lo = min(p.r, min(p.g, p.b)); let c_hi = camera_raw_curve(hi); let c_lo = camera_raw_curve(lo); var out = vec3(c_hi); if (hi - lo > 1e-7) { out = vec3(c_lo) + (c_hi - c_lo) * (p - vec3(lo)) / (hi - lo); } return PROFILE_TO_WORKING * out; } "; #[cfg(test)] mod tests { use super::*; use crate::view::DEFAULT_CONTRAST; use dr_types::tone::{evaluate, ACR3_DEFAULT}; fn identity() -> Vec { (0..1025).map(|i| i as f32 / 1024.0).collect() } #[test] fn at_the_reference_the_input_is_untouched() { assert_eq!(input_scale(DEFAULT_WHITE), 1.0); assert_eq!(contrast_power(REFERENCE_CONTRAST), 1.0); } #[test] fn the_default_adds_the_measured_contrast() { // Fitted on Lightroom exports with neutral settings (D21 addendum): // the bare reference curve is a little flat against them. let p = contrast_power(DEFAULT_CONTRAST); assert!((1.05..1.12).contains(&p), "{p}"); } #[test] fn grey_goes_through_the_curve_and_stays_grey() { for v in [0.02, 0.13, 0.5] { let out = apply_reference(&ACR3_DEFAULT, [v; 3], REFERENCE_CONTRAST, DEFAULT_WHITE); let want = evaluate(&ACR3_DEFAULT, v); assert!( out.iter().all(|o| (o - want).abs() < 1e-4), "{v}: {out:?} vs {want}" ); } } #[test] fn an_identity_curve_changes_nothing_inside_the_range() { let c = [0.4, 0.2, 0.1]; let out = apply_reference(&identity(), c, REFERENCE_CONTRAST, DEFAULT_WHITE); assert!( out.iter().zip(c).all(|(o, c)| (o - c).abs() < 1e-4), "{out:?}" ); } #[test] fn the_middle_channel_keeps_its_place_between_the_other_two() { let p = [0.3, 0.12, 0.05]; let out = rgb_tone(&ACR3_DEFAULT, p); let before = (p[1] - p[2]) / (p[0] - p[2]); let after = (out[1] - out[2]) / (out[0] - out[2]); assert!((before - after).abs() < 1e-5, "{before} {after}"); } #[test] fn the_acr_curve_raises_saturation_in_the_midtones() { let p = [0.15, 0.08, 0.05]; let out = rgb_tone(&ACR3_DEFAULT, p); let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0]; assert!(sat(out) > sat(p), "{p:?} -> {out:?}"); } #[test] fn white_halves_the_input_per_stop() { assert_eq!(input_scale(DEFAULT_WHITE + 1.0), 0.5); assert!(contrast_power(2.8) > 1.0); } }