Merge PR #1545: LR-RAW-DNG: preserve lens corrections in the existing DNG tone-fit proxy

crates/engine/src/camera_preview.rs: LOOK_VERSION keeps both history lines and the higher value.
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
This commit is contained in:
aiongg
2026-10-22 02:38:07 +00:00
5 changed files with 169 additions and 11 deletions
+26 -1
View File
@@ -37,6 +37,7 @@ use lightcraft_raw::{
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699).
/// 10: tone-only fallback for near-black scenes with too few colour pairs (#1587).
/// 10: DNG tone fitting uses a lens-corrected proxy when the file carries lens opcodes.
pub const LOOK_VERSION: u32 = 12;
#[derive(Clone, Debug)]
@@ -958,7 +959,10 @@ fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometr
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
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,
})
}
@@ -4304,6 +4308,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, with or without a look table; and no other raw format.
#[test]
+11 -5
View File
@@ -176,12 +176,17 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
}
/// 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<crate::media::SourceRef>, 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<Value> {
}
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<std::threa
return None;
}
let max_edge = crate::media::SourceLevel::Preview.max_edge();
let photos: Vec<_> = 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<SmartJob> =
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()
+106 -1
View File
@@ -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);
@@ -1831,6 +1844,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<u8> = 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 {
+11 -4
View File
@@ -1411,9 +1411,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();
@@ -1423,11 +1429,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());
+15
View File
@@ -110,6 +110,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.