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:
parent
1cc0dde747
commit
791526aa8c
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue