LR-EDIT-COLOR-TEMP: camera-space white balance for raws with a colour matrix

A custom white balance on a raw with a colour matrix (DNGs, and other raws
without a file-local look) re-develops the pixels for the chosen white
through the file's own colour model: lightcraft_raw::color::rebalance
re-evaluates the camera transform at the new white and maps the as-shot
development onto it, so a neutral lit by that white renders neutral and
saturated colours move as the camera's matrices say (DNG spec ch. 6, as
Lightroom does). The decoder passes the colour tags and the white the
pixels were developed for in SourceInfo::camera_color; other photos, and
relative white balances, keep the Bradford adaptation. The eyedropper and
Auto invert the same model (color::neutral_white), so a picked grey card
renders neutral.

The Tint sign is main's (#349): unchanged here. Render cache version 15.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Exalted5373andClaude Opus 5.5 committed 2026-10-08 18:18:54 -07:00
1 parent ce7b8f511e
commit 0ac5cf4430
11 files changed
+193 -11

No files matched your search

Generated
+1
View File
@@ -2986,6 +2986,7 @@ dependencies = [
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
"proptest",
"rayon",
+32 -1
View File
@@ -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.
+1 -1
View File
@@ -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];
+1
View File
@@ -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 }
+11 -4
View File
@@ -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())
}
+24 -1
View File
@@ -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<Arc<CameraColor>>,
pub camera_tone: Option<tone::CameraTone>,
/// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them.
pub mattes: Option<Arc<masks::Mattes>>,
@@ -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,
}
}
}
+10 -2
View File
@@ -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));
+51
View File
@@ -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<crate::CameraColor> {
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());
}
+57 -1
View File
@@ -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<Mat3> {
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<Xy> {
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, _| {
+1 -1
View File
@@ -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` | |
+4
View File
@@ -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.