Files
lightcraft/crates/pipeline/src/tone.rs
T

468 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The global tone map: scene luminance → display-linear luminance.
//!
//! Built in the log domain around middle grey (0.18): contrast scales log-exposure about grey,
//! whites move the shoulder (white point), blacks move the toe. The shoulder is an extended
//! Reinhard curve so highlights roll off smoothly instead of clipping.
//!
//! Rendered (display-referred) sources such as JPEGs use [`ToneMap::display`] instead: identity at
//! neutral settings (an unedited JPEG renders exactly as the file), with contrast/whites/blacks as
//! S-curve adjustments in a gamma-2.2 perceptual domain and a short shoulder above 0.95.
pub const GREY: f32 = 0.18;
/// The tone LUT spans `LUT_MIN_EV..LUT_MAX_EV` around grey in `LUT_N` steps.
pub const LUT_MIN_EV: f32 = -14.0;
pub const LUT_MAX_EV: f32 = 10.0;
pub const LUT_N: usize = 4096;
/// Nodes of a camera chroma curve, evenly spaced over display luminance 0..=1.
pub const CHROMA_N: usize = 8;
const NO_CHROMA: [f32; CHROMA_N] = [1.0; CHROMA_N];
/// A file-local camera look, fitted independently of the scene-linear colour transform.
/// Knots are scene/display-linear luminance pairs. Keeping this in the finish stage preserves
/// RAW exposure and highlight headroom; it is never baked into the decoded sensor pixels.
/// `chroma` scales colourfulness by display luminance after the curve (a camera's per-channel
/// curve saturates shadows and bleaches highlights toward white, which a luminance curve can't).
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
pub struct CameraTone {
knots: [[f32; 2]; 32],
chroma: [f32; CHROMA_N],
}
impl<'de> serde::Deserialize<'de> for CameraTone {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Wire {
knots: [[f32; 2]; 32],
// absent in smart previews written before the chroma curve existed
#[serde(default)]
chroma: Option<[f32; CHROMA_N]>,
}
let w = Wire::deserialize(d)?;
let tone = Self::new(w.knots).ok_or_else(|| serde::de::Error::custom("invalid camera tone curve"))?;
match w.chroma {
Some(c) => tone.with_chroma(c).ok_or_else(|| serde::de::Error::custom("invalid camera chroma curve")),
None => Ok(tone),
}
}
}
impl CameraTone {
pub fn new(knots: [[f32; 2]; 32]) -> Option<Self> {
let mut previous = [0.0, 0.0];
for p in knots {
if !p.iter().all(|v| v.is_finite()) || p[0] <= previous[0] || p[1] < previous[1] || p[1] >= 1.0 {
return None;
}
previous = p;
}
Some(Self { knots, chroma: NO_CHROMA })
}
/// The curve with chroma scales at display luminance 0, 1/7 … 1 (each finite, 0..=4).
pub fn with_chroma(self, chroma: [f32; CHROMA_N]) -> Option<Self> {
chroma.iter().all(|k| k.is_finite() && (0.0..=4.0).contains(k)).then_some(Self { chroma, ..self })
}
pub fn chroma(&self) -> &[f32; CHROMA_N] {
&self.chroma
}
/// The (scene, display) luminance knots, in increasing order.
pub fn knots(&self) -> &[[f32; 2]; 32] {
&self.knots
}
pub fn apply(&self, y: f32) -> f32 {
if !y.is_finite() || y <= 0.0 {
return 0.0;
}
let mut previous = [0.0, 0.0];
for p in self.knots {
if y <= p[0] {
let t = (y - previous[0]) / (p[0] - previous[0]);
return previous[1] + t * (p[1] - previous[1]);
}
previous = p;
}
let a = self.knots[30];
let b = self.knots[31];
// Extend beyond observed (unclipped) highlights with a continuous, bounded shoulder.
let slope = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0);
1.0 - (1.0 - b[1]) * (-(y - b[0]) * slope / (1.0 - b[1]).max(0.01)).exp()
}
}
/// Scene EV over grey where [`ToneMap::hdr`] starts lifting highlights above the SDR curve.
const HDR_FROM_EV: f32 = 1.5;
#[derive(Clone, Debug)]
pub struct ToneMap {
lut: Vec<f32>,
chroma: [f32; CHROMA_N],
}
impl ToneMap {
pub fn camera(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64) -> ToneMap {
ToneMap::camera_hdr(curve, contrast, whites, blacks, 1.0)
}
/// [`ToneMap::camera`] for an HDR render reaching `peak` (linear, SDR white = 1; 1 = SDR
/// exactly): the camera's look up to 1.5 EV over grey, so midtones match the SDR render (and a
/// gain map only lifts highlights), then the highlights gain up to `peak − 1` more as in
/// [`ToneMap::hdr`].
pub fn camera_hdr(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 };
let adjustment = Self::display(contrast, whites, blacks);
let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0;
let top = (2.9 - 1.6 * (whites as f32 / 100.0) + peak.log2() + 1.0).max(HDR_FROM_EV + 0.5);
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
let y = curve.apply(GREY * 2f32.powf(ev));
let o = if neutral { y } else { adjustment.apply(y) };
if peak > 1.0 { (o + (peak - 1.0) * smooth(HDR_FROM_EV, top, ev)).clamp(0.0, peak) } else { o }
})
.collect();
ToneMap { lut, chroma: curve.chroma }
}
/// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units).
pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
ToneMap::hdr(contrast, whites, blacks, 1.0)
}
/// [`ToneMap::new`] for an HDR render reaching `peak` (linear, SDR white = 1; 1 = SDR exactly).
/// Below about half of SDR white the curve is the SDR one; above, the highlights gain up to
/// `peak − 1` more along a smoothstep in scene EV, from 1.5 EV over grey to one stop past the
/// white point moved up by the headroom, so the result stays monotone, never exceeds `peak`,
/// and reaches it there (within the table's range).
pub fn hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 };
let c = (contrast / 100.0) as f32;
let slope = if c >= 0.0 { 1.0 + 0.55 * c } else { 1.0 + 0.4 * c };
// White point: scene luminance (after contrast) that maps to display 1.0.
let white_ev = 2.9 - 1.6 * (whites as f32 / 100.0);
let wl = GREY * 2f32.powf(white_ev);
let pre = 1.0 + GREY / wl; // keep grey near grey
let b = (blacks / 100.0) as f32;
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
let y = GREY * 2f32.powf(ev * slope) * pre;
// extended Reinhard with white point wl: y(1 + y/wl²)/(1 + y)
let mut o = y * (1.0 + y / (wl * wl)) / (1.0 + y);
o = o.min(1.0);
// Toe: blacks < 0 crushes, > 0 lifts.
if b < 0.0 {
// Smooth max(0, o − k) (a soft knee), renormalized so 1 stays 1.
let k = -b * 0.035;
let e = 0.004;
let soft = |v: f32| ((v - k) + ((v - k) * (v - k) + e * e).sqrt()) * 0.5;
o = (soft(o) - soft(0.0)) / (soft(1.0) - soft(0.0));
} else if b > 0.0 {
let k = b * 0.03;
o = k + (1.0 - k) * o;
}
if peak > 1.0 {
let top = (white_ev + peak.log2() + 1.0).max(HDR_FROM_EV + 0.5);
o += (peak - 1.0) * smooth(HDR_FROM_EV, top, ev * slope);
}
o.clamp(0.0, peak)
})
.collect();
ToneMap { lut, chroma: NO_CHROMA }
}
/// Tone map for display-referred sources: identity at neutral settings.
pub fn display(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
ToneMap::display_hdr(contrast, whites, blacks, 1.0)
}
/// [`ToneMap::display`] for an HDR render reaching `peak`: values above SDR white (an HDR
/// source, e.g. a decoded gain-map image) pass through and roll off softly towards `peak`
/// instead of clipping at 1; SDR sources (≤ 1) render as with [`ToneMap::display`].
pub fn display_hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 };
let c = (contrast / 100.0) as f32;
let w = (whites / 100.0) as f32;
let b = (blacks / 100.0) as f32;
let m = GREY.powf(1.0 / 2.2);
// Neutral sliders are the identity up to white (issue #1945): the SDR shoulder below would
// pull 1.0 down to 0.9875 (8-bit 255 to 254), so it only shapes an edited curve.
let neutral = c == 0.0 && w == 0.0 && b == 0.0;
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
let y = GREY * 2f32.powf(ev);
let mut p = y.powf(1.0 / 2.2);
if p <= 1.0 {
// S-curve anchored at 0, grey and 1
p += c * 0.35 * (p - m) * (1.0 - (2.0 * p - 1.0).powi(2));
// whites: lift/lower the upper tones; blacks: the lower tones
let up = smooth(0.45, 1.0, p);
p += w * 0.12 * up * (1.0 - p * 0.5);
let lo = 1.0 - smooth(0.0, 0.45, p);
p += b * 0.07 * lo * (p * 2.0).min(1.0);
} else {
p += w * 0.12 * 0.5;
}
let mut o = p.max(0.0).powf(2.2);
// short shoulder (SDR, edited curves): slope 1 at 0.95, reaching 1.0 at 1.05
if peak > 1.0 && o > 0.95 {
// exponential shoulder: slope 1 at 0.95, asymptote at `peak`
let r = peak - 0.95;
o = 0.95 + r * (1.0 - (-(o - 0.95) / r).exp());
} else if o > 0.95 && !neutral {
let d = (o - 0.95).min(0.1);
o = 0.95 + d - d * d / 0.2;
}
o.clamp(0.0, peak)
})
.collect();
ToneMap { lut, chroma: NO_CHROMA }
}
/// The table (`LUT_N` entries, see [`ToneMap::apply`]).
pub fn lut(&self) -> &[f32] {
&self.lut
}
/// The chroma curve (`CHROMA_N` entries, see [`ToneMap::chroma_scale`]).
pub fn chroma_lut(&self) -> &[f32] {
&self.chroma
}
/// Chroma scale at display luminance `o` (exactly 1 everywhere unless a camera look sets it).
#[inline]
pub fn chroma_scale(&self, o: f32) -> f32 {
if !o.is_finite() {
return 1.0;
}
let f = o.clamp(0.0, 1.0) * (CHROMA_N - 1) as f32;
let i = (f as usize).min(CHROMA_N - 2);
let t = f - i as f32;
let (a, b) = (self.chroma.get(i).copied().unwrap_or(1.0), self.chroma.get(i + 1).copied().unwrap_or(1.0));
if a == b { a } else { a + (b - a) * t }
}
/// Scene luminance → display-linear luminance.
#[inline]
pub fn apply(&self, y: f32) -> f32 {
if y <= 0.0 {
return 0.0;
}
let ev = (y / GREY).log2();
let f = ((ev - LUT_MIN_EV) / (LUT_MAX_EV - LUT_MIN_EV)).clamp(0.0, 1.0) * (LUT_N - 1) as f32;
let i = (f as usize).min(LUT_N - 2);
let t = f - i as f32;
let v = self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t;
if ev < LUT_MIN_EV { v * (y / (GREY * 2f32.powf(LUT_MIN_EV))) } else { v }
}
}
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
#[cfg(test)]
mod tests {
use super::*;
fn knots() -> [[f32; 2]; 32] {
std::array::from_fn(|i| {
let x = 0.004 * 1.18f32.powi(i as i32);
[x, 1.0 - (-2.0 * x).exp()]
})
}
#[test]
fn chroma_curve_is_identity_unless_set_and_survives_serde() {
let plain = CameraTone::new(knots()).unwrap();
let map = ToneMap::camera(&plain, 0.0, 0.0, 0.0);
assert!([0.0, 0.3, 0.77, 1.0, 2.0].iter().all(|o| map.chroma_scale(*o) == 1.0));
assert!([0.0, 0.5, 1.0].iter().all(|o| ToneMap::new(0.0, 0.0, 0.0).chroma_scale(*o) == 1.0));
// smart previews written before the chroma curve existed still load (identity)
let old = serde_json::json!({ "knots": knots() });
assert_eq!(serde_json::from_value::<CameraTone>(old).unwrap(), plain);
let tone = plain.with_chroma([1.4, 1.3, 1.1, 1.0, 0.7, 0.4, 0.25, 0.2]).unwrap();
let back: CameraTone = serde_json::from_value(serde_json::to_value(tone).unwrap()).unwrap();
assert_eq!(back, tone);
let map = ToneMap::camera(&tone, 0.0, 0.0, 0.0);
assert!((map.chroma_scale(0.0) - 1.4).abs() < 1e-6 && (map.chroma_scale(1.0) - 0.2).abs() < 1e-6);
assert!((map.chroma_scale(0.5 / 7.0) - 1.35).abs() < 1e-5, "interpolates between nodes");
assert_eq!(map.chroma_scale(f32::NAN), 1.0);
// hostile values are rejected, also when deserialized
assert!(plain.with_chroma([f32::NAN; CHROMA_N]).is_none());
assert!(plain.with_chroma([-1.0; CHROMA_N]).is_none());
let bad = serde_json::json!({ "knots": knots(), "chroma": [9.0, 1, 1, 1, 1, 1, 1, 1] });
assert!(serde_json::from_value::<CameraTone>(bad).is_err());
}
#[test]
fn camera_curve_preserves_black_and_extends_headroom() {
let knots = std::array::from_fn(|i| {
let x = 0.005 * 1.15f32.powi(i as i32);
[x, 1.0 - (-3.0 * x).exp()]
});
let curve = CameraTone::new(knots).unwrap();
let base = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
assert_eq!(base.apply(0.0), 0.0);
assert!(base.apply(0.1) < base.apply(0.2));
let mut previous = 0.0;
for i in 0..2000 {
let y = 1e-6 * 1.01f32.powi(i);
let v = base.apply(y);
assert!((0.0..=1.0).contains(&v));
assert!(v >= previous - 1e-6);
previous = v;
}
assert!((base.apply(0.1) - curve.apply(0.1)).abs() < 0.001);
assert!(ToneMap::camera(&curve, 50.0, 0.0, 0.0).apply(0.3) > base.apply(0.3));
let mut invalid = knots;
invalid[1][0] = invalid[0][0];
assert!(CameraTone::new(invalid).is_none());
assert!(serde_json::from_value::<CameraTone>(serde_json::json!({"knots": invalid})).is_err());
}
#[test]
fn monotone_and_bounded() {
for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] {
let t = ToneMap::new(c, w, b);
let mut prev = -1.0;
for i in 0..2000 {
let y = 1e-5 * 1.012f32.powi(i);
let o = t.apply(y);
assert!((0.0..=1.0).contains(&o));
assert!(o >= prev - 1e-6, "{c} {w} {b} at {y}: {o} < {prev}");
prev = o;
}
}
}
#[test]
fn hdr_is_sdr_at_peak_one_and_reaches_peak() {
for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] {
assert_eq!(ToneMap::hdr(c, w, b, 1.0).lut(), ToneMap::new(c, w, b).lut());
for peak in [2.0f32, 8.0, 32.0] {
let sdr = ToneMap::new(c, w, b);
let t = ToneMap::hdr(c, w, b, peak);
let mut prev = -1.0;
for i in 0..2400 {
let y = 1e-5 * 1.01f32.powi(i);
let o = t.apply(y);
assert!((0.0..=peak).contains(&o), "{c} {w} {b} {peak} at {y}: {o}");
assert!(o >= prev - 1e-6, "{c} {w} {b} {peak} at {y}: {o} < {prev}");
prev = o;
if y <= GREY * 2f32.powf(0.9) {
assert!((o - sdr.apply(y)).abs() < 1e-6, "midtones differ at {y}");
}
}
assert!(t.apply(4.0) > sdr.apply(4.0));
if (c, w, b) == (0.0, 0.0, 0.0) {
assert!(t.apply(GREY * 2f32.powf(LUT_MAX_EV)) > peak * 0.99, "{peak}");
}
}
}
// a broken peak falls back to SDR
assert_eq!(ToneMap::hdr(0.0, 0.0, 0.0, f32::NAN).lut(), ToneMap::new(0.0, 0.0, 0.0).lut());
}
#[test]
fn camera_hdr_keeps_the_camera_look_and_lifts_highlights() {
let curve = CameraTone::new(knots()).unwrap().with_chroma([1.2, 1.1, 1.0, 1.0, 0.9, 0.8, 0.7, 0.6]).unwrap();
for (c, w, b) in [(0.0, 0.0, 0.0), (40.0, -30.0, -20.0)] {
let sdr = ToneMap::camera(&curve, c, w, b);
assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, 1.0).lut(), sdr.lut());
assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, f32::NAN).lut(), sdr.lut());
for peak in [2.0f32, 8.0] {
let t = ToneMap::camera_hdr(&curve, c, w, b, peak);
assert_eq!(t.chroma_lut(), sdr.chroma_lut());
let mut prev = -1.0;
for i in 0..2400 {
let y = 1e-5 * 1.01f32.powi(i);
let o = t.apply(y);
assert!((0.0..=peak).contains(&o), "{peak} at {y}: {o}");
assert!(o >= prev - 1e-6, "{peak} at {y}: {o} < {prev}");
prev = o;
// the camera's midtones, not the generic HDR curve's (issue: dark HDR exports)
if y <= GREY * 2f32.powf(HDR_FROM_EV - 0.01) {
assert!((o - sdr.apply(y)).abs() < 1e-6, "midtones differ at {y}: {o} vs {}", sdr.apply(y));
}
}
assert!(t.apply(4.0) > 1.0 && t.apply(4.0) > sdr.apply(4.0) + 0.5, "{peak}: highlights lifted");
}
}
}
#[test]
fn display_hdr_passes_hdr_sources_and_keeps_sdr() {
assert_eq!(ToneMap::display_hdr(20.0, 10.0, -5.0, 1.0).lut(), ToneMap::display(20.0, 10.0, -5.0).lut());
let t = ToneMap::display_hdr(0.0, 0.0, 0.0, 8.0);
for i in 1..=90 {
let y = i as f32 / 100.0;
assert!((t.apply(y) - y).abs() < 2e-3, "{y} -> {}", t.apply(y));
}
let mut prev = 0.0;
for i in 1..2000 {
let o = t.apply(i as f32 / 100.0);
assert!(o >= prev - 1e-6 && o <= 8.0);
prev = o;
}
assert!(t.apply(3.0) > 2.0 && t.apply(3.0) < 3.0);
}
#[test]
fn display_identity_and_monotone() {
let t = ToneMap::display(0.0, 0.0, 0.0);
// up to and including white (issue #1945: 1.0 came out as 0.9875, 8-bit 255 as 254); the
// tolerance is the LUT's interpolation next to the clip at 1.0, far below an 8-bit level
for i in 1..=100 {
let y = i as f32 / 100.0;
assert!((t.apply(y) - y).abs() < 1e-3, "{y} -> {}", t.apply(y));
}
assert_eq!(t.apply(4.0), 1.0, "neutral values pushed above white clip");
for (c, w, b) in [(100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (60.0, -40.0, 30.0)] {
let t = ToneMap::display(c, w, b);
let mut prev = -1.0;
for i in 0..1000 {
let o = t.apply(i as f32 / 500.0);
assert!(o >= prev - 1e-5, "{c} {w} {b}");
prev = o;
}
}
let c = ToneMap::display(60.0, 0.0, 0.0);
assert!(c.apply(0.05) < 0.05 && c.apply(0.7) > 0.7);
}
#[test]
fn display_shoulder_stays_on_edited_curves() {
// The short shoulder is part of the V1 look of edited curves (docs/process-versions.md);
// only neutral settings are the identity.
for (c, w, b) in [(20.0, 0.0, 0.0), (0.0, 10.0, 0.0), (0.0, 0.0, -5.0)] {
let t = ToneMap::display(c, w, b);
let o = t.apply(1.0);
assert!(o < 0.999 && o > 0.95, "{c} {w} {b}: {o}");
}
}
#[test]
fn grey_stays_near_grey_and_highlights_roll_off() {
let t = ToneMap::new(0.0, 0.0, 0.0);
let g = t.apply(0.18);
assert!((0.15..0.24).contains(&g), "{g}");
assert!(t.apply(1.0) < 0.95 && t.apply(1.0) > 0.6);
assert!(t.apply(8.0) > 0.97);
}
#[test]
fn sliders_move_the_right_way() {
let base = ToneMap::new(0.0, 0.0, 0.0);
let contrast = ToneMap::new(60.0, 0.0, 0.0);
assert!(contrast.apply(0.05) < base.apply(0.05));
assert!(contrast.apply(0.8) > base.apply(0.8));
assert!(ToneMap::new(0.0, 60.0, 0.0).apply(0.8) > base.apply(0.8));
assert!(ToneMap::new(0.0, 0.0, -60.0).apply(0.01) < base.apply(0.01));
assert!(ToneMap::new(0.0, 0.0, 60.0).apply(0.01) > base.apply(0.01));
}
}