From 25314aaacf39ed2c0616237489ae5bde1bbc94f6 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:09:47 +0300 Subject: [PATCH 1/2] LR-MERGE-PANO: a panorama of raws keeps the as-shot white balance of its frames (#1144) A raw whose white balance is only in the vendor's multipliers (CR2, NEF, ARW...) has no AsShotNeutral of its own. Loading a frame dropped the multipliers and stitch copied the first frame's colour tags, so a panorama of frames that reach the stitcher without a neutral wrote a DNG with neither AsShotNeutral nor AsShotWhiteXY, and a viewer had no white to start from (a strong colour cast until White Balance > Auto). Frames now carry the multipliers (Frame::wb_multipliers, filled from the decoded raw; load_frame_with is split so a decoded raw can be tested without a file). stitch takes the median R/G and B/G of the multipliers of the frames that are in the panorama (the mean of the middle two for an even count; frames without multipliers, or with non-finite or non-positive ones, do not vote) and sets the result's AsShotNeutral to their reciprocal, green = 1. The source's AsShotWhiteXY is dropped with it: a reader takes it before the neutral and the DNG spec makes the two exclusive. With no multipliers anywhere (DNG sources) the first frame's tags are kept, as before. In the app and the CLI a raw without colour matrices already gets a neutral from starting_colour_tags (#965). For frames balanced alike the new neutral renders the same (checked in the engine crate); it differs when the camera balanced the frames differently: the panorama follows the median instead of the first frame. Tests: the frame carries the multipliers; the consensus for odd and even counts, with missing and unusable values; stitching raw-style frames gives the median AsShotNeutral, ignores frames outside the panorama and leaves the colour as it was without multipliers; the written DNG, read back with the raw reader, has the reciprocal AsShotNeutral and the white balance the source's multipliers give. --- crates/engine/src/camera_preview.rs | 30 ++++ crates/engine/src/merge.rs | 1 + crates/merge/src/frame.rs | 250 ++++++++++++++++++++++++---- crates/merge/src/output.rs | 5 +- crates/merge/src/pano/mod.rs | 9 +- crates/merge/tests/white_balance.rs | 191 +++++++++++++++++++++ docs/camera-preview-colour.md | 11 ++ docs/parity-checklist.md | 5 +- 8 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 crates/merge/tests/white_balance.rs diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..fb714025c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -2404,6 +2404,36 @@ mod tests { } } + /// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without + /// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking + /// it from the multipliers again leaves the merged DNG rendering as the tags do. + #[test] + fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { + let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); + raw.wb_multipliers = Some(multipliers); + let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); + let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); + // the reciprocal of the multipliers, up to the scale a neutral is given in + let scale = neutral[1] * multipliers[1] as f64; + for i in 0..3 { + assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); + } + let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); + merged.set_as_shot_multipliers(multipliers); + let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; + let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); + for i in 0..3 { + assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); + } + for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { + assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); + } + assert_eq!(a.baseline_exposure, b.baseline_exposure); + } + } + /// 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 6a51dc316..c1b17be6d 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -183,6 +183,7 @@ impl MergeJob { orientation: r.orientation, metadata: r.metadata, baseline_exposure: r.baseline_exposure, + wb_multipliers: None, }; f.orient(); merged.push(f); diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 69743a1b3..ae91adffe 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -6,7 +6,7 @@ use lightcraft_geom::Orientation; use lightcraft_meta::Metadata; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use lightcraft_raw::ColorData; +use lightcraft_raw::{ColorData, RawImage}; use crate::{MergeError, Result}; @@ -19,6 +19,21 @@ pub enum FrameColor { Linear { to_xyz_d50: Mat3 }, } +impl FrameColor { + /// Make `multipliers` (green = 1) the as-shot white balance of camera colour. A DNG says it as `AsShotNeutral`, the + /// reciprocal of the multipliers. The source's `AsShotWhiteXY` goes: a reader takes it before the neutral and the + /// DNG spec has the two exclude each other, so a stale one would hide the white balance set here. Linear colour has + /// no white balance to set, and multipliers that aren't finite and above zero change nothing. + pub fn set_as_shot_multipliers(&mut self, multipliers: [f32; 3]) { + if let FrameColor::Camera(c) = self + && multipliers.iter().all(|v| v.is_finite() && *v > 0.0) + { + c.as_shot_neutral = Some(multipliers.map(|v| 1.0 / f64::from(v))); + c.as_shot_white_xy = None; + } + } +} + /// One input photo. #[derive(Clone, Debug)] pub struct Frame { @@ -36,6 +51,9 @@ pub struct Frame { pub metadata: Metadata, /// Raw files: the source's `BaselineExposure` (EV), carried into the output. pub baseline_exposure: f64, + /// Raw files: the as-shot white-balance multipliers from the vendor's maker notes (green = 1), for formats such + /// as CR2 and NEF that have no `AsShotNeutral` of their own. `None` when the file doesn't say. + pub wb_multipliers: Option<[f32; 3]>, } impl Frame { @@ -53,6 +71,7 @@ impl Frame { orientation: Orientation::Normal, metadata: Metadata::default(), baseline_exposure: 0.0, + wb_multipliers: None, } } @@ -81,6 +100,29 @@ pub fn exif_exposure(m: &Metadata) -> Option { Some((t * iso / (n * n)).log2()) } +/// The as-shot white balance `frames` agree on, as multipliers with green = 1: the median R/G and the median B/G of +/// the frames that have usable multipliers (all three finite and above zero), so one frame the camera balanced +/// differently doesn't move it. With an even number of voters the median is the mean of the middle two. Frames without +/// usable multipliers don't vote; `None` when none does. +pub fn consensus_multipliers<'a>(frames: impl IntoIterator) -> Option<[f32; 3]> { + let (mut r_over_g, mut b_over_g) = (Vec::new(), Vec::new()); + for [r, g, b] in frames.into_iter().filter_map(|f| f.wb_multipliers) { + if [r, g, b].iter().all(|v| v.is_finite() && *v > 0.0) { + r_over_g.push(f64::from(r) / f64::from(g)); + b_over_g.push(f64::from(b) / f64::from(g)); + } + } + Some([median(r_over_g)? as f32, 1.0, median(b_over_g)? as f32]) +} + +/// The median of `v` (the mean of the middle two when the count is even); `None` when empty. +fn median(mut v: Vec) -> Option { + v.sort_by(f64::total_cmp); + let upper = *v.get(v.len() / 2)?; + let lower = *v.get(v.len().checked_sub(1)? / 2)?; + Some((lower + upper) / 2.0) +} + /// The saturation level of a frame: 1.0, or the (lower) level at which values pile up when the /// file's white level is optimistic. Returned slightly below the level. pub fn detect_clip(img: &Rgb32f) -> f32 { @@ -119,41 +161,8 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo 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()))?; - for p in image.data.iter_mut() { - *p = p.map(|v| v.max(0.0)); - } - let clip = detect_clip(&image); - if let Some(m) = max_edge - && image.width.max(image.height) > m - { - image = fit(&image, m, m, Filter::Box); - } - let mut f = Frame { - image, - clip, - exposure, - raw: true, - color: FrameColor::Camera(Box::new(raw.color.clone())), - orientation: raw.orientation, - metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, - baseline_exposure: raw.color.baseline_exposure, - }; - if f.exposure.is_none() { - f.exposure = exif_exposure(&f.metadata); - } - if orient { - f.orient(); - } - return Ok(f); + let raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; + return raw_frame(raw, bytes, metadata, exposure, max_edge, orient, colour); } let opts = match max_edge { Some(m) => lightcraft_codecs::DecodeOptions::fit(m as u32, m as u32), @@ -176,6 +185,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo orientation: Orientation::from_exif(d.orientation), metadata, baseline_exposure: 0.0, + wb_multipliers: None, }; if orient { f.orient(); @@ -183,6 +193,54 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo Ok(f) } +/// A decoded raw (from `bytes`, which `colour` may read) as a frame: developed to camera RGB, with its colour tags, +/// its as-shot white-balance multipliers and its orientation. +fn raw_frame( + mut raw: RawImage, + bytes: &[u8], + metadata: Metadata, + exposure: Option, + max_edge: Option, + orient: bool, + colour: &RawColour, +) -> Result { + // 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()))?; + for p in image.data.iter_mut() { + *p = p.map(|v| v.max(0.0)); + } + let clip = detect_clip(&image); + if let Some(m) = max_edge + && image.width.max(image.height) > m + { + image = fit(&image, m, m, Filter::Box); + } + let mut f = Frame { + image, + clip, + exposure, + raw: true, + color: FrameColor::Camera(Box::new(raw.color.clone())), + orientation: raw.orientation, + metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, + baseline_exposure: raw.color.baseline_exposure, + wb_multipliers: raw.wb_multipliers, + }; + if f.exposure.is_none() { + f.exposure = exif_exposure(&f.metadata); + } + if orient { + f.orient(); + } + Ok(f) +} + #[cfg(test)] mod tests { use super::*; @@ -208,4 +266,124 @@ mod tests { } assert!((detect_clip(&img) - 0.9 * 0.995).abs() < 1e-6); } + + /// A 16×12 CR2-style mosaic (no colour tags, as the vendor decoders leave them) with as-shot multipliers. + fn mosaic(wb_multipliers: Option<[f32; 3]>) -> RawImage { + use lightcraft_raw::{BlackLevel, Cfa, OpcodeLists, RawData, RawFormat, Rect}; + let (w, h) = (16usize, 12usize); + RawImage { + format: RawFormat::Cr2, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + } + } + + fn frame_of(raw: RawImage, colour: &RawColour) -> Frame { + raw_frame(raw, &[], Metadata::default(), None, None, true, colour).unwrap() + } + + /// Issue #1144: the as-shot multipliers of a CR2, NEF… (its only white balance) were dropped on loading, so + /// nothing downstream could tell a merged DNG what the camera had balanced to. + #[test] + fn a_raw_frame_carries_the_as_shot_multipliers() { + let wb = [2.05, 1.0, 1.62]; + let f = frame_of(mosaic(Some(wb)), &|_, _| None); + assert!(f.raw); + assert_eq!(f.wb_multipliers, Some(wb)); + // colour tags the caller works out leave them alone + let own = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() }; + let f = frame_of(mosaic(Some(wb)), &|_, _| Some(own.clone())); + assert_eq!(f.wb_multipliers, Some(wb)); + assert_eq!(f.color, FrameColor::Camera(Box::new(own))); + // a file that gives none gives none + assert_eq!(frame_of(mosaic(None), &|_, _| None).wb_multipliers, None); + assert_eq!(Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).wb_multipliers, None); + } + + fn voter(wb_multipliers: Option<[f32; 3]>) -> Frame { + Frame { wb_multipliers, ..Frame::from_linear_rec2020(Rgb32f::new(2, 2), None) } + } + + fn consensus(wb: &[Option<[f32; 3]>]) -> Option<[f32; 3]> { + let frames: Vec = wb.iter().map(|m| voter(*m)).collect(); + consensus_multipliers(&frames) + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) + } + + #[test] + fn the_consensus_is_the_median_of_the_voters() { + // one voter is its own median + assert_eq!(consensus(&[Some([2.0, 1.0, 1.6])]), Some([2.0, 1.0, 1.6])); + // an odd count takes the middle one of each ratio, whichever frame it came from, whatever the order + let odd = [Some([2.4, 1.0, 1.4]), Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&odd), Some([2.0, 1.0, 1.6])); + // an outlier doesn't pull it + let outlier = [Some([2.0, 1.0, 1.6]), Some([2.1, 1.0, 1.5]), Some([1.9, 1.0, 1.7]), Some([5.0, 1.0, 0.7]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&outlier), Some([2.0, 1.0, 1.6])); + // an even count takes the mean of the middle two + let even = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + assert!(close(consensus(&even).unwrap(), [2.1, 1.0, 1.55]), "{:?}", consensus(&even)); + let two = [Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]; + assert!(close(consensus(&two).unwrap(), [2.5, 1.0, 1.5]), "{:?}", consensus(&two)); + // green is the unit even when a file's multipliers aren't normalised to it + assert!(close(consensus(&[Some([4.0, 2.0, 3.0])]).unwrap(), [2.0, 1.0, 1.5])); + } + + #[test] + fn frames_without_usable_multipliers_do_not_vote() { + assert_eq!(consensus(&[]), None); + assert_eq!(consensus(&[None, None]), None); + let bad = |m: [f32; 3]| consensus(&[Some(m)]); + assert_eq!(bad([0.0, 1.0, 1.5]), None); + assert_eq!(bad([2.0, 0.0, 1.5]), None); + assert_eq!(bad([2.0, 1.0, -1.5]), None); + assert_eq!(bad([f32::NAN, 1.0, 1.5]), None); + assert_eq!(bad([2.0, f32::INFINITY, 1.5]), None); + assert_eq!(bad([2.0, 1.0, f32::NEG_INFINITY]), None); + // mixed: the good ones decide, the missing and the unusable ones are not counted as anything + let mixed = [None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0]), Some([2.2, 1.0, 1.4]), Some([0.0, 0.0, 0.0])]; + assert!(close(consensus(&mixed).unwrap(), [2.1, 1.0, 1.5]), "{:?}", consensus(&mixed)); + assert_eq!(consensus(&[None, Some([2.0, 1.0, 1.6]), None]), Some([2.0, 1.0, 1.6])); + } + + #[test] + fn multipliers_become_the_reciprocal_as_shot_neutral() { + use lightcraft_color::{D50, Xy}; + let tagged = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.9]), as_shot_white_xy: Some(Xy::new(0.34, 0.35)), ..Default::default() }; + let mut color = FrameColor::Camera(Box::new(tagged.clone())); + color.set_as_shot_multipliers([2.0, 1.0, 1.6]); + let FrameColor::Camera(c) = &color else { panic!("still camera colour") }; + assert_eq!(c.as_shot_neutral, Some([0.5, 1.0, 1.0 / f64::from(1.6f32)])); + assert_eq!(c.as_shot_white_xy, None, "a stale AsShotWhiteXY would be read before the neutral"); + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..tagged.clone() }, "nothing else changes"); + // multipliers that aren't usable change nothing + for bad in [[0.0, 1.0, 1.0], [2.0, f32::NAN, 1.6], [2.0, 1.0, f32::INFINITY], [-2.0, 1.0, 1.6]] { + let mut color = FrameColor::Camera(Box::new(ColorData { as_shot_white_xy: Some(D50), ..Default::default() })); + let before = color.clone(); + color.set_as_shot_multipliers(bad); + assert_eq!(color, before, "{bad:?}"); + } + // linear colour has no white balance to set + let mut linear = Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).color; + let before = linear.clone(); + linear.set_as_shot_multipliers([2.0, 1.0, 1.6]); + assert_eq!(linear, before); + } } diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 8128644b6..2d966c2aa 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -4,8 +4,9 @@ //! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the //! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame. //! - Raw sources: the reference frame's DNG colour tags (`ColorMatrix*`, `ForwardMatrix*`, -//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over unchanged — the data is -//! still camera RGB. +//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over — the data is still camera +//! RGB. A raw with its white balance only in vendor multipliers (CR2, NEF…) has none of its own: the +//! result's `AsShotNeutral` comes from the frames' multipliers ([`FrameColor::set_as_shot_multipliers`]). //! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB, //! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1). diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 309505614..076c0b85e 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; pub use project::Projection; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::linalg; use crate::{MergeError, Progress, Result, check}; use blend::{Blender, Buf, push_pull}; @@ -362,6 +362,11 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re image = finish::fill_edges(&image, &alpha); } let r = &frames[used[0]]; + // the panorama's white balance is the median of the frames that are in it, not whichever frame came first + let mut color = r.color.clone(); + if let Some(multipliers) = consensus_multipliers(used.iter().filter_map(|&i| frames.get(i))) { + color.set_as_shot_multipliers(multipliers); + } Ok(PanoResult { image, alpha, @@ -375,7 +380,7 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re fov, focal: f_out, origin: (ox, oy), - color: r.color.clone(), + color, metadata: r.metadata.clone(), raw: r.raw, baseline_exposure: r.baseline_exposure, diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs new file mode 100644 index 000000000..4139250f6 --- /dev/null +++ b/crates/merge/tests/white_balance.rs @@ -0,0 +1,191 @@ +//! A panorama of raw files keeps their as-shot white balance (issue #1144). +//! +//! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has +//! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. + +use lightcraft_color::{D50, Xy}; +use lightcraft_geom::Orientation; +use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::output::color_data; +use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; +use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; +use lightcraft_raw::ColorData; + +type Multipliers = Option<[f32; 3]>; + +/// A camera that sees linear sRGB, with the colour matrices a DNG gives but no white balance of its own, as a raw +/// file whose white balance is only in the vendor multipliers would be after the app has worked its colour out. +fn camera_without_white_balance() -> ColorData { + let srgb = FrameColor::Linear { + to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()), + }; + ColorData { as_shot_neutral: None, ..color_data(&srgb) } +} + +fn raw_frame(image: Rgb32f, color: &ColorData, wb_multipliers: Multipliers) -> Frame { + let clip = detect_clip(&image); + Frame { + image, + clip, + exposure: None, + raw: true, + color: FrameColor::Camera(Box::new(color.clone())), + orientation: Orientation::Normal, + metadata: Metadata::default(), + baseline_exposure: 0.0, + wb_multipliers, + } +} + +/// Overlapping views of one scene, one frame per entry of `wb`, in order. +fn shoot(color: &ColorData, wb: &[Multipliers]) -> Vec { + let n = wb.len(); + let yaws: Vec = (0..n).map(|i| (i as f64 - (n as f64 - 1.0) / 2.0) * 25.0).collect(); + let views = lightcraft_merge::synth::pano_views(400, 300, 350.0, &yaws).unwrap(); + views.into_iter().zip(wb).map(|(image, m)| raw_frame(image, color, *m)).collect() +} + +fn pano(frames: Vec) -> PanoResult { + stitch(frames, &PanoOptions { projection: Projection::Cylindrical, ..Default::default() }, &no_progress).unwrap() +} + +fn neutral_of(res: &PanoResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +fn reciprocal(m: [f64; 3]) -> [f64; 3] { + m.map(|v| 1.0 / v) +} + +fn assert_close(got: [f64; 3], want: [f64; 3], tolerance: f64) { + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() <= tolerance), "{got:?} vs {want:?}"); +} + +/// The panorama as a DNG and read back with the repo's own raw reader. +fn dng_of(res: &PanoResult) -> lightcraft_raw::RawImage { + let bytes = write_linear_dng(&res.image, &res.color, Orientation::Normal, &res.metadata, res.baseline_exposure, DngSamples::U16).unwrap(); + lightcraft_raw::decode(&bytes).unwrap() +} + +const DAYLIGHT: [f32; 3] = [2.0, 1.0, 1.6]; + +#[test] +fn a_panorama_of_an_odd_number_of_raw_frames_has_their_median_white_balance() { + // three frames the camera balanced slightly differently: the middle R/G and the middle B/G, from different frames + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used, [0, 1, 2]); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); +} + +#[test] +fn a_panorama_of_an_even_number_of_frames_takes_the_mean_of_the_middle_two() { + let wb = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 4); + let (r, b) = ((f64::from(2.0f32) + f64::from(2.2f32)) / 2.0, (f64::from(1.6f32) + f64::from(1.5f32)) / 2.0); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([r, 1.0, b]), 1e-6); + // two frames: the mean of both + let res = pano(shoot(&camera_without_white_balance(), &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); +} + +#[test] +fn frames_without_multipliers_do_not_vote() { + // one frame of three has none (a DNG, say): the other two decide + let wb = [Some([2.0, 1.0, 1.6]), None, Some([2.2, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 3); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.1, 1.0, 1.5]), 1e-6); + // a first frame without them doesn't hide the others' + let wb = [None, Some(DAYLIGHT), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // unusable values don't count either + let wb = [Some([f32::NAN, 1.0, 1.0]), Some(DAYLIGHT), Some([0.0, 1.0, 1.4]), Some([2.0, f32::INFINITY, 1.6]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn a_frame_outside_the_panorama_does_not_vote() { + let color = camera_without_white_balance(); + let mut frames = shoot(&color, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)]); + // four photos of other scenes, balanced very differently: counted, they would be the majority + let others = lightcraft_scenes::demo_library().into_iter().filter(|s| s.kind != lightcraft_scenes::Kind::Canyon).take(4); + frames.extend(others.map(|scene| raw_frame(scene.render(400, 300), &color, Some([4.0, 1.0, 0.8])))); + assert_eq!(frames.len(), 7); + let res = pano(frames); + assert_eq!(res.used, [0, 1, 2], "the others don't connect"); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn without_any_multipliers_the_colour_is_the_first_frames_as_before() { + for source in [ + camera_without_white_balance(), + ColorData::default(), + ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }, + ] { + let frames = shoot(&source, &[None, None, None]); + let first = frames[0].color.clone(); + let res = pano(frames); + assert_eq!(res.color, first); + assert_eq!(neutral_of(&res), source.as_shot_neutral); + } + // a DNG's own AsShotWhiteXY survives when no frame brings multipliers + let xy = ColorData { as_shot_white_xy: Some(D50), ..camera_without_white_balance() }; + assert_eq!(pano(shoot(&xy, &[None, None, None])).color, FrameColor::Camera(Box::new(xy))); +} + +#[test] +fn the_consensus_replaces_a_neutral_or_white_point_the_first_frame_had() { + // a neutral the app worked out for the first frame, and a white point of the source: both give way + let had = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.8]), as_shot_white_xy: Some(Xy::new(0.31, 0.33)), ..camera_without_white_balance() }; + let res = pano(shoot(&had, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); + // everything else of the first frame's colour is as it was + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..had }); +} + +#[test] +fn the_dng_says_the_median_white_balance_as_as_shot_neutral() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let frames = shoot(&source, &wb); + let median = consensus_multipliers(&frames).unwrap(); + let res = pano(frames); + let raw = dng_of(&res); + // AsShotNeutral is the reciprocal of the median multipliers, to the precision of a TIFF rational + let got = raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"); + assert_close(got, reciprocal(median.map(f64::from)), 2e-6); + assert_close(got, [0.5, 1.0, 0.625], 2e-6); + assert_eq!(raw.color.as_shot_white_xy, None, "the DNG spec has the two tags exclude each other"); + // what a viewer starts from: the white and the camera transform the source's multipliers give + let want = lightcraft_raw::color::as_shot_white_of(&source, Some(median)); + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + assert!((xy.x - want.x).abs() < 1e-4 && (xy.y - want.y).abs() < 1e-4, "{xy:?} vs {want:?}"); + let (got, want) = (lightcraft_raw::color::camera_transform(&raw, xy), lightcraft_raw::color::camera_transform_of(&source, want)); + assert!(got.wb.iter().zip(want.wb).all(|(a, b)| (a - b).abs() < 1e-3), "{:?} vs {:?}", got.wb, want.wb); + // the colour model and the picture are the source's, and the same without the colour matrices + assert_eq!(raw.color.forward_matrix[0].is_some(), source.forward_matrix[0].is_some()); + assert_eq!((raw.width, raw.height, raw.cpp), (res.image.width, res.image.height, 3)); + let bare = dng_of(&pano(shoot(&ColorData::default(), &wb))); + assert_close(bare.color.as_shot_neutral.expect("AsShotNeutral without colour matrices"), [0.5, 1.0, 0.625], 2e-6); +} + +#[test] +fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { + let res = pano(shoot(&ColorData::default(), &[None, None, None])); + let raw = dng_of(&res); + assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..f092e88f0 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -194,6 +194,17 @@ as the fitted table does, and the chroma curve has no DNG tag, so it is left out 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). +A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama takes its white balance +from the frames that are in it: the median R/G and the median B/G of their multipliers (the mean of the middle two +for an even count; frames without usable multipliers, or outside the panorama, do not vote), written as +`AsShotNeutral`, the reciprocal, and any `AsShotWhiteXY` of the source dropped (a reader takes it first, and the +DNG spec has the two exclude each other). So the panorama is balanced for what the camera measured for most of its +frames, not for whichever frame came first. With no multipliers anywhere (DNG sources) the first frame's tags are kept +as before. For frames balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG +renders as before; it matters when the camera balanced the frames differently, and for a caller that loads frames +without a colour hook (`load_frame`), whose raw frames carried no white balance at all (issue #1144). + ## 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 76d91ba3e..7fea9cf66 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO: a panorama of raws takes its as-shot white balance from the median of its frames (#1144); 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 @@ -348,7 +348,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; 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-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; 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) | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | | 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-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 0cd7513a8d76a35436efb2d528889afb969965c9 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:14:16 +0300 Subject: [PATCH 2/2] LR-MERGE-HDR: an HDR merge of raws keeps the as-shot white balance of its inputs (#1144) The HDR merge has the same cause as the panorama: its result took the reference frame's colour tags, and a raw whose white balance is only in the vendor's multipliers (CR2, NEF, ARW...) has no AsShotNeutral in them. merge_hdr now takes the median multipliers of all its inputs (the rule of consensus_multipliers: the mean of the middle two for an even count; missing, non-finite and non-positive values do not vote) and sets the result's AsShotNeutral to their reciprocal, as stitch does. With no multipliers (DNG sources) the reference frame's tags are kept, as before. HdrResult carries the multipliers so that the brackets of an HDR panorama vote as frames of their own when the merged brackets are stitched (the engine passes them on). In the app and the CLI the neutral from starting_colour_tags (#965) is already there for frames balanced alike, so those renders do not change. Tests: an HDR merge of raw-style frames gets the median AsShotNeutral (odd and even counts, missing and unusable multipliers ignored) and the written DNG says it; without multipliers the reference frame's colour is unchanged. --- crates/engine/src/merge.rs | 2 +- crates/merge/src/hdr.rs | 14 ++++++- crates/merge/tests/white_balance.rs | 57 ++++++++++++++++++++++++++++- docs/camera-preview-colour.md | 19 +++++----- docs/parity-checklist.md | 5 ++- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index c1b17be6d..c190cd489 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -183,7 +183,7 @@ impl MergeJob { orientation: r.orientation, metadata: r.metadata, baseline_exposure: r.baseline_exposure, - wb_multipliers: None, + wb_multipliers: r.wb_multipliers, }; f.orient(); merged.push(f); diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7..23f960187 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -26,7 +26,7 @@ use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::align::{self, Alignment}; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::{MergeError, Progress, Result, check}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -83,6 +83,9 @@ pub struct HdrResult { pub orientation: lightcraft_geom::Orientation, pub metadata: lightcraft_meta::Metadata, pub baseline_exposure: f64, + /// The inputs' as-shot white-balance multipliers they agree on ([`crate::frame::consensus_multipliers`]), already in + /// `color`; kept so merged brackets stitched into a panorama can vote as frames of their own. + pub wb_multipliers: Option<[f32; 3]>, } #[inline] @@ -265,6 +268,12 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) None => Plane::new(0, 0), }; let rf = &frames[reference]; + // the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none + let wb_multipliers = consensus_multipliers(&frames); + let mut color = rf.color.clone(); + if let Some(multipliers) = wb_multipliers { + color.set_as_shot_multipliers(multipliers); + } Ok(HdrResult { radiance: out, ghost, @@ -272,11 +281,12 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) ev, ev_exif, alignments, - color: rf.color.clone(), + color, raw: rf.raw, orientation: rf.orientation, metadata: rf.metadata.clone(), baseline_exposure: rf.baseline_exposure, + wb_multipliers, }) } diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs index 4139250f6..84474a494 100644 --- a/crates/merge/tests/white_balance.rs +++ b/crates/merge/tests/white_balance.rs @@ -1,4 +1,4 @@ -//! A panorama of raw files keeps their as-shot white balance (issue #1144). +//! A panorama or an HDR merge of raw files keeps their as-shot white balance (issue #1144). //! //! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has //! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. @@ -6,6 +6,7 @@ use lightcraft_color::{D50, Xy}; use lightcraft_geom::Orientation; use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::hdr::{HdrOptions, HdrResult, merge_hdr}; use lightcraft_merge::output::color_data; use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; @@ -189,3 +190,57 @@ fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { let raw = dng_of(&res); assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); } + +/// Exposures `evs` (EV, relative to the view's) of one view, one frame each, with `wb` as their multipliers. +fn bracket(color: &ColorData, evs: &[f64], wb: &[Multipliers]) -> Vec { + let view = lightcraft_merge::synth::pano_views(320, 240, 280.0, &[0.0]).unwrap().remove(0); + evs.iter() + .zip(wb) + .map(|(ev, m)| { + let k = 2f32.powf(*ev as f32); + let mut frame = raw_frame(view.map(|p| p.map(|v| (v * k).min(1.0))), color, *m); + frame.exposure = Some(*ev); + frame + }) + .collect() +} + +fn hdr(frames: Vec) -> HdrResult { + merge_hdr(frames, &HdrOptions::default(), &no_progress).unwrap() +} + +fn hdr_neutral(res: &HdrResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +#[test] +fn an_hdr_merge_of_raw_frames_has_their_median_white_balance() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some(DAYLIGHT), Some([1.9, 1.0, 1.7])]; + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &wb)); + assert_eq!(res.wb_multipliers, Some(DAYLIGHT)); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // the DNG says it, to the precision of a TIFF rational + let bytes = write_linear_dng(&res.radiance, &res.color, res.orientation, &res.metadata, res.baseline_exposure, DngSamples::Half).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_close(raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"), [0.5, 1.0, 0.625], 2e-6); + // an even count takes the mean of the middle two; frames without multipliers do not vote + let res = hdr(bracket(&source, &[-1.0, 1.0], &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &[None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0])])); + assert_eq!(res.wb_multipliers, Some([2.0, 1.0, 1.6])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn an_hdr_merge_without_multipliers_keeps_the_reference_colour_as_before() { + let source = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }; + let frames = bracket(&source, &[-2.0, 0.0, 2.0], &[None, None, None]); + let reference = frames[1].color.clone(); + let res = hdr(frames); + assert_eq!((res.reference, res.wb_multipliers), (1, None)); + assert_eq!(res.color, reference); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index f092e88f0..1661bf793 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -195,15 +195,16 @@ HDR merge of three copies of the photo against the photo alone (200 px, CIE76): 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). A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a -frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama takes its white balance -from the frames that are in it: the median R/G and the median B/G of their multipliers (the mean of the middle two -for an even count; frames without usable multipliers, or outside the panorama, do not vote), written as -`AsShotNeutral`, the reciprocal, and any `AsShotWhiteXY` of the source dropped (a reader takes it first, and the -DNG spec has the two exclude each other). So the panorama is balanced for what the camera measured for most of its -frames, not for whichever frame came first. With no multipliers anywhere (DNG sources) the first frame's tags are kept -as before. For frames balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG -renders as before; it matters when the camera balanced the frames differently, and for a caller that loads frames -without a colour hook (`load_frame`), whose raw frames carried no white balance at all (issue #1144). +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama, and an HDR merge from its +inputs, takes its white balance from the frames that are in it: the median R/G and the median B/G of their multipliers +(the mean of the middle two for an even count; frames without usable multipliers, or outside the panorama, do not +vote), written as `AsShotNeutral`, the reciprocal. Any `AsShotWhiteXY` of the source is dropped with it: a reader takes +it first, and the DNG spec has the two exclude each other. So the result is balanced for what the camera measured for +most of its frames, not for whichever frame came first; an HDR panorama's merged brackets vote with their bracket's +median. With no multipliers anywhere (DNG sources) the reference or first frame's tags are kept as before. For frames +balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG renders as before; it matters +when the camera balanced the frames differently, and for a caller that loads frames without a colour hook +(`load_frame`), whose raw frames carried no white balance at all (issue #1144). ## Nikon crop and preview colour metadata diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7fea9cf66..49c10e79a 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-13 · **Change:** minor (LR-MERGE-PANO: a panorama of raws takes its as-shot white balance from the median of its frames (#1144); 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO, LR-MERGE-HDR: a panorama and an HDR merge of raws take their as-shot white balance from the median of their frames (#1144); 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 @@ -347,7 +347,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; 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-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; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; 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) | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | +| 2026-10-13 | minor | LR-MERGE-HDR: an HDR merge's DNG takes its `AsShotNeutral` from the median of its inputs' vendor white-balance multipliers instead of the reference frame's tags (issue #1144); status unchanged (✅) | | 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-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |