LR-EDIT-CALIB: Calibration — shadows tint and R/G/B primary hue/saturation
White-preserving primaries matrix (OkLCh hue rotation / chroma scale of each Rec.2020 primary) plus a luminance-preserving green/magenta shadows tint, applied in scene-linear light before the tone map. CPU (colorops::calibrate) and GPU (finish.wgsl) with an equivalence case; seven control specs (calibration.*), a settings group, crs: import (ShadowTint, Red/Green/BlueHue/Saturation) and a Calibration section at the bottom of the Edit panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b13274f711
commit
48efc6df52
|
|
@ -22,6 +22,7 @@ pub enum Section {
|
|||
Optics,
|
||||
Geometry,
|
||||
Profile,
|
||||
Calibration,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
|
|
@ -40,6 +41,7 @@ impl Section {
|
|||
Section::Optics => "Optics",
|
||||
Section::Geometry => "Geometry",
|
||||
Section::Profile => "Profile",
|
||||
Section::Calibration => "Calibration",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -227,6 +229,13 @@ controls! {
|
|||
"geometry.scale" => geometry.scale, "Scale", Geometry, 50, 150, 100, 1, 0, Plain;
|
||||
"geometry.offsetX" => geometry.offset_x, "Offset X", Geometry, -100, 100, 0, 0.1, 1, Centered;
|
||||
"geometry.offsetY" => geometry.offset_y, "Offset Y", Geometry, -100, 100, 0, 0.1, 1, Centered;
|
||||
"calibration.shadowsTint" => calibration.shadows_tint, "Shadows Tint", Calibration, -100, 100, 0, 1, 0, Tint;
|
||||
"calibration.redHue" => calibration.red_hue, "Red Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#e0306a", to: "#e08a30" };
|
||||
"calibration.redSat" => calibration.red_sat, "Red Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e03a3a" };
|
||||
"calibration.greenHue" => calibration.green_hue, "Green Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#b0c030", to: "#30c08a" };
|
||||
"calibration.greenSat" => calibration.green_sat, "Green Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40b050" };
|
||||
"calibration.blueHue" => calibration.blue_hue, "Blue Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#30a0d0", to: "#8040d0" };
|
||||
"calibration.blueSat" => calibration.blue_sat, "Blue Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#3a60e0" };
|
||||
"crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ pub enum SettingsGroup {
|
|||
Spots,
|
||||
RedEye,
|
||||
LensBlur,
|
||||
Calibration,
|
||||
}
|
||||
|
||||
impl SettingsGroup {
|
||||
pub const ALL: [SettingsGroup; 19] = [
|
||||
pub const ALL: [SettingsGroup; 20] = [
|
||||
SettingsGroup::Profile,
|
||||
SettingsGroup::Treatment,
|
||||
SettingsGroup::WhiteBalance,
|
||||
|
|
@ -55,6 +56,7 @@ impl SettingsGroup {
|
|||
SettingsGroup::Spots,
|
||||
SettingsGroup::RedEye,
|
||||
SettingsGroup::LensBlur,
|
||||
SettingsGroup::Calibration,
|
||||
];
|
||||
|
||||
/// Groups included by default when copying (Lightroom excludes crop, masks and spots by default).
|
||||
|
|
@ -86,6 +88,7 @@ impl SettingsGroup {
|
|||
SettingsGroup::Spots => "Remove",
|
||||
SettingsGroup::RedEye => "Red Eye",
|
||||
SettingsGroup::LensBlur => "Lens Blur",
|
||||
SettingsGroup::Calibration => "Calibration",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +114,7 @@ impl SettingsGroup {
|
|||
SettingsGroup::Spots => &["spots"],
|
||||
SettingsGroup::RedEye => &["red_eye"],
|
||||
SettingsGroup::LensBlur => &["lens_blur"],
|
||||
SettingsGroup::Calibration => &["calibration"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub struct DevelopSettings {
|
|||
pub red_eye: Vec<RedEye>,
|
||||
pub lens_blur: LensBlur,
|
||||
pub enhance: Enhance,
|
||||
/// Camera calibration: shadows tint and primary hue/saturation (applied before tone mapping).
|
||||
pub calibration: Calibration,
|
||||
/// Section on/off toggles (the "eye" buttons on panel headers): section id → enabled.
|
||||
pub disabled_sections: Vec<String>,
|
||||
}
|
||||
|
|
@ -62,6 +64,7 @@ impl Default for DevelopSettings {
|
|||
red_eye: Vec::new(),
|
||||
lens_blur: LensBlur::default(),
|
||||
enhance: Enhance::default(),
|
||||
calibration: Calibration::default(),
|
||||
disabled_sections: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -319,6 +322,31 @@ impl ColorGrading {
|
|||
}
|
||||
}
|
||||
|
||||
/// Camera calibration (Lightroom Classic's Calibration panel): a green/magenta tint of the shadows,
|
||||
/// and a hue rotation / saturation scale of each working-space primary (−100..100), i.e. a
|
||||
/// white-preserving 3×3 matrix applied in scene-linear light before tone mapping.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Calibration {
|
||||
pub shadows_tint: f64,
|
||||
pub red_hue: f64,
|
||||
pub red_sat: f64,
|
||||
pub green_hue: f64,
|
||||
pub green_sat: f64,
|
||||
pub blue_hue: f64,
|
||||
pub blue_sat: f64,
|
||||
}
|
||||
|
||||
impl Calibration {
|
||||
/// (hue, saturation) of the red, green and blue primaries.
|
||||
pub fn primaries(&self) -> [(f64, f64); 3] {
|
||||
[(self.red_hue, self.red_sat), (self.green_hue, self.green_sat), (self.blue_hue, self.blue_sat)]
|
||||
}
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
*self == Calibration::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Effects {
|
||||
|
|
|
|||
|
|
@ -261,6 +261,15 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
|||
n(o, "DefringeGreenHueLo", "optics.defringe_green_hue_lo");
|
||||
n(o, "DefringeGreenHueHi", "optics.defringe_green_hue_hi");
|
||||
|
||||
// ---- Calibration
|
||||
n(o, "ShadowTint", "calibration.shadows_tint");
|
||||
n(o, "RedHue", "calibration.red_hue");
|
||||
n(o, "RedSaturation", "calibration.red_sat");
|
||||
n(o, "GreenHue", "calibration.green_hue");
|
||||
n(o, "GreenSaturation", "calibration.green_sat");
|
||||
n(o, "BlueHue", "calibration.blue_hue");
|
||||
n(o, "BlueSaturation", "calibration.blue_sat");
|
||||
|
||||
// ---- Geometry
|
||||
n(o, "PerspectiveVertical", "geometry.vertical");
|
||||
n(o, "PerspectiveHorizontal", "geometry.horizontal");
|
||||
|
|
@ -337,6 +346,8 @@ mod tests {
|
|||
crs:LuminanceSmoothing="18" crs:ColorNoiseReduction="25" crs:PostCropVignetteAmount="-21"
|
||||
crs:PostCropVignetteStyle="2" crs:GrainAmount="15" crs:GrainSize="30" crs:GrainFrequency="60"
|
||||
crs:PerspectiveUpright="2" crs:PerspectiveVertical="-10" crs:AutoLateralCA="1" crs:ConvertToGrayscale="False"
|
||||
crs:ShadowTint="-7" crs:RedHue="+12" crs:RedSaturation="+20" crs:GreenHue="-5" crs:GreenSaturation="+3"
|
||||
crs:BlueHue="-15" crs:BlueSaturation="+25"
|
||||
crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.05" crs:CropBottom="0.9" crs:CropRight="0.8" crs:CropAngle="1.5">
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 52</rdf:li><rdf:li>192, 205</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq>
|
||||
|
|
@ -384,6 +395,11 @@ mod tests {
|
|||
assert_eq!(s.geometry.vertical, -10.0);
|
||||
assert!(s.optics.remove_ca);
|
||||
assert_eq!(s.treatment, lightcraft_develop::Treatment::Color);
|
||||
let cal = s.calibration;
|
||||
assert_eq!(
|
||||
(cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat),
|
||||
(-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0)
|
||||
);
|
||||
let c = s.crop.geometry;
|
||||
assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ fn constants() -> String {
|
|||
let b = lightcraft_pipeline::geometry::BLANK;
|
||||
s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]);
|
||||
s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N);
|
||||
s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT);
|
||||
for (i, h) in GRAIN_HASH.iter().enumerate() {
|
||||
s += &format!("const GRAIN_H{i}: u32 = {h}u;\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ const FIELDS: &[(&str, usize)] = &[
|
|||
("GRAIN_ROUGH", 1),
|
||||
("GRAIN_SEED", 1),
|
||||
("GRAIN_AFF", 6),
|
||||
("CALIB", 1),
|
||||
("CALIB_M", 9),
|
||||
("SHADOW_TINT", 1),
|
||||
];
|
||||
|
||||
/// `(name, index)` of every field (for the WGSL constants).
|
||||
|
|
@ -196,6 +199,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
|
|||
},
|
||||
);
|
||||
}
|
||||
if let Some(m) = &fp.calib {
|
||||
p.b("CALIB", true);
|
||||
p.fs("CALIB_M", m.as_flattened());
|
||||
}
|
||||
p.f("SHADOW_TINT", fp.shadow_tint);
|
||||
if let Some((amt, cell, rough, seed)) = fp.grain {
|
||||
p.b("GRAIN", true);
|
||||
p.f("GRAIN_AMT", amt);
|
||||
|
|
|
|||
|
|
@ -117,6 +117,28 @@ fn color_ops(rgb: vec3<f32>, local_sat: f32, local_hue: f32) -> vec3<f32> {
|
|||
return oklab_inv(lab);
|
||||
}
|
||||
|
||||
// `colorops::calibrate`: primaries matrix, then the shadows tint (luminance kept).
|
||||
fn calibrate(c0: vec3<f32>) -> vec3<f32> {
|
||||
var c = c0;
|
||||
if (pu(F_CALIB) != 0u) {
|
||||
let m = array<vec3<f32>, 3>(
|
||||
vec3<f32>(pf(F_CALIB_M), pf(F_CALIB_M + 1u), pf(F_CALIB_M + 2u)),
|
||||
vec3<f32>(pf(F_CALIB_M + 3u), pf(F_CALIB_M + 4u), pf(F_CALIB_M + 5u)),
|
||||
vec3<f32>(pf(F_CALIB_M + 6u), pf(F_CALIB_M + 7u), pf(F_CALIB_M + 8u)),
|
||||
);
|
||||
c = max(mul3(m, c), vec3<f32>(0.0));
|
||||
}
|
||||
let st = pf(F_SHADOW_TINT);
|
||||
if (st != 0.0) {
|
||||
let y0 = lum2020(c);
|
||||
let w = 1.0 - sstep(-5.0, -0.5, log2(max(y0, 1e-7) / 0.18));
|
||||
c.y *= max(1.0 - SHADOW_TINT_K * st * w, 0.0);
|
||||
let y1 = max(lum2020(c), 1e-9);
|
||||
c = c * y0 / y1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -263,6 +285,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
c = c * exp2(delta);
|
||||
}
|
||||
|
||||
// --- calibration (scene linear, before the tone map)
|
||||
if (pu(F_CALIB) != 0u || pf(F_SHADOW_TINT) != 0.0) {
|
||||
c = calibrate(c);
|
||||
}
|
||||
|
||||
// --- tone map on luminance, highlight desaturation
|
||||
let yl = lum2020(c);
|
||||
let o = tone_apply(yl);
|
||||
|
|
|
|||
|
|
@ -253,6 +253,14 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
|||
},
|
||||
];
|
||||
}),
|
||||
("calibration", |s| {
|
||||
s.calibration.shadows_tint = 40.0;
|
||||
s.calibration.red_hue = 50.0;
|
||||
s.calibration.red_sat = -30.0;
|
||||
s.calibration.green_hue = -40.0;
|
||||
s.calibration.blue_sat = 60.0;
|
||||
s.calibration.blue_hue = 25.0;
|
||||
}),
|
||||
("spots + defringe (cpu stage)", |s| {
|
||||
s.spots = vec![Spot { points: vec![Point::new(0.3, 0.3)], size: 0.03, source_offset: Some(Point::new(0.1, 0.0)), ..Default::default() }];
|
||||
s.optics.defringe_purple_amount = 5.0;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! Colour tools in OkLCh on display-linear Rec.2020 values: vibrance, saturation, the 8-band colour
|
||||
//! mixer, B&W mix, and 3-way colour grading.
|
||||
//! mixer, B&W mix, and 3-way colour grading; plus the camera-calibration matrix (scene linear).
|
||||
|
||||
use std::f32::consts::{PI, TAU};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use lightcraft_color::perceptual::{hsv_to_rgb, lab_to_lch, lch_to_lab, oklab_from_2020, oklab_to_2020};
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_develop::{DevelopSettings, MIXER_HUES};
|
||||
use lightcraft_color::{Mat3, REC2020, SRGB};
|
||||
use lightcraft_develop::{Calibration, DevelopSettings, MIXER_HUES};
|
||||
|
||||
/// OkLCh hue angle (radians) of a pure sRGB colour with HSV hue `deg`.
|
||||
pub fn oklch_hue_of_srgb_hue(deg: f64) -> f32 {
|
||||
|
|
@ -158,6 +158,57 @@ impl ColorOps {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hue rotation (OkLCh radians) of a calibration primary at ±100.
|
||||
pub const CALIB_HUE: f32 = 0.5;
|
||||
/// Chroma scale of a calibration primary at ±100 (`1 ± CALIB_SAT`).
|
||||
pub const CALIB_SAT: f32 = 0.6;
|
||||
|
||||
/// The calibration panel's primaries as a white-preserving 3×3 matrix on linear Rec.2020 (row-major):
|
||||
/// each primary is rotated in OkLCh hue and scaled in chroma at constant OkLab lightness, then the
|
||||
/// columns are rescaled so that neutral (1, 1, 1) maps to itself. `None` when neutral.
|
||||
pub fn calibration_matrix(c: &Calibration) -> Option<[[f32; 3]; 3]> {
|
||||
let prim = c.primaries();
|
||||
if prim.iter().all(|(h, s)| *h == 0.0 && *s == 0.0) {
|
||||
return None;
|
||||
}
|
||||
let mut p = [[0.0f64; 3]; 3];
|
||||
for (i, (hue, sat)) in prim.iter().enumerate() {
|
||||
let mut e = [0.0f32; 3];
|
||||
e[i] = 1.0;
|
||||
let [l, ch, h] = lab_to_lch(oklab_from_2020(e));
|
||||
let h2 = h + (hue.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_HUE;
|
||||
let c2 = ch * (1.0 + (sat.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_SAT);
|
||||
let q = oklab_to_2020(lch_to_lab([l, c2, h2]));
|
||||
for (r, row) in p.iter_mut().enumerate() {
|
||||
row[i] = q[r] as f64;
|
||||
}
|
||||
}
|
||||
let m = Mat3(p);
|
||||
let k = m.inverse()?.apply([1.0, 1.0, 1.0]);
|
||||
Some(std::array::from_fn(|r| std::array::from_fn(|col| (p[r][col] * k[col]) as f32)))
|
||||
}
|
||||
|
||||
/// Shadows-tint strength at ±100: the green channel's relative change in deep shadows.
|
||||
pub const SHADOW_TINT: f32 = 0.3;
|
||||
|
||||
/// Calibration in scene-linear light (before tone mapping): the primaries matrix, then the shadows
|
||||
/// tint (green ↔ magenta, weighted towards dark tones, luminance kept).
|
||||
#[inline]
|
||||
pub fn calibrate(c: [f32; 3], m: Option<&[[f32; 3]; 3]>, shadow_tint: f32) -> [f32; 3] {
|
||||
let mut c = c;
|
||||
if let Some(m) = m {
|
||||
c = std::array::from_fn(|r| (m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2]).max(0.0));
|
||||
}
|
||||
if shadow_tint != 0.0 {
|
||||
let y0 = lightcraft_color::luminance_2020(c);
|
||||
let w = 1.0 - smooth(-5.0, -0.5, (y0.max(1e-7) / 0.18).log2());
|
||||
c[1] *= (1.0 - SHADOW_TINT * shadow_tint * w).max(0.0);
|
||||
let y1 = lightcraft_color::luminance_2020(c).max(1e-9);
|
||||
c = c.map(|v| v * y0 / y1);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
|
|
@ -219,6 +270,36 @@ mod tests {
|
|||
assert!((cr - cr0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_keeps_white_and_moves_primaries() {
|
||||
assert!(calibration_matrix(&Calibration::default()).is_none());
|
||||
let cal = Calibration { red_hue: 60.0, blue_sat: -50.0, ..Default::default() };
|
||||
let m = calibration_matrix(&cal).unwrap();
|
||||
let grey = calibrate([0.3, 0.3, 0.3], Some(&m), 0.0);
|
||||
assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-4), "{grey:?}");
|
||||
// red rotates in hue, blue loses chroma
|
||||
let red = [0.4, 0.05, 0.03];
|
||||
let h0 = lab_to_lch(oklab_from_2020(red))[2];
|
||||
let h1 = lab_to_lch(oklab_from_2020(calibrate(red, Some(&m), 0.0)))[2];
|
||||
assert!(h1 - h0 > 0.05, "{h0} -> {h1}");
|
||||
let blue = [0.03, 0.05, 0.4];
|
||||
let c0 = lab_to_lch(oklab_from_2020(blue))[1];
|
||||
let c1 = lab_to_lch(oklab_from_2020(calibrate(blue, Some(&m), 0.0)))[1];
|
||||
assert!(c1 < c0 * 0.97, "{c0} -> {c1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_tint_targets_shadows() {
|
||||
let dark = calibrate([0.01, 0.01, 0.01], None, 1.0);
|
||||
let bright = calibrate([0.8, 0.8, 0.8], None, 1.0);
|
||||
assert!(dark[1] < dark[0] * 0.85, "magenta shadows: {dark:?}");
|
||||
assert!((bright[1] - bright[0]).abs() < 1e-3, "{bright:?}");
|
||||
let y = |c: [f32; 3]| lightcraft_color::luminance_2020(c);
|
||||
assert!((y(dark) - 0.01).abs() < 1e-5);
|
||||
let green = calibrate([0.01, 0.01, 0.01], None, -1.0);
|
||||
assert!(green[1] > green[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grading_tints_shadows_only() {
|
||||
let mut s = DevelopSettings::default();
|
||||
|
|
|
|||
|
|
@ -145,6 +145,9 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
|
|||
pub struct FinishParams {
|
||||
pub tone: ToneMap,
|
||||
pub ops: ColorOps,
|
||||
/// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1).
|
||||
pub calib: Option<[[f32; 3]; 3]>,
|
||||
pub shadow_tint: f32,
|
||||
/// Tone curves (parametric ∘ point, per channel) on encoded values, 1024 entries each.
|
||||
pub curves: Option<[Lut1; 3]>,
|
||||
pub vig: Option<Vig>,
|
||||
|
|
@ -187,7 +190,10 @@ impl FinishParams {
|
|||
let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * px_per_long as f32;
|
||||
((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");
|
||||
FinishParams {
|
||||
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 info.raw {
|
||||
ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks)
|
||||
} else {
|
||||
|
|
@ -330,6 +336,11 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
|||
c = c.map(|v| v * g);
|
||||
}
|
||||
|
||||
// --- calibration (scene linear, before the tone map)
|
||||
if fp.calib.is_some() || fp.shadow_tint != 0.0 {
|
||||
c = crate::colorops::calibrate(c, fp.calib.as_ref(), fp.shadow_tint);
|
||||
}
|
||||
|
||||
// --- tone map on luminance, highlight desaturation
|
||||
let yl = luminance_2020(c);
|
||||
let o = tone.apply(yl);
|
||||
|
|
|
|||
|
|
@ -230,3 +230,32 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() {
|
|||
let d = max_diff(&a, &b);
|
||||
assert!(d <= 2, "max difference {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_shifts_colours_but_keeps_greys() {
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let req = RenderRequest::fit(64, 8);
|
||||
// a grey ramp is untouched by the primaries
|
||||
let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]);
|
||||
let mut s = DevelopSettings::default();
|
||||
s.calibration.red_hue = 80.0;
|
||||
s.calibration.green_sat = -60.0;
|
||||
let a = render(&grey, &info, &DevelopSettings::default(), &req).image;
|
||||
let b = render(&grey, &info, &s, &req).image;
|
||||
assert!(max_diff(&a, &b) <= 1);
|
||||
// a red patch rotates towards yellow (green rises relative to blue)
|
||||
let red = Rgb32f::filled(16, 16, [0.3, 0.04, 0.03]);
|
||||
let r0 = render(&red, &info, &DevelopSettings::default(), &RenderRequest::fit(16, 16)).image.data[0];
|
||||
let r1 = render(&red, &info, &s, &RenderRequest::fit(16, 16)).image.data[0];
|
||||
assert!(r1[1] as i32 - r1[2] as i32 > r0[1] as i32 - r0[2] as i32 + 5, "{r0:?} -> {r1:?}");
|
||||
// shadows tint: magenta (+) lowers green in a dark grey
|
||||
let dark = Rgb32f::filled(16, 16, [0.01, 0.01, 0.01]);
|
||||
s = DevelopSettings::default();
|
||||
s.calibration.shadows_tint = 100.0;
|
||||
let d = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0];
|
||||
assert!(d[1] + 3 < d[0], "{d:?}");
|
||||
// the section toggle disables it
|
||||
s.set_section_enabled("calibration", false);
|
||||
let off = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0];
|
||||
assert!(off[1].abs_diff(off[0]) <= 1, "{off:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
// Lightroom Classic's Calibration panel (the cloud app hides it): last, like there.
|
||||
section(app, ui, &d, "calibration", "Calibration", |app, ui, d| {
|
||||
sub_title(ui, "Shadows");
|
||||
control(app, ui, d, "calibration.shadowsTint", true);
|
||||
for (title, k) in [("Red Primary", "red"), ("Green Primary", "green"), ("Blue Primary", "blue")] {
|
||||
sub_title(ui, title);
|
||||
control(app, ui, d, &format!("calibration.{k}Hue"), true);
|
||||
control(app, ui, d, &format!("calibration.{k}Sat"), true);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
ui.add_space(40.0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`).
|
|||
| `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | the switch only; lens profiles are our own |
|
||||
| `LensManualDistortionAmount`, `VignetteAmount`, `VignetteMidpoint` | `optics.distortion`, `optics.vignetting`, `optics.vignetting_midpoint` | |
|
||||
| `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | |
|
||||
| `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 |
|
||||
| `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | |
|
||||
| `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided |
|
||||
| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop |
|
||||
|
|
|
|||
Loading…
Reference in New Issue