Merge PR #2009: Kelvin white balance from Canon CR2 ColorData presets (PowerShot G1 X Mark III)

docs/raw-parity.md: header change note and revision rows keep both sides (newest first).
This commit is contained in:
aiongg
2026-10-22 02:40:28 +00:00
7 changed files with 247 additions and 33 deletions
+15
View File
@@ -1224,6 +1224,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
+112 -23
View File
@@ -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 5 % 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<KelvinPresets> {
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<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> {
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<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> {
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"));
+1 -1
View File
@@ -667,7 +667,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<kelvin_presets::KelvinPresets>,
+113 -6
View File
@@ -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<crate::kelvin_presets::KelvinPresets> {
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,
@@ -229,11 +266,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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) {
@@ -256,7 +291,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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,
@@ -507,6 +542,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<u64> {
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<u64> = (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<u8> = 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]
+2
View File
@@ -52,6 +52,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.
+1 -1
View File
@@ -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 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` | |
+3 -2
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; 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
@@ -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):
@@ -288,6 +288,7 @@ Beyond decoding:
| Date | Change | Summary |
|---|---|---|
| 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 | DNGs with a look table and no tone curve (Lightroom-written, Adobe Standard) fit their tone to the embedded preview, also Lightroom's 1024 px one: two lossy ILCE-7CM2 DNGs no longer render 0.8 EV dark and flat |
| 2026-10-15 | minor | Lossy JPEG XL DNG tiles that declare a non-linear SDR transfer curve (sRGB, BT.709, DCI, gamma) decode with that curve restored instead of falling back to the embedded preview: Lightroom Classic 15.1 LinearRaw DNGs (Rec. 2100 primaries, sRGB curve; 2 private ILCE-7CM2 files) |
| 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 |