mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
48efc6df52
commit
37d9ecf0e9
10 files changed
+99
-1
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -191,6 +191,9 @@ pub struct ToneCurve {
|
||||
pub red: Vec<Point>,
|
||||
pub green: Vec<Point>,
|
||||
pub blue: Vec<Point>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> 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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -139,6 +139,20 @@ fn calibrate(c0: vec3<f32>) -> vec3<f32> {
|
||||
return c;
|
||||
}
|
||||
|
||||
// `finish::refine_saturation`.
|
||||
fn refine_saturation(before: vec3<f32>, after: vec3<f32>, refine: f32) -> vec3<f32> {
|
||||
let lw = vec3<f32>(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<u32>(i) * GRAIN_H0) ^ (bitcast<u32>(j) * GRAIN_H1) ^ (seed * GRAIN_H2);
|
||||
v ^= v >> 13u;
|
||||
@@ -365,7 +379,12 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// --- encode, curves, grain
|
||||
var e = vec3<f32>(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z));
|
||||
if (pu(F_CURVES) != 0u) {
|
||||
let e0 = e;
|
||||
e = vec3<f32>(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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vig>,
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Point> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`).
|
||||
| `GrayMixer<Band>` | `bw_mix.<band>` | |
|
||||
| `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` | |
|
||||
|
||||
Reference in new issue
Block a user