From 21d71feb042c1e5b92324eab590c36e973eb6434 Mon Sep 17 00:00:00 2001 From: robmdunn <1900604+robmdunn@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:12:35 -0400 Subject: [PATCH] LR-HDR-EXPORT: HDR renders keep the camera tone curve in the midtones Raws with a fitted camera look (e.g. Nikon NEF) rendered HDR with the generic tone curve instead, more than a stop darker through the midtones than the SDR render: PQ JPEG XL / AVIF exports looked dark everywhere, and gain map JPEGs carried negative gains, so they went dark on HDR displays. ToneMap::camera_hdr keeps the camera curve up to 1.5 EV over grey and lifts highlights as ToneMap::hdr does. Co-Authored-By: Claude --- crates/pipeline/src/finish.rs | 7 +++--- crates/pipeline/src/tests_hdr.rs | 32 +++++++++++++++++++++++++ crates/pipeline/src/tone.rs | 40 +++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index fce3b8324..886ec9eee 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -275,12 +275,13 @@ impl FinishParams { /// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited /// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here. /// `peak` is the HDR render's peak relative to SDR white ([`lightcraft_develop::Hdr::peak`]); at 1.0 (SDR) -/// every arm is the SDR tone map. The camera's look is bounded at white, so HDR renders use the HDR tone map. +/// every arm is the SDR tone map. An HDR render keeps the camera's look through the midtones and +/// lifts its highlights past white ([`ToneMap::camera_hdr`]), so it matches the SDR render below them. pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { let l = &s.light; match s.process.process() { - Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) { - Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks), + Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw) { + Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak), None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak), None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), }, diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs index bacc0eaa3..158019a4c 100644 --- a/crates/pipeline/src/tests_hdr.rs +++ b/crates/pipeline/src/tests_hdr.rs @@ -61,6 +61,38 @@ fn hdr_midtones_match_the_sdr_render() { assert!(n > 100, "the scene should have midtones and shadows ({n})"); } +#[test] +fn hdr_keeps_the_camera_tone_curve_in_the_midtones() { + // A raw with a fitted camera look (brighter midtones than the generic curve): its HDR render + // used to drop the look and come out more than a stop darker than the SDR render. + let knots = std::array::from_fn(|i| { + let x = 0.004 * 1.18f32.powi(i as i32); + [x, 1.0 - (-2.0 * x).exp()] + }); + let info = SourceInfo { camera_tone: crate::tone::CameraTone::new(knots), ..raw() }; + let src = sunset(); + let render_at = |s: &DevelopSettings, depth| { + let req = RenderRequest { depth, ..RenderRequest::fit(240, 240) }; + match render(&src, &info, s, &req).deep.map(|d| d.samples) { + Some(DeepSamples::F32(v)) => v, + other => panic!("expected float samples, got {other:?}"), + } + }; + let hdr = render_at(&hdr_on(3.0), OutputDepth::F32Hdr); + let sdr = render_at(&DevelopSettings::default(), OutputDepth::F32Linear); + 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.4) { + 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})"); + assert!(hdr.iter().any(|v| *v > 1.0), "highlights go past SDR white"); +} + #[test] fn f32hdr_without_hdr_is_the_sdr_float_render() { let src = sunset(); diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs index f9602e996..d4d2d129a 100644 --- a/crates/pipeline/src/tone.rs +++ b/crates/pipeline/src/tone.rs @@ -103,13 +103,24 @@ pub struct ToneMap { 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)); - if neutral { y } else { adjustment.apply(y) } + 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 } @@ -353,6 +364,33 @@ mod tests { 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());