Share Lightroom's saturation bands across ours at measured strengths

Photographs opened with an earlier Lightroom edit now import its HSL
saturation as fitted against the library's own Lightroom 6 exports, rather
than one band to one band.

Measured on two looks' exports and their raws (darkroom-lrfit, hsl_map_fit),
by encoded hue: Lightroom's saturation bands act about 45 degrees either
side on our wheel, wider than ours, and not all at our strength. Each is now
shared between two or three of our bands — Aqua mostly cyan and azure, where
skies are; Blue mostly blue and violet; Orange, where skin is, at about 0.4
of its value. Values add when two of Lightroom's bands share one of ours.
On the measured skies the import now lifts muted sky blues about 1.9× against
Lightroom's 2.1×, where it gave 1.15×. Hue and luminance still go one band to
the band of the same hue; they were not measured.
This commit is contained in:
2026-10-04 05:28:33 -04:00
parent 3f8f909e41
commit 75e12441fd
3 changed files with 165 additions and 39 deletions
+160 -35
View File
@@ -147,12 +147,21 @@ const MAPPINGS: &[Mapping] = &[
},
// TRACES: FR-DEV-6
// Lightroom's HSL panel: eight bands, each ±100 for hue, saturation and
// luminance, onto the colour mixer's twelve. Lightroom's bands sit where
// ours do except two, matched to the nearest of ours by hue: Aqua (180°)
// is our cyan, Purple (270°) our violet; chartreuse, spring, azure and
// rose have no Lightroom counterpart and are left alone. One for one, as
// a first translation — the band widths differ, and `lr-fit`'s
// measurement against Lightroom's own output may yet scale these.
// luminance, onto the colour mixer's twelve.
//
// Hue and luminance go to the band of the same hue, one for one: Aqua
// (180°) is our cyan, Purple (270°) our violet.
//
// Saturation is measured. Fitted against the library's Lightroom 6 exports
// of two looks and their raws (darkroom-lrfit, hsl_map_fit), Lightroom's
// saturation bands are about 45° wide either side on our hue wheel, wider
// than ours, and do not all have our strength: each is shared between
// two or three of our bands with the factors below. Aqua sits at 187°
// and reaches into azure, where skies are; Blue at 251° reaches violet;
// Orange, where skin is, carries only ~0.4 — Lightroom's Orange is
// gentle. Red, Purple and Magenta barely appear in those exports and
// take the common gain; Green is capped where its few pixels would push
// it further. Values add when two Lightroom bands share one of ours.
Mapping {
crs: "HueAdjustmentRed",
op: "colour_mixer",
@@ -163,7 +172,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentRed",
op: "colour_mixer",
param: "red_sat",
convert: Convert::Direct,
convert: Convert::Scale(1.02),
},
Mapping {
crs: "SaturationAdjustmentRed",
op: "colour_mixer",
param: "orange_sat",
convert: Convert::Scale(0.34),
},
Mapping {
crs: "SaturationAdjustmentRed",
op: "colour_mixer",
param: "rose_sat",
convert: Convert::Scale(0.34),
},
Mapping {
crs: "LuminanceAdjustmentRed",
@@ -181,7 +202,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentOrange",
op: "colour_mixer",
param: "orange_sat",
convert: Convert::Direct,
convert: Convert::Scale(0.39),
},
Mapping {
crs: "SaturationAdjustmentOrange",
op: "colour_mixer",
param: "red_sat",
convert: Convert::Scale(0.13),
},
Mapping {
crs: "SaturationAdjustmentOrange",
op: "colour_mixer",
param: "yellow_sat",
convert: Convert::Scale(0.13),
},
Mapping {
crs: "LuminanceAdjustmentOrange",
@@ -199,7 +232,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentYellow",
op: "colour_mixer",
param: "yellow_sat",
convert: Convert::Direct,
convert: Convert::Scale(0.87),
},
Mapping {
crs: "SaturationAdjustmentYellow",
op: "colour_mixer",
param: "orange_sat",
convert: Convert::Scale(0.29),
},
Mapping {
crs: "SaturationAdjustmentYellow",
op: "colour_mixer",
param: "chartreuse_sat",
convert: Convert::Scale(0.29),
},
Mapping {
crs: "LuminanceAdjustmentYellow",
@@ -217,7 +262,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentGreen",
op: "colour_mixer",
param: "green_sat",
convert: Convert::Direct,
convert: Convert::Scale(1.2),
},
Mapping {
crs: "SaturationAdjustmentGreen",
op: "colour_mixer",
param: "chartreuse_sat",
convert: Convert::Scale(0.4),
},
Mapping {
crs: "SaturationAdjustmentGreen",
op: "colour_mixer",
param: "spring_sat",
convert: Convert::Scale(0.4),
},
Mapping {
crs: "LuminanceAdjustmentGreen",
@@ -235,7 +292,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentAqua",
op: "colour_mixer",
param: "cyan_sat",
convert: Convert::Direct,
convert: Convert::Scale(0.9),
},
Mapping {
crs: "SaturationAdjustmentAqua",
op: "colour_mixer",
param: "azure_sat",
convert: Convert::Scale(0.51),
},
Mapping {
crs: "SaturationAdjustmentAqua",
op: "colour_mixer",
param: "spring_sat",
convert: Convert::Scale(0.19),
},
Mapping {
crs: "LuminanceAdjustmentAqua",
@@ -253,7 +322,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentBlue",
op: "colour_mixer",
param: "blue_sat",
convert: Convert::Direct,
convert: Convert::Scale(0.75),
},
Mapping {
crs: "SaturationAdjustmentBlue",
op: "colour_mixer",
param: "violet_sat",
convert: Convert::Scale(0.56),
},
Mapping {
crs: "SaturationAdjustmentBlue",
op: "colour_mixer",
param: "azure_sat",
convert: Convert::Scale(0.09),
},
Mapping {
crs: "LuminanceAdjustmentBlue",
@@ -271,7 +352,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentPurple",
op: "colour_mixer",
param: "violet_sat",
convert: Convert::Direct,
convert: Convert::Scale(1.26),
},
Mapping {
crs: "SaturationAdjustmentPurple",
op: "colour_mixer",
param: "blue_sat",
convert: Convert::Scale(0.42),
},
Mapping {
crs: "SaturationAdjustmentPurple",
op: "colour_mixer",
param: "magenta_sat",
convert: Convert::Scale(0.42),
},
Mapping {
crs: "LuminanceAdjustmentPurple",
@@ -289,7 +382,19 @@ const MAPPINGS: &[Mapping] = &[
crs: "SaturationAdjustmentMagenta",
op: "colour_mixer",
param: "magenta_sat",
convert: Convert::Direct,
convert: Convert::Scale(1.05),
},
Mapping {
crs: "SaturationAdjustmentMagenta",
op: "colour_mixer",
param: "violet_sat",
convert: Convert::Scale(0.35),
},
Mapping {
crs: "SaturationAdjustmentMagenta",
op: "colour_mixer",
param: "rose_sat",
convert: Convert::Scale(0.35),
},
Mapping {
crs: "LuminanceAdjustmentMagenta",
@@ -466,7 +571,12 @@ pub fn read_xmp(text: &str) -> Result<Import, ImportError> {
// Out-of-range values are left as they are: `EditGraph::set_param`
// clamps when the preset is applied, and clamping here as well would
// mean two places to be wrong about a range.
params.insert((mapping.op.to_string(), mapping.param.to_string()), value);
//
// Added rather than set: one of Lightroom's HSL bands is shared
// between two or three of ours, and two of its bands can share one.
*params
.entry((mapping.op.to_string(), mapping.param.to_string()))
.or_insert(0.0) += value;
}
let skipped = KNOWN_UNSUPPORTED
@@ -569,9 +679,15 @@ mod tests {
let import = read_embedded(&file).expect("an edit");
let p = import.preset.params();
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
assert_eq!(get("colour_mixer", "violet_sat"), Some(23.0));
// Saturation is shared out by the measured factors; values add.
let near = |got: Option<f32>, want: f32| {
let got = got.expect("set");
assert!((got - want).abs() < 1e-3, "{got} vs {want}");
};
near(get("colour_mixer", "blue_sat"), 58.0 * 0.75 + 23.0 * 0.42);
near(get("colour_mixer", "cyan_sat"), 50.0 * 0.9);
near(get("colour_mixer", "azure_sat"), 50.0 * 0.51 + 58.0 * 0.09);
near(get("colour_mixer", "violet_sat"), 58.0 * 0.56 + 23.0 * 1.26);
assert_eq!(get("colour_mixer", "red_hue"), Some(-5.0));
assert_eq!(get("colour_mixer", "green_lum"), Some(7.0));
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0 * 1.4));
@@ -594,8 +710,11 @@ mod tests {
let import = read_embedded(&bytes).expect("Lightroom's edit");
let p = import.preset.params();
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
// The house look's sky: Aqua and Blue land in cyan, azure and blue.
for (param, at_least) in [("cyan_sat", 40.0), ("azure_sat", 25.0), ("blue_sat", 40.0)] {
let v = get("colour_mixer", param).unwrap_or(0.0);
assert!(v >= at_least, "{param} {v}");
}
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0 * 1.4));
}
@@ -628,22 +747,28 @@ mod tests {
}
#[test]
fn no_two_mappings_claim_the_same_key_or_the_same_target() {
let mut keys: Vec<&str> = MAPPINGS.iter().map(|m| m.crs).collect();
keys.sort_unstable();
let before = keys.len();
keys.dedup();
assert_eq!(before, keys.len(), "two mappings read the same crs key");
fn no_two_mappings_repeat_a_key_and_target() {
// A saturation band may be shared between several of ours, and two of
// Lightroom's may share one of ours (their values add); but the same
// key written twice to the same target would count it twice.
let mut pairs: Vec<(&str, &str, &str)> =
MAPPINGS.iter().map(|m| (m.crs, m.op, m.param)).collect();
pairs.sort_unstable();
let before = pairs.len();
pairs.dedup();
assert_eq!(before, pairs.len(), "a key is written twice to one target");
let mut targets: Vec<(&str, &str)> = MAPPINGS.iter().map(|m| (m.op, m.param)).collect();
targets.sort_unstable();
let before = targets.len();
targets.dedup();
assert_eq!(
before,
targets.len(),
"two mappings write the same parameter"
);
// Only the HSL saturation bands are shared; every other key has one
// home, so a slip in the table cannot fan a slider out unnoticed.
let mut single: Vec<&str> = MAPPINGS
.iter()
.map(|m| m.crs)
.filter(|k| !k.starts_with("SaturationAdjustment"))
.collect();
single.sort_unstable();
let before = single.len();
single.dedup();
assert_eq!(before, single.len(), "two mappings read the same crs key");
}
#[test]
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -942,7 +942,8 @@ mod tests {
dr_pipeline::ParamId("blue_sat"),
)
};
assert_eq!(blue(), Some(58.0));
// Blue is shared across our bands at measured factors; blue takes 0.75.
assert_eq!(blue(), Some(58.0 * 0.75));
assert!(!s.adopt_earlier_edit(), "taken: a second call does nothing");
assert!(!s.graph.is_neutral());
s.undo();