From 1a5c4665da63d72fc2415079573ed15ae11572a3 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Mon, 12 Oct 2026 10:57:55 -0400 Subject: [PATCH 1/3] Fit a DNG's tone to its embedded preview when the file defines none For a DNG with its own colour matrix, an identity or absent ProfileToneCurve, no LookTable and no ProfileGainTableMap, fit only the tone and chroma curves to the camera JPEG, with the file's colour model untouched (profile tables and embedded vignetting applied to the sensor proxy as the loader renders it). The fit is used only when the preview is at least 90% of the raw's long edge, shows the same picture (structure gate), passes the existing held-out acceptance, and does not put more of the photo near white than the plain render (highlight gate, at 2x proxy luminance). Otherwise the render is unchanged. Other formats and catalogued photos are untouched. Render cache version 62. --- crates/engine/src/camera_preview.rs | 297 ++++++++++++++++++++++++++-- crates/engine/src/files.rs | 4 +- crates/engine/src/media.rs | 2 +- 3 files changed, 288 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..6521d4a15 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -69,8 +69,26 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) +pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&lightcraft_develop::EmbeddedLens>) -> (Xy, CameraTransform, Option) { + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, lens); + return (xy, t, look); + } + (xy, t, look) +} + +/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). +fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { + curve.is_none_or(|c| (0..=32).all(|i| { let x = i as f32 / 32.0; (c.eval(x) - x).abs() < 0.005 })) +} + +/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no +/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). +pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { + let p = &raw.color.profile; + raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.look_table.is_none() + && p.gain_table_map.is_none() } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -112,7 +130,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0) else { return (None, false) }; let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). @@ -265,14 +283,151 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { }) } +/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model +/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near +/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than +/// the plain render ([`clips_more`]). +fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { + let (mut sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION)?; + // As the loader renders it: the profile's tables around the baseline exposure. + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); + } + correct_vignetting(&mut sensor, lens, raw.orientation); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return None; + } + if !structure_agrees(&sensor, &reference) { + return None; + } + let unclipped = without_clipped(&sensor, &clipped); + let fit_on = unclipped.as_ref().unwrap_or(&sensor); + let colour = Some((Mat3::IDENTITY, None)); + let look = TONE_FITS.iter().find_map(|&tone| { + [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges })) + }); + let look = look?; + let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { + Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), + None => ToneMap::new(0.0, 0.0, 0.0), + }; + if clips_more(&sensor, &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), &plain) { + return None; + } + Some(look) +} + +/// Whether the fitted tone map puts more of the photo's proxy pixels at or near white than the plain render does. +fn clips_more(sensor: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { + let near_white = |tone: &ToneMap| { + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(CLIP_HEADROOM * luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + n as f64 / sensor.data.len().max(1) as f64 + }; + let (a, b) = (near_white(fitted), near_white(plain)); + a > b +} + +/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the +/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the +/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. +fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { + let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; + let (w, h) = (sensor.width as f64, sensor.height as f64); + let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + let width = sensor.width.max(1); + for (i, p) in sensor.data.iter_mut().enumerate() { + let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); + let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; + *p = p.map(|v| v * gain); + } +} + +/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a +/// small thumbnail is not fitted. +const MIN_PREVIEW_FRACTION: f64 = 0.9; + +/// Luminance headroom of the highlight gate: the 96 px proxy averages small bright spots away, so [`clips_more`] +/// looks at the proxy at this multiple of its luminance (chosen on the CC0 and user DNGs: 1 passes a fit that clips +/// the brightest 0.04% of a photo, 2 refuses it while keeping most fits). +const CLIP_HEADROOM: f32 = 2.0; + +/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. +const MIN_STRUCTURE: f64 = 0.85; + +/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance +/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) +/// correlates better. +fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { + let (w, h) = (sensor.width, sensor.height); + if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { + return false; + } + let log = |img: &Rgb32f| -> Vec { + let l: Vec = + img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (mut sum, mut n) = (0.0, 0.0); + for yy in y.saturating_sub(2)..(y + 3).min(h) { + for xx in x.saturating_sub(2)..(x + 3).min(w) { + if let Some(v) = l.get(yy * w + xx) { + sum += v; + n += 1.0; + } + } + } + sum / f64::max(n, 1.0) + }) + .collect() + }; + let (a, b) = (log(sensor), log(reference)); + const M: usize = 3; + let corr = |dx: isize, dy: isize| -> f64 { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in M..h - M { + for x in M..w - M { + let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); + let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; + n += 1.0; + sa += u; + sb += v; + saa += u * u; + sbb += v * v; + sab += u * v; + } + } + let var = (saa - sa * sa / n) * (sbb - sb * sb / n); + if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } + }; + let at_zero = corr(0, 0); + let (mut best, mut shift) = (at_zero, (0isize, 0isize)); + for dy in -(M as isize)..=M as isize { + for dx in -(M as isize)..=M as isize { + let c = corr(dx, dy); + if c > best + 1e-9 { + (best, shift) = (c, (dx, dy)); + } + } + } + at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 +} + /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec)> { +fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize, min_fraction: f64) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; - let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); + // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) { + return None; + } + let mut reference = decoded.to_working(); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { return None; } @@ -474,7 +629,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0)?; if !monochrome(&reference) { return None; } @@ -1748,7 +1903,7 @@ mod tests { assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); @@ -1807,13 +1962,13 @@ mod tests { #[test] fn a_camera_outside_the_table_keeps_the_fallback() { let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); // without a model there is no table row let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback); } /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is @@ -1836,7 +1991,7 @@ mod tests { 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 before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, None); let spectral = !own && fitted.is_none() && covered; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); @@ -2224,7 +2379,7 @@ mod tests { let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); @@ -2810,4 +2965,122 @@ mod tests { reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); } + + /// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. + fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { + let scene = |x: f32, y: f32| -> [f32; 3] { + let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; + [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); + } + } + (sensor, reference) + } + + /// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a + /// flat and a differently sized preview. + #[test] + fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { + let (sensor, reference) = scene_pair(96, 64, 0); + assert!(structure_agrees(&sensor, &reference)); + assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); + assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); + let mut stripes = reference.clone(); + for (i, p) in stripes.data.iter_mut().enumerate() { + *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; + } + assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); + let mut flat = reference.clone(); + flat.data.fill([0.2; 3]); + assert!(!structure_agrees(&sensor, &flat)); + assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); + } + + /// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that + /// renders mid-grey brighter than the default tone map is followed. + #[test] + fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { + let (mut sensor, mut reference) = scene_pair(96, 64, 0); + for image in [&mut sensor, &mut reference] { + for p in image.data.iter_mut().step_by(5) { + *p = [p[0] * 1.6, p[1], p[2] * 0.5]; + } + } + let colour = Some((Mat3::IDENTITY, None)); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false }).expect("tone fitted"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let target = 1.0 - (-2.5f32 * 0.18).exp(); + assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); + assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); + assert!(!clips_more(&sensor, &fitted, &fitted), "a tone never clips more than itself"); + } + + /// The highlight gate compares the share of near-white pixels of two tone maps, in one direction only. + #[test] + fn dng_highlight_gate_compares_near_white_shares() { + let mut sensor = Rgb32f::new(32, 32); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; + } + let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); + assert!(clips_more(&sensor, &bright, &dark) != clips_more(&sensor, &dark, &bright)); + assert!(!clips_more(&sensor, &bright, &bright)); + } + + /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. + #[test] + fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let lens = EmbeddedLens { + vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() + }; + let mut proxy = Rgb32f::new(96, 64); + proxy.data.fill([0.2; 3]); + correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); + let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); + assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); + let mut plain = Rgb32f::new(96, 64); + plain.data.fill([0.2; 3]); + correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); + assert!(plain.data.iter().all(|p| *p == [0.2; 3])); + } + + /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the + /// identity, no look table; and no other raw format. + #[test] + fn dng_tone_gates_follow_the_files_own_tags() { + use lightcraft_raw::profile::{ToneCurve, HsvTable}; + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { + let raw = raw_of(format, "Make", "Model", color); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + dng_tone_eligible(&raw, &t) + }; + assert!(eligible(RawFormat::Dng, color.clone())); + assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); + assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); + let mut identity = color.clone(); + identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); + assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); + let mut curved = color.clone(); + curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); + let mut looked = color; + looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); + assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); + } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..afed9b77d 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -403,7 +403,7 @@ fn load_bytes_now( // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -472,7 +472,7 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| { + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)).then(|| { let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) }); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; From f89b70e1c11fb8df0c05cf6d0332aab7a53551d2 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Mon, 12 Oct 2026 11:36:22 -0400 Subject: [PATCH 2/3] Tone-only DNG fit: train on dark pixels, compare highlights with the camera preview too --- crates/engine/src/camera_preview.rs | 105 ++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 6521d4a15..86d6f5673 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -289,7 +289,14 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { /// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than /// the plain render ([`clips_more`]). fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { - let (mut sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION)?; + // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. + let refuse = |why: &str| { + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE refused {why}"); + } + None + }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION) else { return refuse("preview") }; // As the loader renders it: the profile's tables around the baseline exposure. let xy = lightcraft_raw::color::as_shot_white_xy(raw); if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { @@ -298,36 +305,42 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: } correct_vignetting(&mut sensor, lens, raw.orientation); if (sensor.width, sensor.height) != (reference.width, reference.height) { - return None; + return refuse("preview"); } if !structure_agrees(&sensor, &reference) { - return None; + return refuse("structure"); } let unclipped = without_clipped(&sensor, &clipped); let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges })) + [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) }); - let look = look?; + let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), None => ToneMap::new(0.0, 0.0, 0.0), }; - if clips_more(&sensor, &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), &plain) { - return None; + let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if clips_more(&sensor, &reference, &fitted, &plain) { + return refuse("clip"); + } + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE accepted"); } Some(look) } -/// Whether the fitted tone map puts more of the photo's proxy pixels at or near white than the plain render does. -fn clips_more(sensor: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { +/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain +/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the +/// camera's look, one that clips what neither does loses highlight detail. +fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { let near_white = |tone: &ToneMap| { - let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(CLIP_HEADROOM * luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); n as f64 / sensor.data.len().max(1) as f64 }; - let (a, b) = (near_white(fitted), near_white(plain)); - a > b + let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; + near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE } /// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the @@ -349,10 +362,14 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb /// small thumbnail is not fitted. const MIN_PREVIEW_FRACTION: f64 = 0.9; -/// Luminance headroom of the highlight gate: the 96 px proxy averages small bright spots away, so [`clips_more`] -/// looks at the proxy at this multiple of its luminance (chosen on the CC0 and user DNGs: 1 passes a fit that clips -/// the brightest 0.04% of a photo, 2 refuses it while keeping most fits). -const CLIP_HEADROOM: f32 = 2.0; +/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark +/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. +const DARK_FLOOR: (f32, f32) = (0.004, 0.001); + +/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's +/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits +/// that clip about 1% more than the camera does and passes the ones that clip like the camera). +const CLIP_TOLERANCE: f64 = 0.001; /// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. const MIN_STRUCTURE: f64 = 0.85; @@ -633,7 +650,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option f32 { /// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated /// colours (see [`EDGE_CONTRAST`]). type Pairs = Vec<([f64; 3], [f64; 3])>; -fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option) -> Option<(Pairs, Pairs)> { +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -724,7 +741,8 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi if (0.015..=1.0).contains(&y) { bright.push((input.map(f64::from), output.map(f64::from))); } - if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) { + let (ylo, clo) = if dark { DARK_FLOOR } else { (0.015, 0.004) }; + if !output.iter().all(|v| *v > clo && *v < 0.98) || !(ylo..0.85).contains(&y) { continue; } let min = output.iter().copied().fold(f32::INFINITY, f32::min); @@ -805,6 +823,8 @@ struct Attempt { tone: ToneFit, profile: bool, away_from_edges: bool, + /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. + dark: bool, } /// The photo's look and the attempt that produced it: its own matrix (with and without a @@ -821,7 +841,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt order.iter().find_map(|&tone| { profiles.iter().find_map(|&profile| { [false, true].into_iter().find_map(|away_from_edges| { - let attempt = Attempt { tone, profile, away_from_edges }; + let attempt = Attempt { tone, profile, away_from_edges, dark: false }; fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) }) }) @@ -830,7 +850,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, attempt: Attempt) -> Option { let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone) + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark) } fn fit_pairs_on( @@ -839,10 +859,11 @@ fn fit_pairs_on( colour: Option<(Mat3, Option)>, edge_limit: Option, tone_fit: ToneFit, + dark: bool, ) -> Option { // A known colour model needs enough signal for tone fitting, not a scene rich enough to // learn a new colour matrix. Still reject monochrome references. - let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; let candidates = match colour { Some(given) => vec![given], None => { @@ -988,7 +1009,7 @@ fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option { fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) -> Option { // as for a known colour model: enough signal for the tone, but no monochrome reference - let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; let related = rank_correlation(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1098,7 +1119,7 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option { // every pair counts: the JPEG has no colour to require - let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?; + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; let related = rank_correlation_ties(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1699,7 +1720,7 @@ mod tests { let mut pool = Vec::new(); for seed in 0..4 { let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); - pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); } let (matrix, _) = fit_profile(&pool).unwrap(); // the camera's colours (luminance-normalised: the tone curve sets brightness) @@ -2645,7 +2666,7 @@ mod tests { assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap(); - assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false }); + assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); } @@ -3017,17 +3038,18 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false }).expect("tone fitted"); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }).expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let target = 1.0 - (-2.5f32 * 0.18).exp(); assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); - assert!(!clips_more(&sensor, &fitted, &fitted), "a tone never clips more than itself"); + assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); } - /// The highlight gate compares the share of near-white pixels of two tone maps, in one direction only. + /// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the + /// camera preview's, in one direction only: clipping what the camera clips is not refused. #[test] fn dng_highlight_gate_compares_near_white_shares() { let mut sensor = Rgb32f::new(32, 32); @@ -3035,8 +3057,29 @@ mod tests { *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; } let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); - assert!(clips_more(&sensor, &bright, &dark) != clips_more(&sensor, &dark, &bright)); - assert!(!clips_more(&sensor, &bright, &bright)); + let mut white = Rgb32f::new(32, 32); + white.data.iter_mut().for_each(|p| *p = [1.0; 3]); + let mut black = Rgb32f::new(32, 32); + black.data.iter_mut().for_each(|p| *p = [0.1; 3]); + assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); + assert!(!clips_more(&sensor, &black, &dark, &bright)); + assert!(!clips_more(&sensor, &black, &bright, &bright)); + assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); + } + + /// A dark scene's shadows below the usual training floor still reach the tone-only fit. + #[test] + fn dng_tone_fit_trains_on_dark_pixels() { + let mut sensor = Rgb32f::new(64, 64); + let mut reference = Rgb32f::new(64, 64); + for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { + let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; + *s = [v, v * 1.1, v * 0.9]; + *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; + } + let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); + let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); + assert!(dark > usual, "{dark} vs {usual}"); } /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. From 0391362f2fef3db6cd659fde469eb2995dd2c7b3 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Mon, 12 Oct 2026 12:48:47 -0400 Subject: [PATCH 3/3] Format the DNG tone fit with rustfmt --- crates/engine/src/camera_preview.rs | 30 ++++++++++++++++++++++------- crates/engine/src/files.rs | 12 +++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 86d6f5673..39b0d1b1c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -69,7 +69,11 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&lightcraft_develop::EmbeddedLens>) -> (Xy, CameraTransform, Option) { +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Xy, CameraTransform, Option) { let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)); if look.is_none() && dng_tone_eligible(raw, &t) { let look = fit_dng_tone(raw, bytes, &t, lens); @@ -80,14 +84,22 @@ pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&li /// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { - curve.is_none_or(|c| (0..=32).all(|i| { let x = i as f32 / 32.0; (c.eval(x) - x).abs() < 0.005 })) + curve.is_none_or(|c| { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (c.eval(x) - x).abs() < 0.005 + }) + }) } /// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no /// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { let p = &raw.color.profile; - raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.look_table.is_none() + raw.format == RawFormat::Dng + && !t.matrix_is_fallback + && identity_tone_curve(p.tone_curve.as_ref()) + && p.look_table.is_none() && p.gain_table_map.is_none() } @@ -314,7 +326,9 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) + [false, true] + .into_iter() + .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) }); let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { @@ -441,7 +455,8 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any - if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) { + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) + { return None; } let mut reference = decoded.to_working(); @@ -3038,7 +3053,8 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }).expect("tone fitted"); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }) + .expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); @@ -3105,7 +3121,7 @@ mod tests { /// identity, no look table; and no other raw format. #[test] fn dng_tone_gates_follow_the_files_own_tags() { - use lightcraft_raw::profile::{ToneCurve, HsvTable}; + use lightcraft_raw::profile::{HsvTable, ToneCurve}; let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index afed9b77d..d1c0e2747 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -403,7 +403,8 @@ fn load_bytes_now( // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = + rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -472,10 +473,11 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)).then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) + .then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let local_tone = local_tone(&raw); let (temp, tint) = scale.as_shot(xy);