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) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-16 21:19:18 +11:00
co-authored by Claude Opus 5.5
parent f34c7a8a89
commit 340ed4c2d8
7 changed files with 247 additions and 33 deletions
+15
View File
@@ -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
+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 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<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
@@ -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<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,
@@ -219,11 +256,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) {
@@ -246,7 +281,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,
@@ -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<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
@@ -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.
+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 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` | |
+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-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% |