Merge PR #2006: LR-EDIT-COLOR-TEMP: Kelvin white balance for the Nikon Z5II from built-in presets

crates/raw/src/kelvin_presets.rs: covered_models keeps the Z5II asserts (#2006) and the Canon cases (#2009).
docs/parity-checklist.md: LR-EDIT-COLOR-TEMP row keeps both sides' preset clauses.
This commit is contained in:
aiongg
2026-10-22 02:41:12 +00:00
6 changed files with 105 additions and 7 deletions
+1
View File
@@ -653,6 +653,7 @@ mod edited_tests {
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
("NIKON CORPORATION NIKON Z5_2", "NEF", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
+92
View File
@@ -0,0 +1,92 @@
//! Built-in white-balance presets for cameras whose raws carry neither colour matrices nor a preset table, feeding
//! the Kelvin scale of [`crate::kelvin_presets`].
//!
//! Nikon NEFs store only the as-shot multipliers (`WB_RBLevels`) and, in the maker note, the colour temperature the
//! camera states for the white-balance setting it shot with (`ColorTemperatureAuto`, `0x004f`: tag name from the
//! ExifTool Nikon tag documentation, read with exiftool run as a black-box tool on the files). A body's points are
//! the as-shot multipliers of its own photos shot at one preset each, placed at the temperature the camera itself
//! wrote for that preset. Presets off the daylight locus (fluorescent, flash) and shots with a fine-tune shift are
//! left out.
//!
//! Sources: own photos of each body listed (a reporter's samples, never committed), the camera's own stated
//! temperatures, and the ExifTool Nikon tag-name documentation (prose). No matrices or third-party data.
use crate::kelvin_presets::KelvinPresets;
/// One body: its make's first word, its Exif model without the make, and five (Kelvin, R/G, B/G) points.
struct Builtin {
make: &'static str,
model: &'static str,
points: [(f32, f32, f32); 5],
}
const BUILTINS: &[Builtin] = &[
// Nikon Z5II (issue emmertex/lightcraft-classic#157): Incandescent 3000 K, Direct sunlight 5210 K, the Kelvin
// setting at 5500 K, Cloudy 5990 K, Shade 8000 K (the temperatures the camera writes in `ColorTemperatureAuto`).
// Natural Auto (the camera wrote 5030 K) reads back at 5036 K; Flash (5410 K nominal) at about 6500 K.
Builtin {
make: "NIKON",
model: "Z5_2",
points: [
(3000.0, 1.138_671_9, 2.083_984_4),
(5210.0, 1.726_562_5, 1.335_937_5),
(5500.0, 1.771_484_4, 1.281_25),
(5990.0, 1.839_843_8, 1.212_890_6),
(8000.0, 2.097_656_3, 1.089_843_8),
],
},
];
fn find_builtin(make: Option<&str>, model: &str) -> Option<&'static Builtin> {
let model = model.trim();
BUILTINS.iter().find(|b| {
let n = b.make.len();
let bare = match (model.get(..n), model.get(n..)) {
(Some(prefix), Some(rest)) if prefix.eq_ignore_ascii_case(b.make) => rest.trim_start(),
_ => model,
};
make.is_none_or(|m| m.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case(b.make))) && bare.eq_ignore_ascii_case(b.model)
})
}
/// Whether this Exif make and model has built-in presets.
pub fn covers(make: Option<&str>, model: &str) -> bool {
find_builtin(make, model).is_some()
}
/// The built-in presets of this Exif make and model, if any.
pub fn find(make: Option<&str>, model: &str) -> Option<KelvinPresets> {
let b = find_builtin(make, model)?;
KelvinPresets::new(b.points.map(|(k, r, b)| (k, [r, 1.0, b])))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_builtin_makes_a_curve() {
for b in BUILTINS {
assert!(find(Some(b.make), b.model).is_some(), "{} {}", b.make, b.model);
}
}
#[test]
fn z5ii_reads_its_own_shots_near_the_camera_temperatures() {
let p = find(Some("NIKON CORPORATION"), "NIKON Z5_2").unwrap();
// Natural Auto, not on the curve: the camera wrote 5030 K for it
let (t, tint) = p.temp_tint([1.695_312_5, 1.0, 1.373_046_9]).unwrap();
assert!((1e6 / t - 1e6 / 5030.0).abs() < 10.0 && tint.abs() < 5.0, "{t} {tint}");
// Flash (nominally 5410 K) sits near the curve on the daylight side
let (t, tint) = p.temp_tint([1.916_015_6, 1.0, 1.164_062_5]).unwrap();
assert!((5000.0..7500.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}");
// the 5990 K Kelvin shot with a green-magenta fine-tune shift: daylight, clearly tinted
let (t, tint) = p.temp_tint([1.531_25, 1.0, 1.074_218_8]).unwrap();
assert!((4000.0..7500.0).contains(&t) && tint.abs() > 20.0, "{t} {tint}");
}
#[test]
fn matching() {
assert!(covers(Some("NIKON CORPORATION"), "NIKON Z5_2") && covers(None, "Z5_2") && covers(Some("Nikon"), "nikon z5_2"));
assert!(!covers(Some("NIKON CORPORATION"), "NIKON Z5") && !covers(Some("SONY"), "Z5_2") && !covers(None, "") && !covers(None, "NIK"));
}
}
+9 -5
View File
@@ -56,8 +56,12 @@ const MODELS: &[(&str, &str)] = &[
];
/// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour
/// matrices of its own or measured: see [`crate::spectral::find`], which comes first).
/// matrices of its own or measured: see [`crate::spectral::find`], which comes first), from the file's presets or
/// built-in ones ([`crate::kelvin_builtin`]).
pub fn covers(make: Option<&str>, model: &str) -> bool {
if crate::kelvin_builtin::covers(make, model) {
return true;
}
let model = model.trim();
let brand = make.and_then(|m| m.split_whitespace().next());
MODELS.iter().any(|(b, m)| {
@@ -72,10 +76,9 @@ pub fn covers(make: Option<&str>, model: &str) -> bool {
/// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`).
pub fn covers_camera(camera: &str) -> bool {
match camera.split_once(char::is_whitespace) {
Some((make, model)) => covers(Some(make), model),
None => covers(None, camera),
}
let words: Vec<&str> = camera.split_whitespace().collect();
// the make can be several words ("NIKON CORPORATION"), so try every split, as `spectral::find_camera` does
(1..words.len()).any(|i| covers(Some(&words[..i].join(" ")), &words[i..].join(" "))) || covers(None, camera)
}
/// Most points a curve keeps (Canon writes about seven at a stated temperature, Sony five).
@@ -294,6 +297,7 @@ mod tests {
#[test]
fn covered_models() {
assert!(covers_camera("NIKON CORPORATION NIKON Z5_2") && covers_camera("NIKON Z5_2") && !covers_camera("NIKON CORPORATION NIKON Z6"));
for c in [
"SONY ILCA-99M2",
"SONY DSC-RX10",
+1
View File
@@ -34,6 +34,7 @@ pub mod gaintable;
pub mod highlight;
#[cfg(feature = "jxl")]
mod jxl;
pub mod kelvin_builtin;
pub mod kelvin_presets;
pub mod lens_metadata;
pub mod ljpeg;
+1 -1
View File
@@ -301,7 +301,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
active_area: Rect::new(0, 0, active_w, h),
crop,
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
color: ColorData::default(),
color: ColorData { kelvin_presets: crate::kelvin_builtin::find(Some(&make), &model), ..Default::default() },
wb_multipliers: wb,
linearized: false,
pixel_aspect,
+1 -1
View File
@@ -193,7 +193,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |
| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | |
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking |
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). |
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). |
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests |
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | |