diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index c8a001b7..83b26403 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -140,6 +140,13 @@ controls! { "light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered; "light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered; "light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered; + "hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain; + "hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered; "curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered; "curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered; "curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered; diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 64bb116c..ad2f070d 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -98,7 +98,7 @@ impl SettingsGroup { SettingsGroup::Profile => &["profile"], SettingsGroup::Treatment => &["treatment"], SettingsGroup::WhiteBalance => &["wb"], - SettingsGroup::Light => &["light"], + SettingsGroup::Light => &["light", "hdr"], SettingsGroup::ToneCurve => &["curve"], SettingsGroup::Color => &["color"], SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"], diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 42011287..d7ce79f9 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -14,6 +14,8 @@ pub struct DevelopSettings { pub treatment: Treatment, pub wb: WhiteBalance, pub light: Light, + /// HDR editing: highlights above SDR white, and the SDR rendition derived from them. + pub hdr: Hdr, pub curve: ToneCurve, pub color: ColorAdj, pub mixer: Mixer, @@ -48,6 +50,7 @@ impl Default for DevelopSettings { treatment: Treatment::Color, wb: WhiteBalance::default(), light: Light::default(), + hdr: Hdr::default(), curve: ToneCurve::default(), color: ColorAdj::default(), mixer: Mixer::default(), @@ -931,3 +934,68 @@ impl Enhance { self.denoise_on.unwrap_or(self.denoise > 0.0) } } + +/// HDR editing. When enabled, an HDR render ([`Hdr::peak`]) keeps highlights above SDR white (1.0) +/// up to `2^max_ev`; every SDR render (previews on SDR displays, 8/16-bit exports and a gain map's +/// base image) uses the SDR rendition ([`DevelopSettings::sdr_rendition`]): the same edit with the +/// `sdr_*` offsets applied. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Hdr { + pub enabled: bool, + /// Headroom limit: stops above SDR white the HDR render may reach. + pub max_ev: f64, + /// SDR rendition offsets (−100..100): brightness (±1 EV), then added to the matching sliders. + pub sdr_brightness: f64, + pub sdr_contrast: f64, + pub sdr_highlights: f64, + pub sdr_shadows: f64, + pub sdr_whites: f64, + pub sdr_clarity: f64, +} + +impl Default for Hdr { + fn default() -> Self { + Self { + enabled: false, + max_ev: Hdr::DEFAULT_MAX_EV, + sdr_brightness: 0.0, + sdr_contrast: 0.0, + sdr_highlights: 0.0, + sdr_shadows: 0.0, + sdr_whites: 0.0, + sdr_clarity: 0.0, + } + } +} + +impl Hdr { + pub const DEFAULT_MAX_EV: f64 = 3.0; + pub const MAX_EV_LIMIT: f64 = 5.0; + + /// Peak linear value of an HDR render relative to SDR white (1.0 when HDR is off). + pub fn peak(&self) -> f32 { + if self.enabled { (self.max_ev.clamp(0.0, Hdr::MAX_EV_LIMIT) as f32).exp2() } else { 1.0 } + } +} + +impl DevelopSettings { + /// The settings an SDR render uses: unchanged when HDR is off; otherwise HDR off and the + /// SDR rendition offsets applied to exposure, contrast, highlights, shadows, whites, clarity. + pub fn sdr_rendition(&self) -> std::borrow::Cow<'_, DevelopSettings> { + if !self.hdr.enabled { + return std::borrow::Cow::Borrowed(self); + } + let h = self.hdr; + let mut s = self.clone(); + s.hdr.enabled = false; + let add = |v: f64, d: f64| (v + d).clamp(-100.0, 100.0); + s.light.exposure = (s.light.exposure + h.sdr_brightness / 100.0).clamp(-5.0, 5.0); + s.light.contrast = add(s.light.contrast, h.sdr_contrast); + s.light.highlights = add(s.light.highlights, h.sdr_highlights); + s.light.shadows = add(s.light.shadows, h.sdr_shadows); + s.light.whites = add(s.light.whites, h.sdr_whites); + s.effects.clarity = add(s.effects.clarity, h.sdr_clarity); + std::borrow::Cow::Owned(s) + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9c234964..386fc8f5 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -340,6 +340,7 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { return None; } + let s = &*lightcraft_pipeline::settings_for(s, req); let gpu = device()?; let ext = stages.map(|c| c.extension::()); let fault = take_fault(); diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 6b422ee9..76e6c1f6 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -192,6 +192,9 @@ pub struct FinishParams { pub px_per_long: f64, /// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed. pub view: [f32; 4], + /// Peak linear output relative to SDR white: 1 for SDR renders; `2^max_ev` for an HDR render + /// ([`OutputDepth::F32Hdr`] with HDR on), which keeps values above 1 instead of clipping them. + pub peak: f32, } impl FinishParams { @@ -220,15 +223,18 @@ impl FinishParams { ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) }); let calibration = s.section_enabled("calibration"); + let peak = s.hdr.peak(); FinishParams { + peak, calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None }, shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 }, - tone: if let Some(curve) = info.camera_tone.as_ref().filter(|_| info.raw) { + tone: if let Some(curve) = info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) { + // the camera's look is bounded at white: HDR renders use the HDR tone map ToneMap::camera(curve, s.light.contrast, s.light.whites, s.light.blacks) } else if info.raw { - ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) + ToneMap::hdr(s.light.contrast, s.light.whites, s.light.blacks, peak) } else { - ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks) + ToneMap::display_hdr(s.light.contrast, s.light.whites, s.light.blacks, peak) }, lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)), ops: ColorOps::new(s), @@ -298,6 +304,12 @@ pub(crate) fn finish_deep( let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0)))); DeepSamples::F32(v.into_flattened()) } + OutputDepth::F32Hdr => { + // the sRGB curve extended past 1 (it is unbounded), so values up to `peak` survive + let peak = fp.peak; + let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.max(0.0)).clamp(0.0, peak))); + DeepSamples::F32(v.into_flattened()) + } _ => { let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; let v = finish_with(p, &fp, true, |e| match trc { @@ -352,6 +364,8 @@ pub(crate) fn finish_with( let aspect = vw / vh; let srgb = srgb_lut(); + let peak = fp.peak; + let hdr = peak > 1.0; let mut out = vec![T::default(); w * h]; for_rows(&mut out, w, |y, row| { for (x, px) in row.iter_mut().enumerate() { @@ -493,8 +507,8 @@ pub(crate) fn finish_with( d = d.map(|v| o + (v - o) * k); } let mx = d[0].max(d[1]).max(d[2]); - if mx > 1.0 { - let t = ((mx - 1.0) / (mx - o).max(1e-6)).clamp(0.0, 1.0); + if mx > peak { + let t = ((mx - peak) / (mx - o).max(1e-6)).clamp(0.0, 1.0); d = d.map(|v| v + (o - v) * t); } @@ -526,6 +540,9 @@ pub(crate) fn finish_with( } else { d = d.map(|c| c * f); } + } else if hdr { + // lighten towards SDR white; never pull HDR highlights down to it + d = d.map(|c| c + (1.0 - c).max(0.0) * v.amount * t * 0.85); } else { d = d.map(|c| c + (1.0 - c) * v.amount * t * 0.85); } @@ -538,7 +555,7 @@ pub(crate) fn finish_with( let mut r = match &fp.proof { Some(pp) => { let q0 = mul3(&pp.to_proof, d); - let (q, t) = gamut_map(q0, pp.luma); + let (q, t) = gamut_map_to(q0, pp.luma, peak); if pp.dest_warning && out_of_gamut(q0, t) { warn = Some(crate::output::PROOF_DEST_WARNING); } @@ -546,17 +563,37 @@ pub(crate) fn finish_with( } None => mul3(to_out, d), }; - let (mapped, t) = gamut_map(r, *out_luma); + let (mapped, t) = gamut_map_to(r, *out_luma, peak); if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { warn = Some(crate::output::PROOF_DISPLAY_WARNING); } r = mapped; // --- encode, curves, grain - let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) }; + let mut e = if hdr { + // HDR: the (unbounded) sRGB curve, so values above SDR white encode above 1 + r.map(|v| { + if v > 1.0 { + linear_to_srgb(v) + } else if exact { + linear_to_srgb(v.max(0.0)) + } else { + encode_srgb(srgb, v) + } + }) + } else if exact { + r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) + } else { + r.map(|v| encode_srgb(srgb, v)) + }; if let Some(l) = curves { let e0 = e; - e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; + e = if hdr { + // above SDR white the curves continue with slope 1 from their end point + std::array::from_fn(|k| if e[k] > 1.0 { l[k].eval(1.0) + (e[k] - 1.0) } else { l[k].eval(e[k]) }) + } else { + [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])] + }; if fp.refine_sat < 1.0 { e = refine_saturation(e0, e, fp.refine_sat); } @@ -567,11 +604,20 @@ pub(crate) fn finish_with( let sc = fp.px_per_long as f32 / cell; let mut g = grain_noise(gx * sc, gy * sc, seed); g = g * (1.0 - rough * 0.5) + grain_noise(gx * sc * 2.3, gy * sc * 2.3, seed ^ 0x55) * rough * 0.7; - let lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + let mut lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + if hdr { + lum = lum.clamp(0.0, 1.0); + } let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e.map(|v| v + k); } let e = match &p_lut { + Some((l, k)) if hdr => { + // the LUT sees SDR values; the part above SDR white is carried over + let c = e.map(|v| v.clamp(0.0, 1.0)); + let m = l.apply(c); + [e[0] + (m[0] - c[0]) * k, e[1] + (m[1] - c[1]) * k, e[2] + (m[2] - c[2]) * k] + } Some((l, k)) => { let m = l.apply(e.map(|v| v.clamp(0.0, 1.0))); [e[0] + (m[0] - e[0]) * k, e[1] + (m[1] - e[1]) * k, e[2] + (m[2] - e[2]) * k] @@ -609,13 +655,19 @@ fn mul3(m: &[[f32; 3]; 3], d: [f32; 3]) -> [f32; 3] { /// returns the mapped colour and the chroma scale used (1 = already in gamut). #[inline] pub fn gamut_map(r: [f32; 3], luma: [f32; 3]) -> ([f32; 3], f32) { - let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, 1.0); + gamut_map_to(r, luma, 1.0) +} + +/// [`gamut_map`] into `0..peak` (an HDR render's range; `peak` = 1 is [`gamut_map`]). +#[inline] +pub fn gamut_map_to(r: [f32; 3], luma: [f32; 3], peak: f32) -> ([f32; 3], f32) { + let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, peak); let mut t = 1.0f32; for c in r { if c < 0.0 { t = t.min(yy / (yy - c).max(1e-9)); - } else if c > 1.0 { - t = t.min((1.0 - yy) / (c - yy).max(1e-9)); + } else if c > peak { + t = t.min((peak - yy) / (c - yy).max(1e-9)); } } if t < 1.0 { (r.map(|c| yy + (c - yy) * t), t) } else { (r, 1.0) } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 274bcd61..0613c65e 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -448,6 +448,13 @@ pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) { redeye::apply(img, &p.eyes); } +/// The settings a render of `s` for `req` uses: the HDR edit for an [`OutputDepth::F32Hdr`] +/// render, otherwise its SDR rendition (see [`DevelopSettings::sdr_rendition`]). Every renderer +/// (CPU and GPU) goes through this, so an SDR render of an HDR edit looks the same everywhere. +pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::borrow::Cow<'a, DevelopSettings> { + if req.depth == OutputDepth::F32Hdr { std::borrow::Cow::Borrowed(s) } else { s.sdr_rendition() } +} + /// Render `src` with settings `s`. pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered { render_impl(Src::Borrowed(src), info, s, req, None) @@ -467,7 +474,8 @@ enum Src<'a> { fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered { // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); - let s: &DevelopSettings = &effective; + let rendition = settings_for(&effective, req); + let s: &DevelopSettings = &rendition; // `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling. let lap = |what: &str, t: &mut Option| { if let Some(t) = t { @@ -620,6 +628,8 @@ mod tests; #[cfg(test)] mod tests_geometry; #[cfg(test)] +mod tests_hdr; +#[cfg(test)] mod tests_local; #[cfg(test)] mod tests_window; diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 5272b54d..97e736a8 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -192,6 +192,9 @@ pub enum OutputDepth { U16, /// 32-bit float *linear* RGB (target primaries, 0..1) in [`crate::Rendered::deep`]. F32Linear, + /// 32-bit float linear RGB (target primaries) of the HDR render: SDR white = 1, highlights up + /// to [`lightcraft_develop::Hdr::peak`]. With HDR off this is [`OutputDepth::F32Linear`]. + F32Hdr, } /// High-bit-depth RGB samples (3 per pixel, interleaved, row-major). diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs new file mode 100644 index 00000000..e25797ea --- /dev/null +++ b/crates/pipeline/src/tests_hdr.rs @@ -0,0 +1,89 @@ +//! HDR editing: the HDR render ([`OutputDepth::F32Hdr`]) and the SDR rendition every other render uses. + +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::Rgb32f; + +use crate::{DeepSamples, OutputDepth, RenderRequest, SourceInfo, render}; + +fn sunset() -> Rgb32f { + let lib = lightcraft_scenes::demo_library(); + let s = lib.iter().find(|s| s.kind == lightcraft_scenes::Kind::OceanSunset).map(|s| s.render(240, 160)); + s.unwrap_or_else(|| lib[0].render(240, 160)) +} + +fn raw() -> SourceInfo { + SourceInfo { raw: true, ..Default::default() } +} + +fn hdr_on(max_ev: f64) -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.hdr.enabled = true; + s.hdr.max_ev = max_ev; + s +} + +fn deep(src: &Rgb32f, s: &DevelopSettings, depth: OutputDepth) -> Vec { + let req = RenderRequest { depth, ..RenderRequest::fit(240, 240) }; + match render(src, &raw(), s, &req).deep.map(|d| d.samples) { + Some(DeepSamples::F32(v)) => v, + other => panic!("expected float samples, got {other:?}"), + } +} + +#[test] +fn hdr_render_keeps_highlights_up_to_the_peak() { + let src = sunset(); + let v = deep(&src, &hdr_on(3.0), OutputDepth::F32Hdr); + assert!(v.iter().all(|x| x.is_finite() && (0.0..=8.0 + 1e-4).contains(x))); + let max = v.iter().copied().fold(0.0f32, f32::max); + assert!(max > 1.5, "the sun should rise above SDR white, max {max}"); + // a smaller headroom limit caps lower + let v1 = deep(&src, &hdr_on(1.0), OutputDepth::F32Hdr); + assert!(v1.iter().all(|x| *x <= 2.0 + 1e-4)); +} + +#[test] +fn hdr_midtones_match_the_sdr_render() { + let src = sunset(); + let hdr = deep(&src, &hdr_on(3.0), OutputDepth::F32Hdr); + let sdr = deep(&src, &DevelopSettings::default(), OutputDepth::F32Linear); + // pixels whose brightest channel is a midtone (bright saturated colours legitimately differ: + // SDR desaturates them at white, HDR keeps their colour) + let mut n = 0; + for (h, s) in hdr.as_chunks::<3>().0.iter().zip(sdr.as_chunks::<3>().0) { + if s.iter().all(|v| *v < 0.15) { + for (a, b) in h.iter().zip(s) { + assert!((a - b).abs() < 2e-3, "{h:?} vs {s:?}"); + } + n += 1; + } + } + assert!(n > 100, "the scene should have midtones and shadows ({n})"); +} + +#[test] +fn f32hdr_without_hdr_is_the_sdr_float_render() { + let src = sunset(); + let s = DevelopSettings::default(); + assert_eq!(deep(&src, &s, OutputDepth::F32Hdr), deep(&src, &s, OutputDepth::F32Linear)); +} + +#[test] +fn sdr_renders_of_an_hdr_edit_use_the_sdr_rendition() { + let src = sunset(); + let req = RenderRequest::fit(240, 240); + // neutral offsets: identical to the edit with HDR off + let plain = render(&src, &raw(), &DevelopSettings::default(), &req); + let on = render(&src, &raw(), &hdr_on(3.0), &req); + assert_eq!(plain.image.data, on.image.data); + // offsets apply to every SDR render + let mut s = hdr_on(3.0); + s.hdr.sdr_brightness = 50.0; + s.hdr.sdr_highlights = -40.0; + let brighter = render(&src, &raw(), &s, &req); + let expect = render(&src, &raw(), &s.sdr_rendition(), &req); + assert_eq!(brighter.image.data, expect.image.data); + assert_ne!(brighter.image.data, plain.image.data); + let r = s.sdr_rendition(); + assert!(!r.hdr.enabled && (r.light.exposure - 0.5).abs() < 1e-9 && r.light.highlights == -40.0); +} diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs index 1a1fcc20..3d078c99 100644 --- a/crates/pipeline/src/tone.rs +++ b/crates/pipeline/src/tone.rs @@ -87,6 +87,9 @@ impl CameraTone { } } +/// 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, @@ -108,6 +111,16 @@ impl ToneMap { } /// `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. @@ -133,7 +146,11 @@ impl ToneMap { let k = b * 0.03; o = k + (1.0 - k) * o; } - o.clamp(0.0, 1.0) + 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 } @@ -141,6 +158,14 @@ impl ToneMap { /// 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; @@ -163,11 +188,15 @@ impl ToneMap { } let mut o = p.max(0.0).powf(2.2); // short shoulder: slope 1 at 0.95, reaching 1.0 at 1.05 - if o > 0.95 { + 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 { let d = (o - 0.95).min(0.1); o = 0.95 + d - d * d / 0.2; } - o.clamp(0.0, 1.0) + o.clamp(0.0, peak) }) .collect(); ToneMap { lut, chroma: NO_CHROMA } @@ -291,6 +320,51 @@ mod tests { } } + #[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 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);