From 37d9ecf0e9b7f74d795d13253d58017b3fc6fa13 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:06:14 -0400 Subject: [PATCH] LR-EDIT-LIGHT-CURVE-REFINESAT: curve Refine Saturation curve.refineSaturation (0..100, default 100 = the curve's own saturation): after the tone curves, the chroma around luma is rescaled so saturation moves from the curve's towards the pre-curve value, keeping the curve's tone. CPU (finish::refine_saturation) and GPU twin, equivalence case, slider under the curve editor, crs:CurveRefineSaturation import. Co-Authored-By: Claude Opus 5.5 --- crates/develop/src/controls.rs | 1 + crates/develop/src/settings.rs | 4 ++++ crates/engine/src/crs.rs | 4 +++- crates/gpu/src/params.rs | 2 ++ crates/gpu/src/wgsl/finish.wgsl | 19 +++++++++++++++ crates/gpu/tests/equivalence.rs | 5 ++++ crates/pipeline/src/finish.rs | 39 +++++++++++++++++++++++++++++++ crates/pipeline/src/tests.rs | 23 ++++++++++++++++++ crates/ui-egui/src/panels/edit.rs | 2 ++ docs/xmp-interop.md | 1 + 10 files changed, 99 insertions(+), 1 deletion(-) diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index ebbd5315f7..ba456260c6 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -140,6 +140,7 @@ controls! { "curve.splitShadows" => curve.split_shadows, "Shadows split", Curve, 10, 70, 25, 1, 0, Plain; "curve.splitMid" => curve.split_mid, "Midtones split", Curve, 20, 80, 50, 1, 0, Plain; "curve.splitHighlights" => curve.split_highlights, "Highlights split", Curve, 30, 90, 75, 1, 0, Plain; + "curve.refineSaturation" => curve.refine_saturation, "Refine Saturation", Curve, 0, 100, 100, 1, 0, Gradient { from: "#7a7a7a", to: "#d85a3a" }; "color.vibrance" => color.vibrance, "Vibrance", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#d8406a" }; "color.saturation" => color.saturation, "Saturation", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e04a3a" }; "mixer.red.hue" => mixer.red.hue, "Red Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 0 }; diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 3e0326cbc4..ee625e1a56 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -191,6 +191,9 @@ pub struct ToneCurve { pub red: Vec, pub green: Vec, pub blue: Vec, + /// Refine Saturation 0..100: 100 keeps the saturation a curve produces, lower values pull it + /// back towards the saturation before the curve (strong contrast curves oversaturate). + pub refine_saturation: f64, } impl Default for ToneCurve { @@ -207,6 +210,7 @@ impl Default for ToneCurve { red: Vec::new(), green: Vec::new(), blue: Vec::new(), + refine_saturation: 100.0, } } } diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index d39c0a72f4..6dfaaa93cb 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -190,6 +190,7 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "ParametricShadowSplit", "curve.split_shadows"); n(o, "ParametricMidtoneSplit", "curve.split_mid"); n(o, "ParametricHighlightSplit", "curve.split_highlights"); + n(o, "CurveRefineSaturation", "curve.refine_saturation"); for (crs, ch) in [("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] { @@ -339,7 +340,7 @@ mod tests { crs:Whites2012="+9" crs:Blacks2012="-17" crs:Texture="+6" crs:Clarity2012="+11" crs:Dehaze="+4" crs:Vibrance="+19" crs:Saturation="-3" crs:HueAdjustmentOrange="-7" crs:SaturationAdjustmentBlue="-22" crs:LuminanceAdjustmentAqua="+13" crs:ParametricShadows="+5" crs:ParametricHighlights="-8" - crs:ParametricMidtoneSplit="55" crs:SplitToningShadowHue="210" crs:SplitToningShadowSaturation="14" + crs:ParametricMidtoneSplit="55" crs:CurveRefineSaturation="40" crs:SplitToningShadowHue="210" crs:SplitToningShadowSaturation="14" crs:SplitToningHighlightHue="40" crs:SplitToningHighlightSaturation="9" crs:SplitToningBalance="+20" crs:ColorGradeMidtoneHue="120" crs:ColorGradeMidtoneSat="6" crs:ColorGradeBlending="70" crs:Sharpness="55" crs:SharpenRadius="+1.2" crs:SharpenDetail="30" crs:SharpenEdgeMasking="12" @@ -380,6 +381,7 @@ mod tests { assert_eq!(s.mixer.aqua.lum, 13.0); assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); assert_eq!(s.curve.master.len(), 4); + assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index d05d622fb6..18ad1639c0 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -59,6 +59,7 @@ const FIELDS: &[(&str, usize)] = &[ ("GRAIN_ROUGH", 1), ("GRAIN_SEED", 1), ("GRAIN_AFF", 6), + ("REFINE_SAT", 1), ("CALIB", 1), ("CALIB_M", 9), ("SHADOW_TINT", 1), @@ -147,6 +148,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.u("SRGB_OFF", srgb_off as u32); p.u("CURVE_OFF", curve_off as u32); p.b("CURVES", fp.curves.is_some()); + p.f("REFINE_SAT", fp.refine_sat); p.f("GAIN", fp.gain); p.f("EV", fp.ev); p.f("AIR", fp.air); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 8528d30c3c..6b1a22a259 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -139,6 +139,20 @@ fn calibrate(c0: vec3) -> vec3 { return c; } +// `finish::refine_saturation`. +fn refine_saturation(before: vec3, after: vec3, refine: f32) -> vec3 { + let lw = vec3(0.2126, 0.7152, 0.0722); + let y0 = dot(before, lw); + let y1 = dot(after, lw); + let s0 = (max(before.x, max(before.y, before.z)) - min(before.x, min(before.y, before.z))) / max(y0, 1e-4); + let s1 = (max(after.x, max(after.y, after.z)) - min(after.x, min(after.y, after.z))) / max(y1, 1e-4); + if (s1 <= 1e-6 || s0 <= 1e-6) { + return after; + } + let k = clamp(pow(s0 / s1, 1.0 - refine), 0.0, 4.0); + return y1 + (after - y1) * k; +} + fn ghash(i: i32, j: i32, seed: u32) -> f32 { var v = (bitcast(i) * GRAIN_H0) ^ (bitcast(j) * GRAIN_H1) ^ (seed * GRAIN_H2); v ^= v >> 13u; @@ -365,7 +379,12 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // --- encode, curves, grain var e = vec3(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z)); if (pu(F_CURVES) != 0u) { + let e0 = e; e = vec3(curve(0u, e.x), curve(1u, e.y), curve(2u, e.z)); + let rs = pf(F_REFINE_SAT); + if (rs < 1.0) { + e = refine_saturation(e0, e, rs); + } } if (pu(F_GRAIN) != 0u) { let px = f32(x) + 0.5; diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index b479dcf745..12c94be49d 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -102,6 +102,11 @@ fn cases() -> Vec<(&'static str, Edit)> { s.curve.master = vec![Point::new(0.0, 0.05), Point::new(0.5, 0.55), Point::new(1.0, 0.95)]; s.curve.blue = vec![Point::new(0.0, 0.0), Point::new(0.5, 0.45), Point::new(1.0, 1.0)]; }), + ("tone curves + refine saturation", |s| { + s.curve.master = vec![Point::new(0.0, 0.0), Point::new(0.25, 0.15), Point::new(0.75, 0.88), Point::new(1.0, 1.0)]; + s.curve.lights = 30.0; + s.curve.refine_saturation = 20.0; + }), ("vignette (highlight)", |s| { s.vignette.amount = -60.0; s.vignette.highlights = 50.0; diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index fc14f71c96..0ab6199e41 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -150,6 +150,8 @@ pub struct FinishParams { pub shadow_tint: f32, /// Tone curves (parametric ∘ point, per channel) on encoded values, 1024 entries each. pub curves: Option<[Lut1; 3]>, + /// Refine Saturation as 0..1 (1 = the curves' own saturation). + pub refine_sat: f32, pub vig: Option, pub to_srgb: [[f32; 3]; 3], pub hl: f32, @@ -201,6 +203,7 @@ impl FinishParams { }, ops: ColorOps::new(s), curves: curve_luts(&s.curve), + refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32, vig: if effects { vignette(s) } else { None }, to_srgb: REC2020.to_space(&SRGB).to_f32(), hl: (s.light.highlights / 100.0) as f32, @@ -408,7 +411,11 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So // --- encode, curves, grain let mut e = 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])]; + if fp.refine_sat < 1.0 { + e = refine_saturation(e0, e, fp.refine_sat); + } } if let Some((amt, cell, rough, seed)) = *grain { let n = out_to_norm.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); @@ -426,6 +433,23 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So out } +/// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its +/// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is +/// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation. +#[inline] +pub fn refine_saturation(before: [f32; 3], after: [f32; 3], refine: f32) -> [f32; 3] { + let luma = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + let chroma = |e: [f32; 3]| e[0].max(e[1]).max(e[2]) - e[0].min(e[1]).min(e[2]); + let (y0, y1) = (luma(before), luma(after)); + let s0 = chroma(before) / y0.max(1e-4); + let s1 = chroma(after) / y1.max(1e-4); + if s1 <= 1e-6 || s0 <= 1e-6 { + return after; + } + let k = (s0 / s1).powf(1.0 - refine).clamp(0.0, 4.0); + after.map(|v| y1 + (v - y1) * k) +} + pub const SRGB_LUT_N: usize = 4096; /// Linear → sRGB-encoded table (`SRGB_LUT_N + 1` entries over 0..1), interpolated linearly. @@ -460,6 +484,21 @@ fn enc(v: f32) -> u8 { mod tests { use super::*; + #[test] + fn refine_saturation_restores_the_pre_curve_saturation() { + let before = [0.5, 0.3, 0.2]; + let after = [0.7, 0.35, 0.15]; // a contrasty curve: more saturated + assert_eq!(refine_saturation(before, after, 1.0), after); + let r = refine_saturation(before, after, 0.0); + let sat = |e: [f32; 3]| (e[0] - e[2]) / (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]); + assert!((sat(r) - sat(before)).abs() < 1e-4, "{r:?}"); + let half = refine_saturation(before, after, 0.5); + assert!(sat(half) > sat(before) && sat(half) < sat(after)); + // luma is kept + let y = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + assert!((y(r) - y(after)).abs() < 1e-5); + } + #[test] fn srgb_table_matches_the_exact_curve() { let lut = srgb_lut(); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index e3b928f582..93158164b2 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -259,3 +259,26 @@ fn calibration_shifts_colours_but_keeps_greys() { let off = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; assert!(off[1].abs_diff(off[0]) <= 1, "{off:?}"); } + +#[test] +fn refine_saturation_tames_a_contrast_curve() { + let info = SourceInfo { raw: true, ..Default::default() }; + let src = Rgb32f::filled(16, 16, [0.12, 0.05, 0.03]); + let req = RenderRequest::fit(16, 16); + let sat = |p: [u8; 4]| p[0] as i32 - p[2] as i32; + let mut s = DevelopSettings::default(); + let flat = render(&src, &info, &s, &req).image.data[0]; + s.curve.master = vec![ + lightcraft_geom::Point::new(0.0, 0.0), + lightcraft_geom::Point::new(0.3, 0.15), + lightcraft_geom::Point::new(0.7, 0.85), + lightcraft_geom::Point::new(1.0, 1.0), + ]; + let full = render(&src, &info, &s, &req).image.data[0]; + s.curve.refine_saturation = 0.0; + let refined = render(&src, &info, &s, &req).image.data[0]; + assert!(sat(full) > sat(refined), "{flat:?} {full:?} {refined:?}"); + // the curve's tone change stays + let y = |p: [u8; 4]| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32; + assert!((y(full) - y(refined)).abs() < 2.0); +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 2bfd26cb79..a9c443a281 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -463,6 +463,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] { control(app, ui, d, c, true); } + control(app, ui, d, "curve.refineSaturation", true); return; } let pts_of = |d: &DevelopSettings| -> Vec { @@ -554,6 +555,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let _ = app.run("develop.endInteraction", json!({})); } ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); + control(app, ui, d, "curve.refineSaturation", true); let _ = id; } diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 0de4089462..28867685d0 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -58,6 +58,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `GrayMixer` | `bw_mix.` | | | `ParametricShadows`, `ParametricDarks`, `ParametricLights`, `ParametricHighlights` | `curve.shadows/darks/lights/highlights` | | | `ParametricShadowSplit`, `ParametricMidtoneSplit`, `ParametricHighlightSplit` | `curve.split_shadows/split_mid/split_highlights` | | +| `CurveRefineSaturation` | `curve.refine_saturation` | 0..100 (100 = curve saturation unchanged) | | `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve | | `SplitToningShadowHue/Saturation`, `SplitToningHighlightHue/Saturation` | `grading.shadows/highlights.hue/sat` | | | `ColorGradeShadowLum`, `ColorGradeHighlightLum` | `grading.shadows/highlights.lum` | |