LR-MERGE-HDR: merged DNGs declare the white balance their source shows (follow-up to #1123)

#1123 gives HDR and panorama merges of raws without colour matrices the colour they open with, but writes
ColorMatrix1 from the forward matrix, so the as-shot white the DNG declares lands wherever the fitted look and the
generic camera model take it. On real ILCE-7M3 brackets the HDRs opened at As Shot 6113–7307 K / +115 (Duv +0.038)
where the ARWs show 3932–5268 K / +7, and any white-balance edit turned them green (CIEDE2000 11–13 against the ARW
at the same Temp/Tint). The render at As Shot was right (0.4–0.8).

The colour matrices now declare the white balance the source shows, decided by files::WbScaleOf (new dng_white):
the camera's measured matrices when they give the source's scale (lightcraft_raw::spectral; the DNG then reads every
white as the source does), else a matrix under which the as-shot neutral is the white the scale shows (Sony's
presets' reading, 6500 K / 0 on the relative scale, the file's own white otherwise). Forward matrix and neutral are
unchanged, so is the render at As Shot.

Measured on the same brackets with this change on a fork build: As Shot 4813 / 5268 / 3932 K, +8 / +8 / +7 as the
ARWs (declared white Duv +0.003); same Temp/Tint within CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for
the largest (5268 → 3200 K, 3932 → 6500 K: the ARW adapts its look with Bradford, the DNG rebalances in camera space).

Tests: a merged DNG shows its source's As Shot on all four scales (measured matrices, presets, relative, own); with a
look that tints white the DNG declares the shown white, gives the measured model's multipliers at other whites and
renders at As Shot as the source does. Both fail on main (ILCE-7M3 case: (7342, 115) for (5268, 8)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
This commit is contained in:
Claude committed 2026-10-13 06:37:11 +00:00
1 parent 68c924047d
commit 64f354fca3
5 files changed
+167 -15

No files matched your search

+80 -7
View File
@@ -119,8 +119,11 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
if lightcraft_raw::color::has_matrix(&raw.color) {
return None;
}
// the white the source shows, decided before `starting_colour` may fill in measured matrices
let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
let (_, t, look) = starting_colour(raw, bytes);
dng_colour(&raw.color, &t, look.as_ref())
dng_colour(&raw.color, &white, &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
@@ -129,8 +132,13 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
/// 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).
/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]),
/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices
/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges
/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green.
/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them.
/// `None` when the white balance or the matrix can't give tags.
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
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
@@ -143,11 +151,19 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>)
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]));
let (illuminant, color_matrix) = match declared {
crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix),
crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]),
crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]),
};
if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
return None;
}
Some(ColorData {
illuminant: [21, 0],
color_matrix: [Some(camera_to_xyz.inverse()?), None],
forward_matrix: [Some(forward), None],
illuminant,
color_matrix,
// a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white
forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)],
camera_calibration: [None, None],
analog_balance: None,
as_shot_neutral: Some(neutral),
@@ -163,6 +179,17 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>)
})
}
/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per
/// channel. `None` when the model gives no positive neutral there.
fn declaring(xy: Xy, neutral: [f64; 3]) -> Option<Mat3> {
let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy);
let n = g.apply(xy.to_xyz());
if !n.iter().all(|v| v.is_finite() && *v > 1e-9) {
return None;
}
Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g))
}
/// 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<ToneCurve> {
@@ -2379,7 +2406,7 @@ mod tests {
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 dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &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);
@@ -2404,6 +2431,52 @@ mod tests {
}
}
/// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white
/// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their
/// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8.
#[test]
fn dng_colour_declares_the_white_balance_the_source_shows() {
use crate::files::DngWhite;
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers};
let tags = ColorData::default();
let as_shot = [2.5039f32, 1.0, 1.5078];
let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot)));
let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]);
let tint = look.matrix.apply([1.0; 3]);
assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}");
let renders_as_shot = |dng: &ColorData| {
let r = camera_transform_of(dng, as_shot_white_of(dng, None));
assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb);
let m = look.matrix.mul(&t.matrix);
for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] {
let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2());
let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2());
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}");
}
};
// a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0)
for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] {
let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap();
let back = as_shot_white_of(&dng, None);
assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}");
renders_as_shot(&dng);
}
// a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera");
let mut model = tags.clone();
assert!(camera.fill(&mut model));
let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap();
let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap();
let back = as_shot_white_of(&dng, None);
assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}");
for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] {
let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w));
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}");
}
renders_as_shot(&dng);
}
/// 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 {
+64 -3
View File
@@ -513,17 +513,28 @@ fn load_bytes_now(
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
struct WbScaleOf {
pub(crate) struct WbScaleOf {
own_matrix: bool,
relative: bool,
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
legacy_relative: bool,
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
scale_white: Option<lightcraft_color::Xy>,
/// The camera's measured matrices, when they give the scale.
camera: Option<&'static lightcraft_raw::spectral::Camera>,
}
/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does
/// ([`WbScaleOf::dng_white`]).
pub(crate) enum DngWhite {
/// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does.
Matrices(Box<lightcraft_raw::ColorData>),
/// The white the source's scale shows at As Shot.
At(lightcraft_color::Xy),
}
impl WbScaleOf {
fn new(
pub(crate) fn new(
format: lightcraft_raw::RawFormat,
color: &lightcraft_raw::ColorData,
wb_multipliers: Option<[f32; 3]>,
@@ -540,7 +551,23 @@ impl WbScaleOf {
None => None,
};
let covered = camera.is_some() || presets;
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera }
}
/// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the
/// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets'
/// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give).
pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite {
if let Some(camera) = self.camera {
let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() };
if camera.fill(&mut c) {
return DngWhite::Matrices(Box::new(c));
}
}
if self.relative {
return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0));
}
DngWhite::At(self.scale_white.unwrap_or(own))
}
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
@@ -1096,6 +1123,40 @@ mod tests {
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
}
/// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source
/// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative
/// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets
/// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7.
#[test]
fn a_merged_dng_shows_the_white_balance_its_source_shows() {
use lightcraft_raw::{ColorData, RawFormat};
let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
(2500.0, g(1336.0, 3672.0)),
(3200.0, g(1720.0, 2620.0)),
(4500.0, g(2256.0, 1880.0)),
(6000.0, g(2680.0, 1544.0)),
(8500.0, g(3136.0, 1300.0)),
]);
let with_presets = ColorData { kelvin_presets: presets, ..Default::default() };
let cases = [
(RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")),
(RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")),
(RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")),
(RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")),
];
for (format, color, mul, meta) in cases {
let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul));
let source = WbScaleOf::new(format, &color, Some(mul), &meta);
let t = lightcraft_raw::color::camera_transform_of(&color, own);
let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap();
let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta);
let shown = source.as_shot(own);
assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model);
}
}
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
let head = 160;
+16 -4
View File
@@ -184,10 +184,22 @@ A merge (HDR, panorama, HDR panorama) writes a linear DNG of the sources' camera
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).
top of the camera transform) with `AsShotNeutral`, 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 colour matrices declare the white balance the source shows (`files::WbScaleOf::dng_white`), so the DNG opens at the
source's As Shot temperature and tint and a white-balance edit starts from there: the camera's measured matrices
(`lightcraft_raw::spectral`) when they give the source's scale, which reads every white as the source does; else a
matrix under which the as-shot neutral is the white the source's scale shows (Sony's presets' reading, 6500 K / 0 on
the relative scale, the file's own white otherwise). The forward matrix is used only with the as-shot neutral, so the
render at As Shot doesn't depend on them. A colour matrix derived from the forward matrix, as first written, declared
wherever the look and the generic camera model put the white: merges of real ILCE-7M3 brackets opened at 6113–7307 K /
+115 where the ARWs show 3932–5268 K / +7, and any white-balance edit turned them green (CIEDE2000 11–13 against the
ARW at the same Temp/Tint). With the measured matrices they open at the ARWs' As Shot, and the same Temp/Tint renders
within CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for the largest (5268 → 3200 K, 3932 → 6500 K): the ARW
adapts its fitted look with Bradford, the DNG rebalances in camera space.
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
+1 -1
View File
@@ -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; 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, and the white balance they show (`a_merged_dng_shows_the_white_balance_its_source_shows`) |
| 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) |
+6
View File
@@ -2,6 +2,12 @@
## 2026-10-13
### HDR merges keep the white balance
- An HDR merged from raws that LightCraft colours from the camera's JPEG (CR2, NEF, ARW…) now opens at the same
As Shot temperature and tint as its photos, and changing the white balance no longer turns it green. Sony ARW
merges used to open at about 7000 K / +115 where the photos show 4000–5300 K / +8. HDRs merged before this keep
their old white balance; merge them again.
### Slider drags on large raws
- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you
let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's