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] 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 (🟡) |