diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index abeaadad..6ceee798 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -318,12 +318,13 @@ pub fn relative_wb_format(format: &str) -> bool { /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed /// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs): -/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera -/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into -/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730). -/// The engine's render path (`files.rs`) decides the same way from the file; both must agree. +/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor +/// white-balance presets to read Kelvin from. A camera with matrices in `lightcraft_raw::spectral` edits in Kelvin: +/// the matrices turn its as-shot multipliers into a temperature, and the slider back, while its colour look stays +/// whatever the file's fit gives (issue #730). A camera in `lightcraft_raw::kelvin_presets` does the same through +/// its own presets. The engine's render path (`files.rs`) decides the same way from the file; both must agree. pub fn relative_wb_camera(format: &str, camera: &str) -> bool { - relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() + relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera) } impl Photo { @@ -599,6 +600,8 @@ mod edited_tests { ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), ("SONY ILCE-7M2", "ARW", true), + ("SONY ILCA-99M2", "ARW", false), + ("SONY SLT-A33", "ARW", false), ("SONY ILCE-1", "ARW", true), ("", "ARW", true), ("SONY ILCE-7M3", "DNG", false), diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4c..5bb75a37 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1834,17 +1834,19 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let own = lightcraft_raw::color::has_matrix(&raw.color); - let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some(); + let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); + let measured = lightcraft_raw::spectral::find(make, model).is_some(); + let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); let before = raw.color.clone(); let (_, t, fitted) = starting_colour(&mut raw, &bytes); - let spectral = !own && fitted.is_none() && covered; + let spectral = !own && fitted.is_none() && measured; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); - // white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the - // spectral matrices, whichever look it got (issue #730) + // white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin, + // through the spectral matrices or its presets, whichever look it got (issue #730) assert_eq!(info.relative_wb, !own && !covered, "{name}"); assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); if covered && !own { @@ -2317,6 +2319,36 @@ mod tests { } } + /// Sony ARWs without measured matrices read Kelvin from their white-balance presets + /// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and + /// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its + /// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files. + #[test] + fn corpus_arw_white_balance_in_kelvin_through_the_presets() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); + assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); + assert!( + (info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, + "{name}: {} {}", + info.as_shot_temp, + info.as_shot_tint + ); + let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); + } + } + #[test] fn rejects_monochrome_invalid_and_unrelated_previews() { let mut sensor = Rgb32f::new(32, 32); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff7..6b2d5e57 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -508,15 +508,16 @@ fn load_bytes_now( /// /// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without /// (`camera_preview::file_local_look`) has one when its camera's matrices are measured -/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look -/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). +/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one +/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the +/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). struct WbScaleOf { own_matrix: bool, relative: bool, - /// Kelvin through the camera's measured matrices: edited on the relative scale before those were used. + /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. legacy_relative: bool, - /// The as-shot white through the measured matrices, when that is where the scale comes from. - spectral_white: Option, + /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. + scale_white: Option, } impl WbScaleOf { @@ -528,18 +529,26 @@ impl WbScaleOf { ) -> Self { let own_matrix = lightcraft_raw::color::has_matrix(color); let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; - let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten(); - let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers)); - WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white } + let model = meta.model.as_deref().filter(|_| fitted); + let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model)); + let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model)); + let scale_white = match camera { + Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers), + None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers), + None => None, + }; + let covered = camera.is_some() || presets; + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white } } /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through - /// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog - /// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65. + /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the + /// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to + /// `own`, D65. fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { if self.relative { return (6500.0, 0.0); } - let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own)); + let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own)); (t.round(), tint.round()) } } @@ -908,28 +917,49 @@ mod tests { let d65 = lightcraft_color::D65; // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); - assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some()); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); let (t, tint) = a7m3.as_shot(d65); assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); - assert!(bare.legacy_relative && bare.spectral_white.is_none()); + assert!(bare.legacy_relative && bare.scale_white.is_none()); assert_eq!(bare.as_shot(d65), { let (t, tint) = xy_to_temp_tint(d65); (t.round(), tint.round()) }); // a camera without measured matrices: relative, whatever the multipliers let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); - assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none()); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as + // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with = ColorData { kelvin_presets: presets, ..Default::default() }; + let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); + assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); + 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")); + 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 + let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); + assert!(other.relative && other.scale_white.is_none()); let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); // a file with matrices of its own (DNG): absolute from them, no matter whose camera let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); - assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none()); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index e020ac45..8128644b 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -53,6 +53,7 @@ pub fn color_data(color: &FrameColor) -> ColorData { baseline_exposure: 0.0, baseline_sharpness: None, profile: Default::default(), + kelvin_presets: None, } } } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 27e30002..6d4987f2 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -121,6 +121,7 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData { // DNG 1.7 BaselineSharpness (IFD 0 or the enhanced IFD); implausible values are ignored baseline_sharpness: get(t::BASELINE_SHARPNESS).and_then(|v| v.first().copied()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 16.0), profile: profile_look(ifd0, raw, order), + kelvin_presets: None, } } diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs new file mode 100644 index 00000000..d4e6f33f --- /dev/null +++ b/crates/raw/src/kelvin_presets.rs @@ -0,0 +1,227 @@ +//! A Kelvin scale from the camera's own white-balance presets, for raws whose camera has no colour matrices. +//! +//! Sony ARWs from the DSLR-A200 on store, in their encrypted `SR2SubIFD` (see `vendor::sr2`), the multipliers the +//! camera would apply for each of its white-balance presets, among them five at a stated colour temperature: +//! `WB_RGBLevels2500K` (`0x782d`), `3200K` (`0x782c`), `4500K` (`0x7824`), `6000K` (`0x782b`) and `8500K` (`0x782a`; +//! tag numbers from `exiftool -v3`, run as a black-box tool on the files). They are Sony's own reading of its sensor: +//! the as-shot multipliers placed on the curve through them give the temperature Sony's Kelvin dial names for that +//! 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). +//! +//! 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 +//! 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 +//! by the segment's own spacing of the Planckian locus in CIE 1960 (u, v), per unit arc length. +//! - The isotherms are not perpendicular to the curve in these coordinates: an offset also changes mired by +//! [`ISOTHERM_TILT`] times the mired the same distance along the curve would. +//! +//! The two constants come from the 14 Sony bodies that have both measured matrices and the presets (one CC0 +//! raw.pixls.us sample each for the presets, a grid of multipliers through `crate::spectral` for the matrices): +//! the tilt measured 0.59–0.84 (mean 0.74), the tint spacing 0.90–1.04 of the locus's (mean 0.98, taken as 1). +//! On 150 ILCE-7SM2 photos read both ways, the temperatures agree to ±4 mired around a constant offset (Sony's +//! Kelvin is about 8 % above the matrices', as #730 found for the ILCE-7M3's dial) and the tint to −9 ± 4: the +//! matrices put Sony's presets slightly off the locus, the presets themselves are tint 0 here. +use crate::ColorData; +use lightcraft_color::{Xy, cct}; +use serde::{Deserialize, Serialize}; + +/// Mired change per unit offset from the preset curve, in units of the mired change per unit distance along it. +pub const ISOTHERM_TILT: f64 = 0.74; + +/// The slider's range: an as-shot reading is kept inside it. +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"]; + +/// 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)) +} + +/// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). +pub fn covers_camera(camera: &str) -> bool { + match camera.split_once(char::is_whitespace) { + Some((make, model)) => covers(Some(make), model), + None => covers(None, camera), + } +} + +/// A camera's multipliers at five stated temperatures, ascending, green = 1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct KelvinPresets { + points: [(f32, [f32; 3]); 5], +} + +fn usable(m: [f32; 3]) -> bool { + m.iter().all(|v| *v > 0.0 && v.is_finite()) +} + +fn log_point(m: [f32; 3]) -> [f64; 2] { + [(m[0] as f64 / m[1] as f64).ln(), (m[2] as f64 / m[1] as f64).ln()] +} + +fn locus_uv(t: f64) -> (f64, f64) { + cct::xy_to_uv(cct::temp_tint_to_xy(t, 0.0)) +} + +impl KelvinPresets { + /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive + /// multipliers, red rising and blue falling from one preset to the next. + pub fn new(points: [(f32, [f32; 3]); 5]) -> Option { + let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) + && points.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]])) }) + } + + /// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable + /// multipliers. + pub fn temp_tint(&self, wb_multipliers: [f32; 3]) -> Option<(f64, f64)> { + if !usable(wb_multipliers) { + return None; + } + let q = log_point(wb_multipliers); + let last = self.points.len() - 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]); + 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]] }; + let r = [q[0] - p0[0], q[1] - p0[1]]; + let along = (r[0] * t[0] + r[1] * t[1]) / len; + let off = r[0] * n[0] + r[1] * n[1]; + let lo = if i == 0 { f64::NEG_INFINITY } else { 0.0 }; + let hi = if i == last { f64::INFINITY } else { 1.0 }; + let c = along.clamp(lo, hi); + let dist = (r[0] - c * d[0]).hypot(r[1] - c * d[1]); + let (mired0, mired1) = (1e6 / k0 as f64, 1e6 / k1 as f64); + let mired_per_len = (mired1 - mired0) / len; + let ((u0, v0), (u1, v1)) = (locus_uv(k0 as f64), locus_uv(k1 as f64)); + let tint_per_len = cct::TINT_SCALE * (u1 - u0).hypot(v1 - v0) / len; + let mired = mired0 + along * (mired1 - mired0) + ISOTHERM_TILT * mired_per_len * off; + if best.is_none_or(|b| dist < b.0) { + best = Some((dist, mired, tint_per_len * off)); + } + } + let (_, mired, tint) = best?; + let t = 1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K); + Some((t, tint.clamp(-MAX_TINT, MAX_TINT))) + } + + /// The as-shot white of a file through its presets: [`Self::temp_tint`] of its multipliers as a chromaticity. + pub fn as_shot_white(&self, wb_multipliers: Option<[f32; 3]>) -> Option { + let (t, tint) = self.temp_tint(wb_multipliers?)?; + Some(cct::temp_tint_to_xy(t, tint)) + } +} + +/// The as-shot white of a file through its own presets ([`ColorData::kelvin_presets`]); `None` without presets or +/// usable multipliers. +pub fn as_shot_white(color: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Option { + color.kelvin_presets.as_ref()?.as_shot_white(wb_multipliers) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The ILCA-99M2's presets (every file of the body carries the same). + fn a99m2() -> KelvinPresets { + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]) + .unwrap() + } + + #[test] + fn a_preset_reads_as_its_own_temperature_with_no_tint() { + let p = a99m2(); + 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 + let (t2, tint2) = p.temp_tint(m.map(|v| v * 2.3)).unwrap(); + assert!((t2 - t).abs() < 1e-3 && (tint2 - tint).abs() < 1e-4, "{t2} {tint2}"); + } + } + + #[test] + fn between_presets_the_temperature_follows_the_curve() { + let p = a99m2(); + let mid = |a: [f32; 3], b: [f32; 3]| [(a[0] * b[0]).sqrt(), 1.0, (a[2] * b[2]).sqrt()]; + let (t, tint) = p.temp_tint(mid(p.points[2].1, p.points[3].1)).unwrap(); + assert!((4500.0..6000.0).contains(&t) && tint.abs() < 1.0, "{t} {tint}"); + // beyond the ends: extended, then held to the slider's range + let (hot, _) = p.temp_tint([3.5, 1.0, 1.15]).unwrap(); + let (cold, _) = p.temp_tint([1.15, 1.0, 4.0]).unwrap(); + let (coldest, _) = p.temp_tint([0.3, 1.0, 20.0]).unwrap(); + assert!((8500.0..MAX_K).contains(&hot) && (MIN_K..2500.0).contains(&cold) && coldest == MIN_K, "{hot} {cold} {coldest}"); + } + + #[test] + fn less_green_in_the_multipliers_is_a_green_white_and_a_magenta_correction() { + let p = a99m2(); + let day = [2680.0 / 1024.0, 1.0, 1544.0 / 1024.0]; + let (t0, _) = p.temp_tint(day).unwrap(); + // a green light: the camera raises red and blue against green + let (t1, tint1) = p.temp_tint([day[0] * 1.08, 1.0, day[2] * 1.08]).unwrap(); + assert!(tint1 > 15.0 && tint1 < 40.0, "{tint1}"); + // the tilted isotherm: a higher temperature than the curve point it sits beside + assert!(t1 > t0 + 100.0, "{t1} {t0}"); + let (_, magenta) = p.temp_tint([day[0] / 1.08, 1.0, day[2] / 1.08]).unwrap(); + assert!(magenta < -15.0, "{magenta}"); + } + + #[test] + fn unusable_presets_or_multipliers_give_nothing() { + let p = a99m2(); + 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; + pts.swap(1, 2); + assert_eq!(KelvinPresets::new(pts), None, "out of order"); + let mut pts = p.points; + pts[4].1 = pts[3].1; + assert_eq!(KelvinPresets::new(pts), None, "no step"); + let mut pts = p.points; + 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); + let with = ColorData { kelvin_presets: Some(p), ..Default::default() }; + assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some()); + } + + #[test] + fn covered_models() { + for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] { + assert!(covers_camera(c), "{c}"); + } + for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] { + assert!(!covers_camera(c), "{c}"); + } + assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2")); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9ed..7bb1d85a 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -34,6 +34,7 @@ pub mod gaintable; pub mod highlight; #[cfg(feature = "jxl")] mod jxl; +pub mod kelvin_presets; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; @@ -598,6 +599,10 @@ 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 + /// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it. + #[serde(default)] + pub kelvin_presets: Option, } /// A decoded raw image. diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index e8c76600..ba7c3356 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -62,6 +62,9 @@ const CROP_SIZE: u16 = 0x74c8; const SR2_OLD_BLACK: u16 = 0x7300; const SR2_OLD_WB: u16 = 0x7303; const SR2_OLD_CROP: u16 = 0x74c3; +/// `SR2SubIFD` white-balance presets at a stated temperature (`WB_RGBLevels2500K` … `8500K`: R, G, B), which give +/// the Kelvin scale of a camera without colour matrices (`crate::kelvin_presets`). +const SR2_KELVIN_PRESETS: [(f32, u16); 5] = [(2500.0, 0x782d), (3200.0, 0x782c), (4500.0, 0x7824), (6000.0, 0x782b), (8500.0, 0x782a)]; const YCBCR_COEFFICIENTS: u16 = 529; const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). @@ -478,6 +481,16 @@ fn sr2_old_wb(sr2: &super::sr2::SubIfd) -> Option<[f32; 3]> { } /// The DSC-R1's crop from `0x74c3` (x0, y0, x1, y1): an even origin (CFA phase) and a window inside the `w × h` frame. +/// The five presets of [`SR2_KELVIN_PRESETS`], when the block has them all and they make a curve. +fn sr2_kelvin_presets(sr2: &super::sr2::SubIfd) -> Option { + let mut points = [(0.0f32, [0.0f32; 3]); 5]; + for (p, (k, tag)) in points.iter_mut().zip(SR2_KELVIN_PRESETS) { + let v = sr2.shorts(tag).filter(|v| v.len() == 3)?; + *p = (k, [v[0] as f32, v[1] as f32, v[2] as f32]); + } + crate::kelvin_presets::KelvinPresets::new(points) +} + fn sr2_old_crop(sr2: &super::sr2::SubIfd, w: usize, h: usize) -> Option { let v = sr2.shorts(SR2_OLD_CROP).filter(|v| v.len() == 4)?; let [x0, y0, x1, y1] = [v[0] as usize, v[1] as usize, v[2] as usize, v[3] as usize]; @@ -728,7 +741,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: Rect::new(0, 0, w, h), crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + // (linear YCbCr comes white-balanced: its unit multipliers have no place on the presets' curve) + color: ColorData { kelvin_presets: if linear_rgb { None } else { sr2().and_then(sr2_kelvin_presets) }, ..Default::default() }, // Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf7..f82c2c08 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -28,7 +28,9 @@ Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. -**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative. +**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative, unless their own white-balance presets give the scale (next). + +**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. 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). @@ -130,9 +132,9 @@ When the file's JPEG is black and white, step 2 fits the tone curve alone with t The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the gates or the file has no usable JPEG. White balance of a covered model is in Kelvin through its matrices whichever -look it got (above, issue #730); for every other raw without matrices of its own it stays relative to the as-shot -look (`Photo::relative_wb`). `lightcraft-cli calibrate` still pools the uncorrected camera colour of covered -models, so a profile can be fitted for them. +look it got (above, issue #730), and so is that of a model whose presets give the scale; for every other raw +without matrices of its own it stays relative to the as-shot look (`Photo::relative_wb`). `lightcraft-cli calibrate` +still pools the uncorrected camera colour of covered models, so a profile can be fitted for them. No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, is recorded here yet. Whether they should come before the file-local fit for these models is open until one is. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf64..ccbb9769 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -81,7 +81,8 @@ filmstrip is visible. White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on raws with colour matrices — in the file (DNG) or measured for the camera model (`lightcraft_raw::spectral`; issue -#730) — where As Shot reads the file's as-shot white; rendered files and raws without matrices use a relative scale +#730) — and on raws whose white-balance presets give the scale (`lightcraft_raw::kelvin_presets`), where As Shot +reads the file's as-shot white; rendered files and raws without matrices use a relative scale whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge` or a preset whose `wb` sets `temp` / `tint` **without** `scale` keeps the relative meaning it always had (6500 = as shot) on every raw, so agents that mean diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 878603e0..1f9deeea 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -52,7 +52,8 @@ RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvi as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported (issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). A raw whose camera has -measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…) edits in Kelvin +measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…), or whose white-balance +presets give a Kelvin scale (`lightcraft_raw::kelvin_presets`: ILCA-99M2, ILCA-77M2…), edits in Kelvin and keeps the Kelvin value too (issue #730) — when its file was found and probed, since the camera is read from the file, not from the Lightroom catalog; a missing file is treated as a relative-scale raw until Reload reads it. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..2d91095b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -190,7 +190,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`) | 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); raws without matrices 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/raw/src/kelvin_presets.rs`, `crates/catalog/src/model.rs` (`relative_wb_camera`) | 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 or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb..47beec67 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -188,7 +188,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); 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 Sony's white-balance presets in the file give the scale (6 bodies, `kelvin_presets`); other raws keep the relative scale (#296) | **How far the default rendering is from Adobe's** (CIE ΔE; measured where stated): diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a..90207594 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-12 + +### Kelvin white balance for more Sony cameras +- Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 and ILCE-7S raws now show white balance in Kelvin too, + read from the white-balance presets the camera stores in every ARW: a photo shot at 3400 K on the camera reads + 3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative + scale keep their look, and As Shot shows the real temperature once the file is opened. + ## 2026-10-11 ### JPEG XL export diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d119..6a34359b 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -114,7 +114,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 | | `HDREditMode` | `hdr.enabled` | `1` / `True` = HDR editing on, `0` / `False` off | | `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom | -| `Temperature`, `Tint` | `wb.temp`, `wb.tint` (+ `wb.scale: kelvin`) | Kelvin / tint as written for raws with colour matrices — in the file (DNG) or measured for the camera (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…, issue #730) — and for presets; the mapped settings say `"scale": "kelvin"` so the value stays Kelvin wherever it lands. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW of other cameras: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510). Note that LightCraft's Kelvin for a camera comes from its measured matrices, Lightroom's from Adobe's: the two scales differ (about 7–10 % on the ILCE-7M3), so the number is carried over, not the exact look | +| `Temperature`, `Tint` | `wb.temp`, `wb.tint` (+ `wb.scale: kelvin`) | Kelvin / tint as written for raws with colour matrices — in the file (DNG) or measured for the camera (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…, issue #730) or read from its white-balance presets (`lightcraft_raw::kelvin_presets`: ILCA-99M2, ILCA-77M2…) — and for presets; the mapped settings say `"scale": "kelvin"` so the value stays Kelvin wherever it lands. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW of other cameras: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510). Note that LightCraft's Kelvin for a camera comes from its measured matrices, Lightroom's from Adobe's: the two scales differ (about 7–10 % on the ILCE-7M3), so the number is carried over, not the exact look | | `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider); a rendered file with only `Temperature` shifts from `AsShotTemperature` like the raws above when it is present | | `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 | | `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 |