From cc75f0b765e42737fa18eb78bfd7019333964926 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Tue, 13 Oct 2026 07:23:23 -0400 Subject: [PATCH] LR-RAW-DNG: preserve lens corrections in the existing DNG tone-fit proxy --- crates/engine/src/camera_preview.rs | 29 +++++++- crates/engine/src/cmd/previews.rs | 16 +++-- crates/engine/src/files.rs | 107 +++++++++++++++++++++++++++- crates/engine/src/media.rs | 2 +- crates/engine/src/tests_import.rs | 15 ++-- docs/camera-preview-colour.md | 15 ++++ 6 files changed, 171 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 614c58c60..c08cb96a9 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -34,7 +34,8 @@ use lightcraft_raw::{ /// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. -pub const LOOK_VERSION: u32 = 9; +/// 10: DNG tone fitting uses a lens-corrected proxy when the file carries lens opcodes. +pub const LOOK_VERSION: u32 = 10; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -927,7 +928,10 @@ fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometr fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, - None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box), + // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution. + None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => { + fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box) + } None => return None, }) } @@ -3958,6 +3962,27 @@ mod tests { assert!(plain.data.iter().all(|p| *p == [0.2; 3])); } + /// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's + /// full-resolution corrections applied once rather than silently losing the tone fit. + #[test] + fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() { + use lightcraft_raw::Opcode; + let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]); + raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none()); + let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable"); + assert_eq!((proxy.width, proxy.height), (32, 24)); + let center = proxy.data[12 * 32 + 16][1]; + let corner = proxy.data[0][1]; + assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}"); + let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development"); + let small = sensor_proxy(&raw, 2, 16).unwrap(); + assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce"); + } + /// 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] diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index b63ccc2b4..0d8999989 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -176,12 +176,17 @@ fn build(s: &mut Session, p: &Value) -> Result { } /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, -/// how to load the original at preview size, whether the photo takes the per-file camera look — -/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those -/// proxies go stale when the fit changes). +/// how to load the original at preview size, whether the photo can take a preview-fitted look: +/// vendor raws and DNGs (whose tone can come from their preview). #[cfg(not(target_arch = "wasm32"))] type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); +/// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale. +#[cfg(not(target_arch = "wasm32"))] +fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool { + photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG")) +} + /// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look /// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once. #[cfg(not(target_arch = "wasm32"))] @@ -333,7 +338,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { } let path = crate::smart::sharded_file_path(&dir, &ph); let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview)); - jobs.push((id, path, source, ph.fitted_look())); + jobs.push((id, path, source, preview_fitted_look(&ph))); } let custom = s.smart_previews_dir.is_some(); if background { @@ -375,7 +380,8 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect(); + let photos: Vec<_> = + s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && preview_fitted_look(p)).collect(); let jobs: Vec = photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); std::thread::Builder::new() diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..644e08e1f 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -426,13 +426,26 @@ fn load_bytes_now( let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); + // A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit, + // then remove them again: the rendered source leaves those corrections to the pipeline. + let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction())) + .then(|| raw.opcodes.list3.clone()); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or // 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_geometry.as_ref(), lens.as_ref()), + || { + let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops)); + // With the opcodes restored, the proxy already includes vignetting; do not apply it twice. + let embedded = if removed.is_some() { None } else { lens.as_ref() }; + let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded); + if let Some(ops) = removed { + raw.opcodes.list3 = ops; + } + colour + }, || dng_mattes(&bytes, &info), ); drop(bytes); @@ -1830,6 +1843,98 @@ pub(crate) mod tests { assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); } + /// Corrections used while fitting a DNG's preview must not be baked into its editable source. + /// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. + #[test] + fn dng_preview_corrections_leave_the_render_source_uncorrected() { + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (before, expected) = load_bytes(&before, 64).unwrap(); + let (after, info) = load_bytes(&corrected, 64).unwrap(); + assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); + assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); + assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); + } + + /// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. + /// The source pixels stay uncorrected, including when the file is rotated. + #[test] + fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { + use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; + use lightcraft_raw::{Opcode, Orientation}; + let (w, h) = (384usize, 256usize); + let data = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; + let colour = match (i / w % 2, i % w % 2) { + (0, 0) => 1.2, + (1, 1) => 0.8, + _ => 1.0, + }; + 256 + (v * colour * 15744.0) as u16 + }) + .collect(); + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.color = lightcraft_raw::ColorData { + illuminant: [21, 0], + color_matrix: [Some(REC2020.from_xyz()), None], + as_shot_neutral: Some([1.0; 3]), + ..Default::default() + }; + let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![ + Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, + ]; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let to_srgb = REC2020.to_space(&SRGB); + let pixels: Vec = corrected + .data + .iter() + .flat_map(|p| { + let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); + let y = luminance_2020(p).max(1e-6); + let gain = (1.0 - (-3.0 * y).exp()) / y; + to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + for orientation in [Orientation::Normal, Orientation::Rotate90] { + raw.orientation = orientation; + let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + // Append a preview IFD to the little-endian DNG written above. + let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; + let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; + let next = root + 2 + 12 * count; + let start = file.len() as u32; + file[next..next + 4].copy_from_slice(&start.to_le_bytes()); + let jpeg_at = start + 2 + 4 * 12 + 4; + file.extend(4u16.to_le_bytes()); + for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { + file.extend(tag.to_le_bytes()); + file.extend(4u16.to_le_bytes()); + file.extend(1u32.to_le_bytes()); + file.extend(value.to_le_bytes()); + } + file.extend(0u32.to_le_bytes()); + file.extend(&jpeg); + let (source, info) = load_bytes(&file, 96).unwrap(); + let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); + let target = 1.0 - (-3.0f32 * 0.18).exp(); + assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); + let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); + assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); + } + } + /// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's /// own white-balanced neutral comes out yellow (blue 12 % low). fn yellowing_look() -> lightcraft_color::Mat3 { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..e6c438ad7 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 = 64; +pub const RENDER_CACHE_VERSION: u64 = 65; /// 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]; diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 3d6469deb..4797e7a81 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1348,9 +1348,15 @@ fn look_loader( /// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version. #[test] fn smart_previews_from_an_older_look_fit_are_refreshed() { + for format in ["ARW", "DNG"] { + smart_look_refresh_for(format); + } +} + +fn smart_look_refresh_for(format: &str) { use std::sync::atomic::{AtomicUsize, Ordering}; - let src = temp_dir("smartlook-src"); - let lib = temp_dir("smartlook-lib"); + let src = temp_dir(&format!("smartlook-src-{format}")); + let lib = temp_dir(&format!("smartlook-lib-{format}")); write_png(&src.join("a.png"), 3); write_png(&src.join("b.png"), 9); let mut s = Session::new().with_fs(); @@ -1360,11 +1366,12 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) let mut raw = (**s.catalog.photo(id).unwrap()).clone(); - raw.format = "ARW".into(); + raw.format = format.into(); raw.kind = lightcraft_catalog::MediaKind::Raw; s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb()); + assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); + assert!(!s.catalog.photo(png).unwrap().relative_wb()); s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); let dir = s.media.smart_dir.clone().unwrap(); let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 9a259e9ed..fa577f61b 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -109,6 +109,21 @@ On a local set of 54 ARW6 and lossless ARW photos (the maintainer's own shots pl A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 11 of 15 photos regressed, 3 improved and 1 was unchanged, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The 42 private photos are one photographer's scenes, 38 of them in the Standard creative style (the 12 RawDB files were not inventoried); revisit when a varied multi-photographer set exists. +## DNG previews with embedded lens corrections + +A DNG with its own colour model and no non-identity profile tone curve uses the existing +`camera_preview::fit_dng_tone` path, including its eligibility and acceptance checks. There is +one preview fit. Files with a look table, a gain table map or an unusable preview keep their +existing behaviour. + +When the file carries lens opcodes, that fit develops its sensor proxy through those opcodes +before reducing it: its camera preview already contains their distortion and vignetting +corrections. This uses the raw decoder's existing opcode implementation, including chained +warps. The separate proxy vignetting multiplier is skipped in this case. The editable source +still has lens corrections removed, so the pipeline applies them once through Enable Profile +Corrections. Developing this proxy requires a full-resolution demosaic; files without such +opcodes keep their binned proxy. + ## Camera profiles (pooled per model) One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.