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% |