mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user