diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..bf01ff6cf 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -10,7 +10,11 @@ use lightcraft_raster::{ Rgb32f, resample::{Filter, fit, resize}, }; -use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable}; +use lightcraft_raw::{ + ColorData, RawFormat, RawImage, + color::CameraTransform, + profile::{HsvTable, ProfileLook, ToneCurve}, +}; /// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone /// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because @@ -107,6 +111,68 @@ fn starting_colour_with( (xy, t, look) } +/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo +/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered +/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them). +pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option { + if lightcraft_raw::color::has_matrix(&raw.color) { + return None; + } + let (_, t, look) = starting_colour(raw, bytes); + dng_colour(&raw.color, &t, look.as_ref()) +} + +/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward +/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation +/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table +/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two +/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo +/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). +/// `None` when the white balance or the matrix can't give tags. +fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option { + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes + // into the baseline exposure + let white = m.apply([1.0; 3]); + let k = (white[0] + white[1] + white[2]) / 3.0; + if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) { + return None; + } + let m = Mat3(m.0.map(|r| r.map(|v| v / k))); + let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); + let wb = t.wb.map(f64::from); + let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); + let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2])); + Some(ColorData { + illuminant: [21, 0], + color_matrix: [Some(camera_to_xyz.inverse()?), None], + forward_matrix: [Some(forward), None], + camera_calibration: [None, None], + analog_balance: None, + as_shot_neutral: Some(neutral), + as_shot_white_xy: None, + baseline_exposure: t.baseline_exposure + k.log2(), + baseline_sharpness: tags.baseline_sharpness, + profile: ProfileLook { + hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], + tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), + ..Default::default() + }, + }) +} + +/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the +/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). +fn dng_tone_curve(tone: &CameraTone) -> Option { + const N: usize = 256; + let samples = (0..=N).map(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32); + [x, tone.apply(x).min(1.0)] + }); + let flat: Vec = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect(); + ToneCurve::from_tag(&flat) +} + /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { @@ -2062,6 +2128,43 @@ mod tests { assert!(info.camera_tone.is_some(), "no camera look fitted"); } + /// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same + /// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation + /// table and its tone curve; without a look, those of the camera transform. + #[test] + fn dng_colour_renders_like_the_raws_starting_colour() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37)); + assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb); + let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); + for look in [Some(&look), None] { + let dng = dng_colour(&ColorData::default(), &t, look).unwrap(); + let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); + for i in 0..3 { + assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); + } + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + for c in samples { + let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c)); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}"); + } + assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone())); + let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve); + match look { + Some(look) => { + let tone = tone.expect("the look's tone curve"); + let worst = + (0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max); + // (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here) + assert!(worst < 4e-3, "{worst}"); + } + None => assert!(tone.is_none()), + } + } + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 53c7f546e..6a51dc316 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -110,7 +110,9 @@ impl MergeJob { .par_iter() .map(|(_, path)| { let bytes = (self.read)(path)?; - let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}")); + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .map_err(|e| format!("{path}: {e}")); let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); f diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index a0784dd15..27a82fbed 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -67,6 +67,31 @@ fn hdr_merge_command_creates_and_imports_a_dng() { let _ = std::fs::remove_dir_all(&dir); } +/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with, +/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark). +#[test] +fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() { + let dir = temp_dir("hdr-colour"); + let neutral = [0.5, 1.0, 0.7]; + let mut paths = Vec::new(); + for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() { + // the same pixels with a white balance but no colour matrices + let mut raw = lightcraft_raw::decode(&b).unwrap(); + raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() }; + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap(); + let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap(); + assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color); + let got = merged.color.as_shot_neutral.expect("the sources' white balance"); + assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn panorama_command_with_auto_crop() { let dir = temp_dir("pano"); diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 360c7424e..69743a1b3 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -103,15 +103,28 @@ pub fn detect_clip(img: &Rgb32f) -> f32 { if maxv >= 1.0 || piled { level * 0.995 } else { 1.0 } } +/// The colour tags a raw source starts from when the caller knows better than the file (`None`: the +/// file's own tags). Called with the decoded raw, its samples still there, and the file's bytes. +pub type RawColour<'a> = dyn Fn(&mut lightcraft_raw::RawImage, &[u8]) -> Option + Sync + 'a; + /// Decode a photo (raw or standard format) into a frame no larger than `max_edge` (when given). /// `orient` applies the EXIF orientation to the pixels. pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result { + load_frame_with(bytes, max_edge, orient, &|_, _| None) +} + +/// [`load_frame`] with a raw's colour tags from `colour`: a raw without colour matrices of its own +/// (CR2, NEF, ARW…) starts from a colour the app works out (issue #965). +pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colour: &RawColour) -> Result { let metadata = lightcraft_meta::extract(bytes); let exposure = exif_exposure(&metadata); if lightcraft_raw::probe(bytes).is_some() { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; // geometric lens corrections stay with the develop settings ("profile corrections") raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(color) = colour(&mut raw, bytes) { + raw.color = color; + } let small = max_edge.is_some_and(|m| m <= 1200); let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index edfd19348..4b4e206e6 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -23,7 +23,7 @@ pub mod pano; pub mod ransac; pub mod synth; -pub use frame::{Frame, FrameColor, load_frame}; +pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with}; pub use hdr::{Deghost, HdrOptions, HdrResult, merge_hdr}; pub use output::{DngSamples, write_linear_dng}; pub use pano::{PanoOptions, PanoResult, Projection, stitch}; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..da1b87ed7 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -151,6 +151,22 @@ A proxy older than the constant is brought up to date by the existing Build Smar - when the original can't be read (offline drive), the proxy and its curve are kept exactly as they are (`staleKept`). The old version in its header is the mark: the next Build Smart Previews with the original online rebuilds it. A rebuilt proxy carries the current version, so nothing is rebuilt twice. A proxy is only ever rebuilt from the original, never from another proxy; - the two never write the same proxy at once: each proxy is checked and written under one lock. +## Photo Merge + +A merge (HDR, panorama, HDR panorama) writes a linear DNG of the sources' camera RGB, with the reference frame's colour +tags. A raw without colour matrices of its own has none to give, so its merge used to render with no camera colour: an +HDR merge of CR2s came out green and dark (issue #965). Each source now goes through `starting_colour` as when it is +opened alone, and `starting_colour_tags` writes the result as DNG tags: one forward matrix (from the look's matrix on +top of the camera transform) with a colour matrix and `AsShotNeutral` that agree with it at the as-shot white, the +look's brightness of white as baseline exposure, its hue/saturation table as `ProfileHueSatMap` and its tone curve as +`ProfileToneCurve`. Raws with their own matrices (DNG) keep their tags. Without a look the tags give the colour the raw +falls back to (spectral matrices or the neutral fallback). + +The DNG renders close to, not exactly like, the source: a DNG hue/saturation table doesn't keep each pixel's luminance +as the fitted table does, and the chroma curve has no DNG tag, so it is left out. On two CC0 CR2s (EOS 550D, 40D), an +HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th +percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..6dda8d1ee 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -346,7 +346,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..a7cea3bc0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-11 +### HDR and panorama merges of CR2 keep their colour +- Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and + ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come + out green and dark. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a