Files
DarkRoom/core/dr-pipeline/src/camera_raw.rs
T
dtourolle 185e134ead Describe the measured tone and vibrance in DarkRoom's own terms
The calibration commits named DarkRoom's default curve after another
product and described vibrance as doing what another editor's does at
the same value. The curve is the DNG SDK's reference, so it is called
that; vibrance is scaled to deliver the strength its value names, as
measured against the photographer's earlier exports. Two test names
follow. The measurements and where they came from are unchanged.
2026-10-03 16:41:11 -04:00

168 lines
6.1 KiB
Rust

//! 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_CONTRAST, 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<f32>, scale: f32, power: f32, grey: f32) -> vec3<f32> {
var p = PROFILE_FROM_WORKING * c * scale;
if (power != 1.0) {
p = grey * pow(max(p, vec3<f32>(0.0)) / grey, vec3<f32>(power));
}
p = clamp(p, vec3<f32>(0.0), vec3<f32>(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<f32>(c_hi);
if (hi - lo > 1e-7) {
out = vec3<f32>(c_lo) + (c_hi - c_lo) * (p - vec3<f32>(lo)) / (hi - lo);
}
return PROFILE_TO_WORKING * out;
}
";
#[cfg(test)]
mod tests {
use super::*;
use dr_types::tone::{evaluate, ACR3_DEFAULT};
fn identity() -> Vec<f32> {
(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);
}
}