This commit is contained in:
Claude committed 2026-10-11 11:39:25 +00:00
commit f98749343f
3 files changed
+75 -4

No files matched your search

+4 -3
View File
@@ -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),
},
+32
View File
@@ -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();
+39 -1
View File
@@ -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());