mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Vendored
+113
-6
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user