From b881b20b72edea6403564913d805c2a9e10e7a75 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Wed, 21 Oct 2026 23:49:04 -0400 Subject: [PATCH 1/2] Kelvin for Fujifilm X-Trans III bodies from the RAF's own neutral pair (#730) X-T20, X-T2, X-Pro2, X-E3, X-H1 and X100F have no measured colour matrix, so As Shot showed on the relative scale. Their RAFs store the camera's own neutral multipliers for Illuminant A and D65 (tag 0xf00c). Two points give a line in (ln R/G, ln B/G); mired is linear along it, isotherms are perpendicular, tint is scaled by the CIE locus spacing. Only the file's pair and CIE colorimetry are used. Bodies with matrices are unchanged. The look stays the per-file JPEG fit. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/engine/src/files/tests.rs | 20 +++++ crates/raw/src/kelvin_presets.rs | 122 +++++++++++++++++++++++++++++-- crates/raw/src/vendor/raf.rs | 16 +++- docs/camera-preview-colour.md | 2 + docs/raw-parity.md | 2 +- 5 files changed, 152 insertions(+), 10 deletions(-) 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..305650dcf 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,47 @@ 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]) -> (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]); + 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); + (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 +204,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 +356,49 @@ 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); + } + + /// 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): From 15a489c8a5fb8680019e9fc83abb656474e50f70 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 12:01:16 -0400 Subject: [PATCH 2/2] Kelvin presets: a stored neutral pair with equal endpoints reads as unusable pair_temp_tint divided by the zero line length of a repeated point, so a deserialized pair gave a non-finite temperature and tint instead of None. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/raw/src/kelvin_presets.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index 305650dcf..58ed39625 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -181,10 +181,14 @@ impl KelvinPresets { } /// [`Self::temp_tint`] for a neutral pair (see [`Self::from_neutral_pair`]). - fn pair_temp_tint(pair: [[f32; 3]; 2], q: [f64; 2]) -> (f64, f64) { + 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]]; @@ -194,7 +198,7 @@ impl KelvinPresets { 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); - (1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K), tint_a + along * (tint_d - tint_a) + tint_per_len * off) + 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 @@ -205,7 +209,7 @@ impl KelvinPresets { } let q = log_point(wb_multipliers); if let Some(pair) = self.pair { - let (t, tint) = Self::pair_temp_tint(pair, q); + 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); @@ -359,6 +363,9 @@ mod tests { 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![], 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`).