From 340ed4c2d87210745b7ef9cc709bd052575c4715 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Fri, 16 Oct 2026 21:19:18 +1100 Subject: [PATCH 1/3] LR-EDIT-COLOR-TEMP: Kelvin white balance from Canon CR2 ColorData presets Canon's maker-note ColorData lists the levels of each white-balance preset with Canon's colour temperature for it. For versions -4 and 13-15 the Daylight, Shade, Cloudy, Tungsten and Flash rows now give a Kelvin curve (kelvin_presets, generalised from five fixed points to any number, with a from_unordered constructor that sorts and drops points that would turn the curve back). Fluorescent (off the locus) and the Kelvin row (the dial's last setting) are left out. Enabled for the PowerShot G1 X Mark III, which has no colour matrices: its nine as-shot white balances read within 4 % of the camera's own ColorTempAsShot. Sources: own design on the existing kelvin_presets model; ColorData row positions from the ExifTool Canon tag-name documentation, confirmed with ExifTool run as a black-box tool on sample files. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/files.rs | 15 ++++ crates/raw/src/kelvin_presets.rs | 135 +++++++++++++++++++++++++------ crates/raw/src/lib.rs | 2 +- crates/raw/src/vendor/cr2.rs | 119 +++++++++++++++++++++++++-- docs/camera-preview-colour.md | 2 + docs/parity-checklist.md | 2 +- docs/raw-parity.md | 5 +- 7 files changed, 247 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..8b06a69d7 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1182,6 +1182,21 @@ pub(crate) mod tests { let (t, tint) = a99m2.as_shot(d65); assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a + // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) + let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (8529.0, g(1807.0, 1080.0)), + ]); + let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; + let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); + assert!(!g1x3.relative && g1x3.scale_white.is_some()); + let (t, tint) = g1x3.as_shot(d65); + assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); assert!(bare.legacy_relative && bare.scale_white.is_none()); // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index d4e6f33f3..b728e6b1d 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -8,8 +8,13 @@ //! white. That is all a Kelvin slider needs; the look stays the file's JPEG fit, and the picture is adapted from the //! as-shot white with Bradford, as for a camera read through measured matrices (`crate::spectral`, issue #730). //! +//! Canon CR2s list theirs by name in the maker note's `ColorData`, each with Canon's temperature for it (see +//! `vendor::cr2`): five planckian ones (Daylight, Shade, Cloudy, Tungsten, Flash) make the curve the same way, +//! taken in Kelvin order ([`KelvinPresets::from_unordered`]). On the PowerShot G1 X Mark III's nine settings the +//! curve reads the as-shot levels within 4 % of the camera's own `ColorTempAsShot`. +//! //! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]): -//! - The five presets are joined piecewise linearly, with mired linear along each segment and the end segments +//! - The presets (three or more) are joined piecewise linearly, with mired linear along each segment and the end segments //! extended. On every Sony body measured, mired is nearly linear in arc length along the whole curve (2500 K to //! 8500 K within ±3 %), so the segments add no visible kinks. //! - A white off the curve is placed by its perpendicular offset from the nearest segment. Tint is that offset scaled @@ -35,18 +40,34 @@ const MIN_K: f64 = 2000.0; const MAX_K: f64 = 50000.0; const MAX_TINT: f64 = 150.0; -/// Models whose white balance is edited in Kelvin through their presets, checked to carry them (on 80–150 own -/// photos each, or the public corpus samples for the ILCE-3500 and ILCE-7S): the Exif model as Sony writes it. A -/// camera with measured matrices (`crate::spectral`) is read through those instead. -const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"]; +/// Models whose white balance is edited in Kelvin through their presets, as (brand, Exif model without the brand), +/// each checked to carry them: the Sony bodies on 80–150 own photos each, or the public corpus samples for the +/// ILCE-3500 and ILCE-7S; the Canon PowerShot G1 X Mark III on a reporter's nine photos, one per white-balance +/// setting (issue #157 of a downstream fork). A camera with measured matrices (`crate::spectral`) is read through +/// those instead. +const MODELS: &[(&str, &str)] = &[ + ("SONY", "DSC-RX10"), + ("SONY", "ILCA-77M2"), + ("SONY", "ILCA-99M2"), + ("SONY", "ILCE-3500"), + ("SONY", "ILCE-7S"), + ("SONY", "SLT-A33"), + ("CANON", "PowerShot G1 X Mark III"), +]; /// 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). pub fn covers(make: Option<&str>, model: &str) -> bool { let model = model.trim(); - let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model); - make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY"))) - && MODELS.iter().any(|m| m.eq_ignore_ascii_case(model)) + let brand = make.and_then(|m| m.split_whitespace().next()); + MODELS.iter().any(|(b, m)| { + let bare = model + .get(..b.len()) + .zip(model.get(b.len()..)) + .filter(|(p, rest)| p.eq_ignore_ascii_case(b) && rest.starts_with(' ')) + .map_or(model, |(_, rest)| rest.trim_start()); + brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare.eq_ignore_ascii_case(m) + }) } /// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). @@ -57,10 +78,13 @@ pub fn covers_camera(camera: &str) -> bool { } } -/// A camera's multipliers at five stated temperatures, ascending, green = 1. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +/// Most points a curve keeps (Canon writes about seven at a stated temperature, Sony five). +const MAX_POINTS: usize = 32; + +/// A camera's multipliers at stated temperatures (at least three), ascending, green = 1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KelvinPresets { - points: [(f32, [f32; 3]); 5], + points: Vec<(f32, [f32; 3])>, } fn usable(m: [f32; 3]) -> bool { @@ -76,15 +100,36 @@ fn locus_uv(t: f64) -> (f64, f64) { } impl KelvinPresets { - /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive - /// multipliers, red rising and blue falling from one preset to the next. - pub fn new(points: [(f32, [f32; 3]); 5]) -> Option { - let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) + /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: three to + /// [`MAX_POINTS`] of them, positive multipliers, red rising and blue falling from one preset to the next. + pub fn new(points: impl IntoIterator) -> Option { + let points: Vec<_> = points.into_iter().take(MAX_POINTS + 1).collect(); + let ok = (3..=MAX_POINTS).contains(&points.len()) + && points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) && points.windows(2).all(|w| { let (a, b) = (log_point(w[0].1), log_point(w[1].1)); w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1] }); - ok.then(|| KelvinPresets { points: points.map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])) }) + ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() }) + } + + /// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …): + /// sorted by Kelvin, a repeated temperature kept once, unusable points and any that would turn the curve back + /// (red not rising or blue not falling from the warmer neighbour kept) dropped; then [`Self::new`]. + pub fn from_unordered(points: impl IntoIterator) -> Option { + let mut pts: Vec<_> = points.into_iter().take(MAX_POINTS).filter(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)).collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut kept: Vec<(f32, [f32; 3])> = Vec::with_capacity(pts.len()); + for p in pts { + let fits = kept.last().is_none_or(|q| { + let (a, b) = (log_point(q.1), log_point(p.1)); + q.0 < p.0 && b[0] > a[0] && b[1] < a[1] + }); + if fits { + kept.push(p); + } + } + KelvinPresets::new(kept) } /// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable @@ -94,13 +139,17 @@ impl KelvinPresets { return None; } let q = log_point(wb_multipliers); - let last = self.points.len() - 2; + let last = self.points.len().saturating_sub(2); let mut best: Option<(f64, f64, f64)> = None; for (i, w) in self.points.windows(2).enumerate() { let ((k0, m0), (k1, m1)) = (w[0], w[1]); let (p0, p1) = (log_point(m0), log_point(m1)); let d = [p1[0] - p0[0], p1[1] - p0[1]]; let len = d[0].hypot(d[1]); + // only a deserialized curve can repeat a point + if !(len > 0.0 && len.is_finite()) { + continue; + } let t = [d[0] / len, d[1] / len]; // the normal towards higher red and blue: a green white let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] }; @@ -158,7 +207,7 @@ mod tests { #[test] fn a_preset_reads_as_its_own_temperature_with_no_tint() { let p = a99m2(); - for (k, m) in p.points { + for &(k, m) in &p.points { let (t, tint) = p.temp_tint(m).unwrap(); assert!((t - k as f64).abs() < 0.5 && tint.abs() < 1e-6, "{k}: {t} {tint}"); // the scale of the multipliers doesn't matter, only their ratios to green @@ -200,26 +249,66 @@ mod tests { assert_eq!(p.temp_tint([0.0, 1.0, 1.0]), None); assert_eq!(p.temp_tint([f32::NAN, 1.0, 1.0]), None); assert_eq!(p.as_shot_white(None), None); - let mut pts = p.points; + let mut pts = p.points.clone(); pts.swap(1, 2); assert_eq!(KelvinPresets::new(pts), None, "out of order"); - let mut pts = p.points; + let mut pts = p.points.clone(); pts[4].1 = pts[3].1; assert_eq!(KelvinPresets::new(pts), None, "no step"); - let mut pts = p.points; + let mut pts = p.points.clone(); pts[0].1[0] = 0.0; assert_eq!(KelvinPresets::new(pts), None); assert_eq!(as_shot_white(&ColorData::default(), Some([2.0, 1.0, 1.5])), None); + assert_eq!(KelvinPresets::new(p.points[..2].to_vec()), None, "too few"); let with = ColorData { kelvin_presets: Some(p), ..Default::default() }; assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some()); } + /// Canon lists its presets by name, not by temperature, with repeats and an off-curve one among them: the + /// curve takes them in Kelvin order, each temperature once, and leaves out a point that would turn it back. + #[test] + fn a_curve_from_presets_in_any_order() { + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let p = KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (5651.0, g(1418.0, 1357.0)), + (8000.0, g(1400.0, 1400.0)), + (8529.0, g(1807.0, 1080.0)), + (f32::NAN, g(1000.0, 1000.0)), + ]) + .unwrap(); + assert_eq!(p.points.len(), 5); + assert!(p.points.windows(2).all(|w| w[0].0 < w[1].0)); + let (t, tint) = p.temp_tint(g(1653.0, 1171.0)).unwrap(); + assert!((t - 7505.0).abs() < 1.0 && tint.abs() < 0.1, "{t} {tint}"); + assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None); + assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None); + // a stored curve with a repeated point (only deserializing can make one) reads without dividing by zero + let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3] }; + assert_eq!(bad.temp_tint(g(1400.0, 1400.0)), None); + assert_eq!(KelvinPresets { points: vec![] }.temp_tint(g(1400.0, 1400.0)), None); + } + #[test] fn covered_models() { - for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] { + for c in [ + "SONY ILCA-99M2", + "SONY DSC-RX10", + "SONY ILCA-77M2", + "SONY SLT-A33", + "ILCA-99M2", + "Sony ilca-99m2", + "Canon Canon PowerShot G1 X Mark III", + "Canon PowerShot G1 X Mark III", + ] { assert!(covers_camera(c), "{c}"); } - for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] { + for c in + ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY", "Canon Canon PowerShot G1 X Mark II", "SONY PowerShot G1 X Mark III"] + { assert!(!covers_camera(c), "{c}"); } assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2")); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 202983331..de0a3d9a0 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -647,7 +647,7 @@ pub struct ColorData { /// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`). #[serde(default)] pub profile: profile::ProfileLook, - /// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour + /// The camera's white-balance presets at stated temperatures (Sony ARW, Canon CR2), which give a raw without colour /// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it. #[serde(default)] pub kelvin_presets: Option, diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 3fe173e09..6b4b9f93e 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -19,6 +19,8 @@ //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known //! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED //! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first. +//! - The same array lists the levels of the camera's white-balance presets, each with the colour temperature Canon +//! gives it ([`kelvin_presets_from_color_data`]): a Kelvin scale for a body without colour matrices. //! - sRAW / mRAW (raw IFD tag `0xc6c5` = 4) hold subsampled YCbCr instead of a mosaic: see [`sraw`]. mod sraw; @@ -90,6 +92,41 @@ fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { None } +/// Where the planckian white-balance presets sit in `ColorData`, by `ColorDataVersion` (word 0, signed): the word +/// index of each preset's `R G G B` levels and of its colour temperature. Daylight, Shade, Cloudy, Tungsten and +/// Flash, in that order. Fluorescent is left out (a green light off the locus) and so is the Kelvin row (it holds the +/// dial's last setting, and on some bodies a temperature that doesn't match its own levels: 6036 K with the camera at +/// 5500 K on the PowerShot G1 X Mark III, Daylight's levels at 5213 K on the EOS 2000D). Positions from the ExifTool +/// Canon tag-name documentation's `ColorData5` / `ColorData8` tables, confirmed with ExifTool run as a black-box tool +/// on files of each version: -4 on a PowerShot G1 X Mark III (`ColorData5`: 8-word records, the second record after +/// Daylight a repeat of it), 13, 14 and 15 on the CC0 corpus samples of the EOS 5D Mark IV, 2000D and 6D Mark II +/// (`ColorData8`: 5-word records). Other versions give no presets. +fn preset_rows(version: i16) -> Option<[(usize, usize); 5]> { + match version { + -4 => Some([(95, 102), (111, 118), (119, 126), (127, 134), (151, 158)]), + 13..=15 => Some([(133, 137), (138, 142), (143, 147), (148, 152), (163, 167)]), + _ => None, + } +} + +/// The camera's planckian white-balance presets from the `ColorData` words ([`preset_rows`]) as a Kelvin curve; +/// `None` for an unknown version, a short array or presets that don't make a curve. +fn kelvin_presets_from_color_data(v: &[u64]) -> Option { + let version = *v.first()? as u16 as i16; + let rows = preset_rows(version)?; + let mut points = Vec::with_capacity(rows.len()); + for (at, temp_at) in rows { + let q = v.get(at..at + 4)?; + let temp = *v.get(temp_at)? as f32; + if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) + && (1000.0..=20000.0).contains(&temp) + { + points.push((temp, wb)); + } + } + crate::kelvin_presets::KelvinPresets::from_unordered(points) +} + /// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that /// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in /// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy, @@ -219,11 +256,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active = Rect::new(l, tp, r - l + 1, b - tp + 1); } } - let wb = mn - .as_ref() - .and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))) - .and_then(|v| wb_from_color_balance(&v)) - .or_else(|| wb_from_wb_info(mn.as_ref()?)); + let color_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))); + let kelvin_presets = color_data.as_deref().and_then(kelvin_presets_from_color_data); + let wb = color_data.as_deref().and_then(wb_from_color_balance).or_else(|| wb_from_wb_info(mn.as_ref()?)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); let white = white_from_data(&active_rows(&data, width, active), prec as u32); let black = match masked_columns(&data, width, active, white) { @@ -246,7 +281,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: active, crop: Rect::new(0, 0, active.width, active.height), orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + color: ColorData { kelvin_presets, ..Default::default() }, wb_multipliers: wb, linearized: false, pixel_aspect: 1.0, @@ -497,6 +532,78 @@ pub(crate) mod tests { } } + /// `ColorData` words of `version` with the planckian presets (R, B over G = 1024, Kelvin) at its + /// [`preset_rows`], a Fluorescent-like green row between them, and the as-shot levels at word 71 / 63. + fn color_data_with_presets(version: i16, presets: &[(u16, u16, u16)]) -> Vec { + let mut v = vec![0u64; 400]; + v[0] = u64::from(version as u16); + for ((at, temp_at), &(r, b, k)) in preset_rows(version).unwrap().into_iter().zip(presets) { + v[at..at + 4].copy_from_slice(&[r.into(), 1024, 1024, b.into()]); + v[temp_at] = k.into(); + } + v + } + + /// Issue #157 of a downstream fork: the PowerShot G1 X Mark III (`ColorData` -4) gets a Kelvin curve from its + /// Daylight, Shade, Cloudy, Tungsten and Flash presets (levels and temperatures as its files carry them), which + /// puts a preset's own levels at the preset's temperature; the EOS bodies of versions 13–15 likewise. + #[test] + fn kelvin_presets_from_color_data_presets() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let v = color_data_with_presets(-4, &g1x3); + let p = kelvin_presets_from_color_data(&v).unwrap(); + for (r, b, k) in g1x3 { + let (t, tint) = p.temp_tint([r as f32 / 1024.0, 1.0, b as f32 / 1024.0]).unwrap(); + assert!((t - k as f64).abs() < 1.0 && tint.abs() < 0.1, "{k}: {t} {tint}"); + } + // levels between Tungsten and Daylight: a temperature between them + let (t, _) = p.temp_tint([1264.0 / 1024.0, 1.0, 1537.0 / 1024.0]).unwrap(); + assert!((3174.0..5651.0).contains(&t), "{t}"); + let eos = [(2013, 1479, 5200), (2300, 1280, 7000), (2153, 1374, 6000), (1442, 2089, 3200), (2203, 1371, 6156)]; + for version in [13, 14, 15] { + assert!(kelvin_presets_from_color_data(&color_data_with_presets(version, &eos)).is_some(), "{version}"); + } + // a preset that would turn the curve back (Flash cooler than Shade's levels say) is dropped, not fatal + let mut odd = g1x3; + odd[4] = (1500, 1300, 9000); + assert!(kelvin_presets_from_color_data(&color_data_with_presets(-4, &odd)).is_some()); + } + + /// Unknown versions, short or garbled arrays give no presets and never panic. + #[test] + fn kelvin_presets_from_hostile_color_data() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let good = color_data_with_presets(-4, &g1x3); + let mut other = good.clone(); + other[0] = u64::from(-3i16 as u16); + assert!(kelvin_presets_from_color_data(&other).is_none(), "unverified version"); + assert!(kelvin_presets_from_color_data(&[]).is_none()); + for len in [1, 96, 120, 157, 158] { + assert!(kelvin_presets_from_color_data(&good[..len]).is_none(), "{len}"); + } + let mut zeros = good.clone(); + for at in [102, 118, 126] { + zeros[at] = 0; + } + assert!(kelvin_presets_from_color_data(&zeros).is_none(), "two points left"); + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..2000 { + let v: Vec = (0..200) + .map(|i| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + if i == 0 { [0xfffc, 13, 14, 15][(seed % 4) as usize] } else { seed % 70000 } + }) + .collect(); + let _ = kelvin_presets_from_color_data(&v); + } + // through a whole decode, ColorData as UNDEFINED bytes + let bytes: Vec = good.iter().flat_map(|&w| (w as u16).to_le_bytes()).collect(); + assert!(decode_with_color_data(7, &bytes).color.kelvin_presets.is_some()); + assert!(decode_with_color_data(7, &bytes[..200]).color.kelvin_presets.is_none()); + } + /// Masked rows above the image full of samples at 16383 (EOS 50D, issue #1272): the white level is the image's /// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0. #[test] diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 9a259e9ed..eae582ead 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -51,6 +51,8 @@ Vendor RGB WB multipliers alone do **not** identify an absolute illuminant witho **Kelvin through the camera's white-balance presets.** Sony ARWs store, in the encrypted `SR2SubIFD`, the multipliers of every white-balance preset, five of them at a stated temperature (2500, 3200, 4500, 6000 and 8500 K). For a camera without measured matrices whose files carry them (`lightcraft_raw::kelvin_presets`: Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S) they take the matrices' place in everything above: the as-shot multipliers are placed on the curve through the five presets (mired linear along it, in log red/green and blue/green), an offset from the curve is the tint, and the decision, the Bradford adaptation, the pixel-identical upgrade and `Op::SetAsShotWb` are the same. The scale is Sony's own: files shot on the Kelvin setting read back what the dial said (ILCA-77M2 3400 K → 3396 K and 5200 K → 5193 K, SLT-A33 4700 K → 4708 K, the public ILCE-3500 sample's 5600 K → 5594 K; with the colour filter one step towards magenta, the SLT-A33's 3400 K reads 3473 K / +11), and the Shade preset reads 7493 K on the public ILCE-7S sample. Off the curve, two constants come from the 14 Sony bodies that have both measured matrices and presets: the tint is the offset scaled by the locus's own spacing (the matrices give 0.90–1.04 of it), and the isotherms lean against the curve's normal (`ISOTHERM_TILT` 0.74, measured 0.59–0.84). On 150 ILCE-7SM2 photos read both ways, the presets' temperature tracks the matrices' to ±4 mired around Sony's 8 % higher scale, and the tint to −9 ± 4. A listed camera whose file has no presets keeps the Kelvin scale with As Shot at D65, as a covered camera without multipliers does. Bodies with measured matrices keep reading through them. +Canon CR2s list their presets by name in the maker note's `ColorData` (`0x4001`), each with Canon's colour temperature for it: for versions -4 and 13–15 the curve is built the same way from the Daylight, Shade, Cloudy, Tungsten and Flash rows (sorted by temperature; Fluorescent, a green light off the locus, and the Kelvin row, which holds the dial's last setting, are left out). Only the PowerShot G1 X Mark III is enabled, checked on a reporter's nine photos, one per white-balance setting: the as-shot levels read 4160 / 4580 / 5995 / 5215 / 2816 / 3703 / 7162 / 5368 / 4813 K (Auto, Daylight, Shade, Cloudy, Tungsten, Fluorescent at tint +31, Flash, Underwater, Kelvin 5500) where the camera's own `ColorTempAsShot` says 4306 / 4703 / 6005 / 5272 / 2770 / 3533 / 7100 / 5347 / 4925 K. Those photos were shot with the white-balance shift 8 steps (of 9) towards blue (`WBShiftAB` −8), which is why a Daylight shot reads about 4600 K and not the Daylight preset's 5651 K: the levels the camera applied are bluer than the preset's, and the camera's own number agrees. + What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..86c0c8bb0 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); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). 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). 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 | +| 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 4 % on its 9 white-balance settings); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). 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). 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 | | 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/raw-parity.md b/docs/raw-parity.md index d9cc06a93..858f465ea 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Canon ColorData Kelvin; integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -209,7 +209,7 @@ sample files. A pointer for contributors, not legal advice. | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | -| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where Sony's white-balance presets in the file give the scale (6 bodies, `kelvin_presets`); other raws keep the relative scale (#296) | +| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where the camera's white-balance presets in the file give the scale (6 Sony bodies and the Canon PowerShot G1 X Mark III, `kelvin_presets`); other raws keep the relative scale (#296) | **How far the default rendering is from Adobe's** (CIE ΔE; measured where stated): @@ -287,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | From 041f91d1ccda6785808442c8d55f5189f64b2931 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 09:20:10 +1100 Subject: [PATCH 2/3] Fix raw-parity merge markers Co-Authored-By: Claude Sonnet 5.5 --- docs/raw-parity.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2473ed3f3..9cfb75647 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,10 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -<<<<<<< HEAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Canon ColorData Kelvin; integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) -======= -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** patch (X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) ->>>>>>> upstream/main +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -291,13 +287,10 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| -<<<<<<< HEAD -| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) | -======= | 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback | +| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) | | 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged | | 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | ->>>>>>> upstream/main | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | From a5a4b64ec6b489482a3846aeef81910023a30b76 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 12:03:32 +1100 Subject: [PATCH 3/3] Review: fluorescent accuracy bound 5 %, drop stale relative-scale text Co-Authored-By: Claude Sonnet 5.5 --- crates/raw/src/kelvin_presets.rs | 2 +- docs/parity-checklist.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index b728e6b1d..2d78151d5 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -11,7 +11,7 @@ //! Canon CR2s list theirs by name in the maker note's `ColorData`, each with Canon's temperature for it (see //! `vendor::cr2`): five planckian ones (Daylight, Shade, Cloudy, Tungsten, Flash) make the curve the same way, //! taken in Kelvin order ([`KelvinPresets::from_unordered`]). On the PowerShot G1 X Mark III's nine settings the -//! curve reads the as-shot levels within 4 % of the camera's own `ColorTempAsShot`. +//! curve reads the as-shot levels within 5 % of the camera's own `ColorTempAsShot`. //! //! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]): //! - The presets (three or more) are joined piecewise linearly, with mired linear along each segment and the end segments diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b87ac1515..1ab9a03ef 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 4 % on its 9 white-balance settings); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). 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). 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 | +| 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-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` | |