LR-HDR-EDIT: HDR settings, HDR tone map and the float HDR render

- `DevelopSettings.hdr`: enabled, headroom limit (`max_ev`) and SDR-rendition
  offsets (brightness, contrast, highlights, shadows, whites, clarity), with
  control specs (`hdr.maxEv`, `hdr.sdr*`) and copied with the Light group.
- `ToneMap::hdr` / `display_hdr`: the SDR curve through the midtones, with
  highlights lifted up to `2^max_ev`; identical to the SDR tables at peak 1.
- `OutputDepth::F32Hdr`: unclamped linear output up to the peak. The finish
  stage carries the peak through highlight desaturation, gamut mapping,
  encoding, curves, grain and LUT profiles; with HDR off every branch is the
  SDR code path, bit for bit.
- `settings_for`: every non-HDR render (CPU and GPU) uses the SDR rendition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
akvaithi 2026-10-05 11:53:56 -05:00
parent 1cc0dde747
commit 791526aa8c
9 changed files with 322 additions and 18 deletions

View File

@ -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;

View File

@ -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"],

View File

@ -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)
}
}

View File

@ -340,6 +340,7 @@ pub fn render(src: &Arc<Rgb32f>, 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::<GpuStages>());
let fault = take_fault();

View File

@ -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<T: Copy + Default + Send>(
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<T: Copy + Default + Send>(
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<T: Copy + Default + Send>(
} 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<T: Copy + Default + Send>(
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<T: Copy + Default + Send>(
}
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<T: Copy + Default + Send>(
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) }

View File

@ -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<std::time::Instant>| {
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;

View File

@ -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).

View File

@ -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<f32> {
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);
}

View File

@ -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<f32>,
@ -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);