diff --git a/crates/engine/src/files/tests.rs b/crates/engine/src/files/tests.rs index e8bcebe4c..133f1b3bc 100644 --- a/crates/engine/src/files/tests.rs +++ b/crates/engine/src/files/tests.rs @@ -119,6 +119,26 @@ fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); } +/// A Fujifilm X-Trans III body reads Kelvin through the neutral pair its RAF stores (issue #730); without the pair +/// in the file, or on a body with matrices (X-T3), nothing changes. +#[test] +fn a_fujifilm_x_trans_iii_raf_reads_kelvin_through_its_neutral_pair() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let a = [366.0f32 / 302.0, 1.0, 866.0 / 302.0]; + let d65 = [646.0f32 / 302.0, 1.0, 498.0 / 302.0]; + let pair = lightcraft_raw::kelvin_presets::KelvinPresets::from_neutral_pair(a, d65); + assert!(pair.is_some()); + let with = ColorData { kelvin_presets: pair, ..Default::default() }; + let xt20 = WbScaleOf::new(RawFormat::Raf, &with, Some(d65), &meta("FUJIFILM", "X-T20")); + assert!(!xt20.relative && xt20.scale_white.is_some()); + let (t, tint) = xt20.as_shot(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); + assert!((6000.0..7000.0).contains(&t) && tint.abs() < 25.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("RAF", "FUJIFILM X-T20")); + let bare = WbScaleOf::new(RawFormat::Raf, &ColorData::default(), Some(d65), &meta("FUJIFILM", "X-T20")); + assert!(bare.scale_white.is_none()); +} + /// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source /// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative /// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index 4db0a521e..58ed39625 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -22,6 +22,11 @@ //! - 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. //! +//! Fujifilm X-Trans III bodies (X-T20, X-T2, X-Pro2, X-E3, X-H1, X100F) store no such presets but the camera's own +//! neutral multipliers for Illuminant A and D65 (`0xf00c`, [`KelvinPresets::from_neutral_pair`], tag number from +//! a black-box `exiftool -v3`). The pair gives a two-point line in the same coordinates; mired is linear along it, +//! isotherms are perpendicular to it, and tint is scaled by the CIE locus spacing. No other camera's data is used. +//! //! 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). @@ -55,6 +60,11 @@ const MODELS: &[(&str, &str)] = &[ ("CANON", "PowerShot G1 X Mark III"), ]; +/// Fujifilm's X-Trans III bodies, whose raws carry the camera's own neutral multipliers for Illuminant A and D65 +/// (`vendor::raf`, tag `0xf00c`; see [`KelvinPresets::from_neutral_pair`]): checked on the files of each (X-T20, +/// X-T2, X-Pro2, X-E3, X-H1, X100F), the Exif model as Fujifilm writes it. +const FUJI_MODELS: &[&str] = &["X-T20", "X-T2", "X-Pro2", "X-E3", "X-H1", "X100F"]; + /// 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), from the file's presets or /// built-in ones ([`crate::kelvin_builtin`]). @@ -64,14 +74,16 @@ pub fn covers(make: Option<&str>, model: &str) -> bool { } let model = model.trim(); let brand = make.and_then(|m| m.split_whitespace().next()); - MODELS.iter().any(|(b, m)| { - let bare = model + let bare_of = |b: &str| { + 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) - }) + .map_or(model, |(_, rest)| rest.trim_start()) + }; + let listed = MODELS.iter().any(|(b, m)| brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare_of(b).eq_ignore_ascii_case(m)); + let fuji = brand.is_none_or(|b| b.eq_ignore_ascii_case("FUJIFILM")) && FUJI_MODELS.iter().any(|m| bare_of("FUJIFILM").eq_ignore_ascii_case(m)); + listed || fuji } /// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). @@ -88,8 +100,17 @@ const MAX_POINTS: usize = 32; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KelvinPresets { points: Vec<(f32, [f32; 3])>, + /// The camera's neutral multipliers for Illuminant A and D65 when the presets come from such a pair + /// ([`Self::from_neutral_pair`]); the points are then the line between them at five temperatures. + #[serde(default)] + pair: Option<[[f32; 3]; 2]>, } +/// Illuminant A and D65 as chromaticities (CIE). +const A_XY: Xy = Xy::new(0.44757, 0.40745); +const D65_XY: Xy = Xy::new(0.31271, 0.32902); +const POINT_KELVIN: [f32; 5] = [2500.0, 3200.0, 4500.0, 6000.0, 8500.0]; + fn usable(m: [f32; 3]) -> bool { m.iter().all(|v| *v > 0.0 && v.is_finite()) } @@ -113,7 +134,7 @@ impl KelvinPresets { 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.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() }) + ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect(), pair: None }) } /// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …): @@ -135,6 +156,51 @@ impl KelvinPresets { KelvinPresets::new(kept) } + /// Presets from a camera's own neutral multipliers (red, green, blue, green = 1) for Illuminant A and D65, as + /// Fujifilm stores them in `WB_GRBLevelsStandard`. Two whites fix the camera's response only on the line + /// between them, and nothing but the colorimetry of the two lights is used to place a white off it: in + /// `(ln R/G, ln B/G)` mired is linear along the line (extended past both ends), the isotherms are taken + /// perpendicular to it (no tilt), and the tint is the Planckian locus's own CIE 1960 spacing per unit length + /// plus the tint the two lights themselves have (A is on the locus, D65 is about +9.5 off it). No other camera's + /// data is involved. `None` for unusable multipliers or two that make no curve. + pub fn from_neutral_pair(a: [f32; 3], d65: [f32; 3]) -> Option { + if !usable(a) || !usable(d65) { + return None; + } + let (a, d65) = ([a[0] / a[1], 1.0, a[2] / a[1]], [d65[0] / d65[1], 1.0, d65[2] / d65[1]]); + let (pa, pd) = (log_point(a), log_point(d65)); + let (ma, md) = (1e6 / cct::xy_to_temp_tint(A_XY).0, 1e6 / cct::xy_to_temp_tint(D65_XY).0); + let points = POINT_KELVIN.map(|k| { + let f = (1e6 / k as f64 - ma) / (md - ma); + let q = [pa[0] + f * (pd[0] - pa[0]), pa[1] + f * (pd[1] - pa[1])]; + (k, [q[0].exp() as f32, 1.0, q[1].exp() as f32]) + }); + let mut out = KelvinPresets::new(points)?; + out.pair = Some([a, d65]); + Some(out) + } + + /// [`Self::temp_tint`] for a neutral pair (see [`Self::from_neutral_pair`]). + fn pair_temp_tint(pair: [[f32; 3]; 2], q: [f64; 2]) -> Option<(f64, f64)> { + let (pa, pd) = (log_point(pair[0]), log_point(pair[1])); + let d = [pd[0] - pa[0], pd[1] - pa[1]]; + let len = d[0].hypot(d[1]); + // only a deserialized pair can repeat a point + if !(len > 0.0 && len.is_finite()) { + return None; + } + let t = [d[0] / len, d[1] / len]; + let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] }; + let r = [q[0] - pa[0], q[1] - pa[1]]; + let along = (r[0] * t[0] + r[1] * t[1]) / len; + let off = r[0] * n[0] + r[1] * n[1]; + let ((ta, tint_a), (td, tint_d)) = (cct::xy_to_temp_tint(A_XY), cct::xy_to_temp_tint(D65_XY)); + let ((u0, v0), (u1, v1)) = (locus_uv(ta), locus_uv(td)); + let tint_per_len = cct::TINT_SCALE * (u1 - u0).hypot(v1 - v0) / len; + let mired = 1e6 / ta + along * (1e6 / td - 1e6 / ta); + Some((1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K), tint_a + along * (tint_d - tint_a) + tint_per_len * off)) + } + /// 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)> { @@ -142,6 +208,10 @@ impl KelvinPresets { return None; } let q = log_point(wb_multipliers); + if let Some(pair) = self.pair { + let (t, tint) = Self::pair_temp_tint(pair, q)?; + return Some((t, tint.clamp(-MAX_TINT, MAX_TINT))); + } 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() { @@ -290,9 +360,52 @@ mod tests { 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] }; + let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3], pair: None }; assert_eq!(bad.temp_tint(g(1400.0, 1400.0)), None); - assert_eq!(KelvinPresets { points: vec![] }.temp_tint(g(1400.0, 1400.0)), None); + assert_eq!(KelvinPresets { points: vec![], pair: None }.temp_tint(g(1400.0, 1400.0)), None); + // and so does a stored neutral pair with equal endpoints + let same = KelvinPresets { points: vec![], pair: Some([[1.0, 1.0, 1.0]; 2]) }; + assert_eq!(same.temp_tint(g(1400.0, 1400.0)), None); + } + + /// An X-T20's stored pair (`302 366 866 17 302 646 498 21`). + fn xt20() -> (KelvinPresets, [f32; 3], [f32; 3]) { + let (a, d) = ([366.0 / 302.0, 1.0, 866.0 / 302.0], [646.0 / 302.0, 1.0, 498.0 / 302.0]); + (KelvinPresets::from_neutral_pair(a, d).unwrap(), a, d) + } + + #[test] + fn a_neutral_pair_reads_back_as_its_own_illuminants() { + let (p, a, d) = xt20(); + let (ta, tint_a) = p.temp_tint(a).unwrap(); + let (td, tint_d) = p.temp_tint(d).unwrap(); + let (want_t, want_tint) = cct::xy_to_temp_tint(D65_XY); + assert!((ta - cct::xy_to_temp_tint(A_XY).0).abs() < 2.0 && (tint_a - cct::xy_to_temp_tint(A_XY).1).abs() < 0.5, "{ta} {tint_a}"); + assert!((td - want_t).abs() < 2.0 && (tint_d - want_tint).abs() < 0.5, "{td} {tint_d}"); + // the points are the same reading at their temperatures, on the locus + for (k, m) in p.points.clone() { + let (t, tint) = p.temp_tint(m).unwrap(); + assert!((t - k as f64).abs() < 2.0 && tint.abs() < 12.0, "{k}: {t} {tint}"); + } + // greener light than the daylight pair: positive tint, as for a matrix camera + let (_, green) = p.temp_tint([d[0] * 1.06, 1.0, d[2] * 1.06]).unwrap(); + let (_, magenta) = p.temp_tint([d[0] / 1.06, 1.0, d[2] / 1.06]).unwrap(); + assert!(green > magenta + 15.0, "{green} {magenta}"); + } + + #[test] + fn unusable_pairs_give_nothing() { + assert_eq!(KelvinPresets::from_neutral_pair([0.0, 1.0, 1.0], [2.0, 1.0, 1.5]), None); + } + + #[test] + fn fujifilm_x_trans_iii_models_are_covered() { + for c in ["FUJIFILM X-T20", "Fujifilm X-T2", "FUJIFILM X100F", "FUJIFILM X-H1", "FUJIFILM X-Pro2", "FUJIFILM X-E3"] { + assert!(covers_camera(c), "{c}"); + } + for c in ["FUJIFILM X-T3", "FUJIFILM X-T30", "SONY X-T20", "FUJIFILM GFX 100"] { + assert!(!covers_camera(c), "{c}"); + } } #[test] diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 42ed29ce8..b60a74266 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -118,6 +118,19 @@ pub(crate) fn unpack_row(src: &[u8], bits: u32, words32: bool, out: &mut [u16]) } } +/// The Kelvin scale of a body without colour matrices, from tag `0xf00c` (`WB_GRBLevelsStandard`; tag number from +/// `exiftool -v3` run as a black-box tool on the files): `G R B 17 G R B 21`, the camera's own neutral multipliers +/// for Illuminant A and D65 (see [`crate::kelvin_presets::KelvinPresets::from_neutral_pair`]). +fn fuji_kelvin_presets(ifd: &Ifd) -> Option { + let v = ifd.f64s(0xf00c).filter(|v| v.len() >= 8)?; + let grb = |at: usize, code: f64| { + (v[at + 3] == code && v[at] > 0.0 && v[at + 1] > 0.0 && v[at + 2] > 0.0) + .then(|| [(v[at + 1] / v[at]) as f32, 1.0, (v[at + 2] / v[at]) as f32]) + }; + let (a, d65) = (grb(0, 17.0)?, grb(4, 21.0)?); + crate::kelvin_presets::KelvinPresets::from_neutral_pair(a, d65) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let h = header(bytes)?; let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?; @@ -191,6 +204,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let wb = ifd.f64s(0xf00e).filter(|v| v.len() >= 3 && v.iter().take(3).all(|x| *x > 0.0)).map(|v| [(v[1] / v[0]) as f32, 1.0, (v[2] / v[0]) as f32]); let white = white_from_data(&data, bits); + let kelvin_presets = fuji_kelvin_presets(&ifd); let mut metadata = h.jpeg.map(lightcraft_meta::extract).unwrap_or_default(); metadata.width = Some(active.width as u32); @@ -209,7 +223,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: active, crop: Rect::new(0, 0, active.width, active.height), orientation, - color: ColorData::default(), + color: ColorData { kelvin_presets, ..Default::default() }, wb_multipliers: wb, linearized: false, pixel_aspect: 1.0, diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 7a1235ab3..f4f6d9b80 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -54,6 +54,8 @@ Vendor RGB WB multipliers alone do **not** identify an absolute illuminant witho 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. +**Kelvin through a Fujifilm body's stored neutral pair (issue #730).** Fujifilm RAFs carry, in tag `0xf00c` of the raw IFD (`WB_GRBLevelsStandard`; tag number from `exiftool -v3` run as a black-box tool on the files), the camera's own neutral multipliers for Illuminant A (2856 K) and D65 (6504 K): `G R B 17 G R B 21`. For the X-Trans III bodies without measured matrices (`lightcraft_raw::kelvin_presets`: X-T20, X-T2, X-Pro2, X-E3, X-H1, X100F) they take the matrices' place for the Kelvin scale only, with the same decision, adaptation, pixel-identical upgrade and `Op::SetAsShotWb` as above. Two whites fix a camera's response only on the line between them, so nothing but the colorimetry of the two lights places a white off it (`KelvinPresets::from_neutral_pair`): in log red/green and blue/green mired is linear along the line, extended past both ends; the isotherms are perpendicular to it; the tint is the Planckian locus's own CIE 1960 spacing per unit length, plus the tint the two lights have (A on the locus, D65 about +9.5). No other camera's data is used, and the colour look stays the file's JPEG fit. Scored (never fitted) against the three bodies in the spectral table that have a spectral response (X-T3, X-T4, GFX 100), on a grid of 3200 to 9000 K and tints of ±20, this plain line reads whites to 6.8 mired rms (21 at worst) and 3.0 tint rms (8 at worst); beyond that grid, to 2500 to 12000 K, 11 mired rms. That is the accepted accuracy of a two-point Kelvin scale: As Shot and any white near the line are right, a strongly green or magenta light is read a few mired and a few tint off. Reading `DSCF2947.raf` (X-T20): 6193 K / +16 (a Lightroom reading of the same file, 6050 K / +22, is a sanity check, not an input). + What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust. diff --git a/docs/raw-parity.md b/docs/raw-parity.md index bd17a3d32..1d0f48df0 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -218,7 +218,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 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) | +| 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`), or the neutral pair a Fujifilm X-Trans III RAF stores (6 bodies, #730); other raws keep the relative scale (#296) | **How far the default rendering is from Adobe's** (CIE ΔE; measured where stated):