diff --git a/Cargo.lock b/Cargo.lock index b1221cbe..b631d259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2986,6 +2986,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-geom", "lightcraft-raster", + "lightcraft-raw", "lightcraft-scenes", "proptest", "rayon", diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 8e14c68f..2bfa1ae5 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -307,9 +307,18 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< } let (temp, tint) = xy_to_temp_tint(xy); let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; + // White balance re-evaluates the file's own colour model (when it has one and no + // file-local look matrix sits on top of it) + let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; - return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone, mattes })); + return Ok(( + img, + SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes }, + )); } let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; drop(bytes); @@ -800,6 +809,28 @@ mod tests { assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13)); } + /// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the + /// pipeline the file's colour tags (without the profile look, applied at load) and the white + /// the pixels were developed for, and a custom white balance renders a neutral differently + /// from the Bradford adaptation it replaces. + #[test] + fn dng_white_balance_uses_the_camera_colour_model() { + use lightcraft_pipeline::{RenderRequest, render}; + let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let raw = lightcraft_raw::decode(&dng).unwrap(); + let (img, info) = load_bytes(&dng, 64).unwrap(); + let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix"); + assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw)); + assert!(cc.tags.profile.is_empty()); + assert_eq!(cc.tags.color_matrix, raw.color.color_matrix); + let mut s = lightcraft_develop::DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0); + let req = RenderRequest::fit(32, 32); + let camera = render(&img, &info, &s, &req).image; + let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image; + assert_ne!(camera.data, adapted.data); + } + /// Apple ProRAW: like Lightroom Classic, the default render ignores the file's /// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel /// changes nothing. diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 56ad62d4..473504a6 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 15; +pub const RENDER_CACHE_VERSION: u64 = 16; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index 27d77816..c8219f02 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -13,6 +13,7 @@ lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } +lightcraft-raw = { workspace = true } rayon = { workspace = true } serde = { workspace = true } diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs index 45100a1e..3455fff2 100644 --- a/crates/pipeline/src/auto.rs +++ b/crates/pipeline/src/auto.rs @@ -120,10 +120,17 @@ pub fn auto_wb(src: &Rgb32f, info: &SourceInfo) -> (f64, f64) { return (info.as_shot_temp, info.as_shot_tint); } let avg = acc.map(|v| v / wsum); - let xyz = REC2020.to_xyz().apply(avg); - let shot = lightcraft_color::cct::temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint); - let seen = bradford(REC2020.white, shot).apply(xyz); - let (t, tint) = xy_to_temp_tint(Xy::from_xyz(seen)); + // the white that makes `avg` neutral, through the same model the white balance renders with + // (`local::wb_matrix_for`): the camera's own colour model, else a Bradford adaptation + let camera = info.camera_color.as_deref().filter(|_| info.raw && !info.relative_wb); + let seen = match camera.and_then(|cc| lightcraft_raw::color::neutral_white(&cc.tags, cc.developed_for, avg)) { + Some(white) => white, + None => { + let shot = lightcraft_color::cct::temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint); + Xy::from_xyz(bradford(REC2020.white, shot).apply(REC2020.to_xyz().apply(avg))) + } + }; + let (t, tint) = xy_to_temp_tint(seen); (t.clamp(2000.0, 50000.0).round(), tint.clamp(-150.0, 150.0).round()) } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 76e80ed2..84f5bd98 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -54,6 +54,17 @@ use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows}; pub use tone::ToneMap; +/// A raw source's own colour model, for white balance: like Lightroom, a white balance +/// re-evaluates the camera's colour matrices at the chosen white (camera-space white balance, DNG +/// spec ch. 6) instead of adapting the as-shot rendering ([`local::wb_matrix_for`]). +#[derive(Debug, PartialEq)] +pub struct CameraColor { + /// The file's colour tags (its profile look left out: it is applied at load). + pub tags: lightcraft_raw::ColorData, + /// The white the source pixels were developed for. + pub developed_for: lightcraft_color::Xy, +} + /// Facts about the source the settings are interpreted against. #[derive(Clone, Debug, PartialEq)] pub struct SourceInfo { @@ -66,6 +77,9 @@ pub struct SourceInfo { pub as_shot_tint: f64, /// No measured camera illuminant: WB adjustments are relative to the camera's rendered look. pub relative_wb: bool, + /// The camera's colour model (raw files with a colour matrix); `None`: white balance adapts + /// the developed pixels (Bradford, linear Rec.2020). + pub camera_color: Option>, pub camera_tone: Option, /// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them. pub mattes: Option>, @@ -73,7 +87,16 @@ pub struct SourceInfo { impl Default for SourceInfo { fn default() -> Self { - Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, relative_wb: false, camera_tone: None, mattes: None } + Self { + raw: false, + as_shot_temp: 6500.0, + as_shot_tint: 0.0, + lens: None, + relative_wb: false, + camera_color: None, + camera_tone: None, + mattes: None, + } } } diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 898e69cf..fd7fefd3 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -22,13 +22,21 @@ pub fn white_balance(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings) { wb_gain(img, info, s, 1.0); } -/// The white-balance matrix (linear Rec.2020, luminance-preserving) for the settings, or `None` -/// when the as-shot white is kept. +/// The white-balance matrix (linear Rec.2020) for the settings, or `None` when the as-shot white +/// is kept. A raw source with its camera colour model ([`crate::CameraColor`]) is re-developed for +/// the new white in camera space, as Lightroom does (so a neutral under the chosen white renders +/// neutral and saturated colours move as the camera's matrices say); other sources are adapted +/// with Bradford, luminance-preserving. pub fn wb_matrix_for(info: &SourceInfo, s: &DevelopSettings) -> Option<[[f32; 3]; 3]> { let (t, tint) = effective_wb(info, s); if (t - info.as_shot_temp).abs() < 1e-6 && (tint - info.as_shot_tint).abs() < 1e-6 { return None; } + if let Some(cc) = info.camera_color.as_deref().filter(|_| info.raw && !info.relative_wb) + && let Some(m) = lightcraft_raw::color::rebalance(&cc.tags, cc.developed_for, temp_tint_to_xy(t, tint)) + { + return Some(m.to_f32()); + } let set = wb_matrix(&REC2020, temp_tint_to_xy(t, tint)); let shot = wb_matrix(&REC2020, temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint)); let m = set.mul(&shot.inverse().unwrap_or(lightcraft_color::Mat3::IDENTITY)); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 697a0f97..f572b21d 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -369,6 +369,8 @@ fn tint_negative_is_green_and_positive_is_magenta() { SourceInfo::default(), SourceInfo { raw: true, relative_wb: true, ..Default::default() }, SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() }, + // camera-space white balance: the same sign through the camera's own colour model + SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, camera_color: Some(camera_color(4200.0, 15.0)), ..Default::default() }, ] { let mut s = DevelopSettings::default(); let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8); @@ -384,3 +386,52 @@ fn tint_negative_is_green_and_positive_is_magenta() { } } } + +/// A dual-illuminant (A / D65) camera colour model, its pixels developed for `temp` / `tint`. +fn camera_color(temp: f64, tint: f64) -> std::sync::Arc { + use lightcraft_color::Mat3; + let tags = lightcraft_raw::ColorData { + illuminant: [17, 21], + color_matrix: [ + Some(Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])), + Some(Mat3([[0.7, 0.3, -0.1], [-0.35, 1.3, 0.1], [0.05, -0.2, 1.0]])), + ], + ..Default::default() + }; + std::sync::Arc::new(crate::CameraColor { tags, developed_for: lightcraft_color::cct::temp_tint_to_xy(temp, tint) }) +} + +/// Camera-space white balance: a raw source with its colour model is re-developed for the chosen +/// white, so a grey card lit by that white renders neutral. Without the model, or with a relative +/// white balance, the developed pixels are adapted (Bradford, neutral luminance kept). +#[test] +fn custom_white_balance_redevelops_in_camera_space() { + use crate::local::wb_matrix_for; + use lightcraft_color::cct::temp_tint_to_xy; + use lightcraft_develop::WbMode; + let cc = camera_color(5500.0, 0.0); + let info = SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, camera_color: Some(cc.clone()), ..Default::default() }; + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0); + // a grey card lit by 3200 K / +10, as the camera records it, developed for the as-shot white + let a = lightcraft_raw::color::camera_transform_of(&cc.tags, cc.developed_for); + let n = lightcraft_raw::color::camera_neutral(&cc.tags, temp_tint_to_xy(3200.0, 10.0)); + let card = a.matrix.apply(std::array::from_fn(|i| n[i] * a.wb[i] as f64)).map(|v| v as f32); + let apply = |m: [[f32; 3]; 3], c: [f32; 3]| -> [f32; 3] { std::array::from_fn(|r| m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2]) }; + let m = wb_matrix_for(&info, &s).unwrap(); + let out = apply(m, card); + assert!(out.iter().all(|v| (v / out[1] - 1.0).abs() < 1e-4), "{card:?} -> {out:?}"); + // the adaptation path for sources without a colour model, and for relative white balance + let luma = |c: [f32; 3]| lightcraft_color::luminance_2020(c); + for other in [SourceInfo { camera_color: None, ..info.clone() }, SourceInfo { relative_wb: true, ..info.clone() }] { + let b = wb_matrix_for(&other, &s).unwrap(); + assert_ne!(b, m); + assert!((luma(apply(b, [1.0; 3])) - 1.0).abs() < 1e-4); + } + // the white-balance picker / auto white balance invert the same model: the card names its white + let picked = crate::auto::auto_wb(&Rgb32f::filled(4, 4, card), &info); + assert_eq!(picked, (3200.0, 10.0)); + // the as-shot white needs nothing + s.wb.mode = WbMode::AsShot; + assert!(wb_matrix_for(&info, &s).is_none()); +} diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index 050d21ee..d738bb1f 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -181,7 +181,11 @@ pub struct CameraTransform { /// Camera transform for white `wb_xy`: `rec2020 = matrix · (wb ⊙ camera)`. pub fn camera_transform(raw: &RawImage, wb_xy: Xy) -> CameraTransform { - let color = &raw.color; + camera_transform_of(&raw.color, wb_xy) +} + +/// [`camera_transform`] from the colour tags alone. +pub fn camera_transform_of(color: &ColorData, wb_xy: Xy) -> CameraTransform { let fallback = !has_matrix(color); let wb = wb_multipliers(color, wb_xy); let to_d50 = camera_to_xyz_d50(color, wb_xy); @@ -192,6 +196,34 @@ pub fn camera_transform(raw: &RawImage, wb_xy: Xy) -> CameraTransform { CameraTransform { matrix, wb: wb.map(|v| v as f32), white_xy: wb_xy, matrix_is_fallback: fallback, baseline_exposure: color.baseline_exposure } } +/// Linear Rec.2020 → linear Rec.2020 matrix that re-develops pixels developed with the camera +/// transform for white `from` as if developed for white `to`: the camera's own colour model is +/// evaluated at the new white (camera-space white balance, DNG spec ch. 6), as Lightroom does, so +/// a neutral lit by `to` comes out neutral. `None` when the tags have no colour matrix or the +/// transform is singular. +pub fn rebalance(color: &ColorData, from: Xy, to: Xy) -> Option { + if !has_matrix(color) { + return None; + } + let (a, b) = (camera_transform_of(color, from), camera_transform_of(color, to)); + let ratio = Mat3::diag(f64::from(b.wb[0] / a.wb[0]), f64::from(b.wb[1] / a.wb[1]), f64::from(b.wb[2] / a.wb[2])); + Some(b.matrix.mul(&ratio).mul(&a.matrix.inverse()?)) +} + +/// The white under which `rgb` (linear Rec.2020, developed with the camera transform for white +/// `developed_for`) is a neutral: the white [`rebalance`] must re-develop for to make it neutral +/// (white-balance picker, auto white balance). `None` without a colour matrix, or when `rgb` maps +/// to a non-positive camera value. +pub fn neutral_white(color: &ColorData, developed_for: Xy, rgb: [f64; 3]) -> Option { + if !has_matrix(color) { + return None; + } + let a = camera_transform_of(color, developed_for); + let balanced = a.matrix.inverse()?.apply(rgb); + let camera: [f64; 3] = std::array::from_fn(|i| balanced[i] / f64::from(a.wb[i])); + camera.iter().all(|v| v.is_finite() && *v > 0.0).then(|| neutral_to_xy(color, camera)) +} + /// `(matrix, multipliers)` with `rec2020 = matrix · (multipliers ⊙ camera)`, linear Rec.2020 D65. pub fn camera_to_rec2020(raw: &RawImage, wb_xy: Xy) -> (Mat3, [f32; 3]) { let t = camera_transform(raw, wb_xy); @@ -373,6 +405,30 @@ mod tests { assert_eq!(illuminant_temperature(255), None); } + /// Camera-space white balance: re-developing for another white turns a neutral lit by that + /// white neutral (the camera's own matrices, interpolated for the new white, say how); the same + /// white is the identity; without a colour matrix there is nothing to re-evaluate. + #[test] + fn rebalance_redevelops_for_the_new_white() { + let color = ColorData { illuminant: [17, 21], color_matrix: [Some(cam_a()), Some(cam_b())], ..Default::default() }; + let (from, to) = (cct::temp_tint_to_xy(5500.0, 0.0), cct::temp_tint_to_xy(3200.0, 10.0)); + let a = camera_transform_of(&color, from); + // a grey card lit by `to`, as the camera records it, developed for `from`: warm + let n = camera_neutral(&color, to); + let developed = a.matrix.apply(std::array::from_fn(|i| n[i] * a.wb[i] as f64)); + assert!(developed[0] > 1.2 * developed[2], "{developed:?}"); + let out = rebalance(&color, from, to).unwrap().apply(developed); + assert!(close(out.map(|v| v / out[1]), [1.0; 3], 1e-6), "{out:?}"); + let same = rebalance(&color, from, from).unwrap(); + assert!((0..3).all(|r| close(same.0[r], Mat3::IDENTITY.0[r], 1e-9)), "{same:?}"); + assert!(rebalance(&ColorData::default(), from, to).is_none()); + // and back: the developed grey card names the white it was lit by + let white = neutral_white(&color, from, developed).unwrap(); + assert!((white.x - to.x).abs() < 1e-6 && (white.y - to.y).abs() < 1e-6, "{white:?} vs {to:?}"); + assert!(neutral_white(&color, from, [-1.0, 0.5, 0.5]).is_none()); + assert!(neutral_white(&ColorData::default(), from, developed).is_none()); + } + #[test] fn grey_world_balances() { let img = lightcraft_raster::Rgb32f::from_fn(20, 20, |x, _| { diff --git a/docs/parity.md b/docs/parity.md index 8e6c6f5a..611d39c2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -246,7 +246,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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` | no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp` | relative scale for non-raw in the UI | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) | | 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` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index ca5758bf..c25ee525 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -67,6 +67,10 @@ - The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker). +- White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the + camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey + lit by the chosen white comes out grey and saturated colours move as the camera records them. The eyedropper and + Auto use the same model. Custom white balances on these photos render slightly differently than before. - Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and dragging a handle into the image edge stops there instead of pushing the crop out of shape.