diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 6ac3544ee..a3fe36aba 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -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; diff --git a/crates/raw/src/kelvin_builtin.rs b/crates/raw/src/kelvin_builtin.rs new file mode 100644 index 000000000..1ce4ddfc4 --- /dev/null +++ b/crates/raw/src/kelvin_builtin.rs @@ -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 { + 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")); + } +} diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index 2d78151d5..4db0a521e 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -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", diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index c27a5a89b..d8767e947 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -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; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index af77366aa..62c9a2505 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -301,7 +301,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 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, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3ed05a34f..55ed1723e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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` | |