Merge PR #1471 (rebased)

# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas
2026-10-15 01:10:17 -04:00
9 changed files with 544 additions and 59 deletions
+30
View File
@@ -2789,6 +2789,36 @@ mod tests {
renders_as_shot(&dng);
}
/// 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 {
+1
View File
@@ -191,6 +191,7 @@ impl MergeJob {
metadata: r.metadata,
baseline_exposure: r.baseline_exposure,
lens: Vec::new(),
wb_multipliers: r.wb_multipliers,
};
f.orient();
merged.push(f);
+227 -51
View File
@@ -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, Opcode};
use lightcraft_raw::{ColorData, Opcode, 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 {
@@ -40,6 +55,9 @@ pub struct Frame {
/// in order), re-expressed for `image` (the default crop, not yet turned), for the merged DNG to carry. Empty for
/// other sources and once [`Frame::orient`] has turned the pixels.
pub lens: Vec<Opcode>,
/// 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 {
@@ -58,6 +76,7 @@ impl Frame {
metadata: Metadata::default(),
baseline_exposure: 0.0,
lens: Vec::new(),
wb_multipliers: None,
}
}
@@ -87,6 +106,29 @@ pub fn exif_exposure(m: &Metadata) -> Option<f64> {
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<Item = &'a Frame>) -> 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<f64>) -> Option<f64> {
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 {
@@ -125,56 +167,8 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, 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()))?;
// Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and
// the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps.
// The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional.
let (aw, ah) = (raw.active_area.width, raw.active_area.height);
let crop = raw.develop_crop(aw, ah);
let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3)
.filter(|op| op.is_lens_correction())
.filter_map(|op| op.cropped(aw, ah, crop))
.map(|op| match op {
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => {
Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false }
}
op => op,
})
.collect();
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,
lens,
};
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),
@@ -198,6 +192,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
metadata,
baseline_exposure: 0.0,
lens: Vec::new(),
wb_multipliers: None,
};
if orient {
f.orient();
@@ -205,6 +200,67 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, 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<f64>,
max_edge: Option<usize>,
orient: bool,
colour: &RawColour,
) -> Result<Frame> {
// Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and
// the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps.
// The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional.
let (aw, ah) = (raw.active_area.width, raw.active_area.height);
let crop = raw.develop_crop(aw, ah);
let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3)
.filter(|op| op.is_lens_correction())
.filter_map(|op| op.cropped(aw, ah, crop))
.map(|op| match op {
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false },
op => op,
})
.collect();
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,
lens,
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::*;
@@ -230,4 +286,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<Frame> = 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);
}
}
+12 -2
View File
@@ -28,7 +28,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)]
@@ -87,6 +87,9 @@ pub struct HdrResult {
pub baseline_exposure: f64,
/// The reference frame's lens corrections, for `radiance` ([`Frame::lens`]).
pub lens: Vec<lightcraft_raw::Opcode>,
/// 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]>,
}
/// Long edge (at most) of the frames the deghost test compares: small moving things (a star at 200 mm) still span a few
@@ -273,6 +276,12 @@ pub fn merge_hdr(mut frames: Vec<Frame>, 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,
@@ -280,12 +289,13 @@ pub fn merge_hdr(mut frames: Vec<Frame>, 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,
lens: rf.lens.clone(),
wb_multipliers,
})
}
+3 -2
View File
@@ -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).
//! - Lens corrections (an HDR merge's: the reference frame's, [`crate::Frame::lens`]) go in `OpcodeList3`, where the
+7 -2
View File
@@ -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};
@@ -376,6 +376,11 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
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,
@@ -390,7 +395,7 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
native_megapixels: (bx1 - bx0) * (by1 - by0) / 1e6,
focal: f_out,
origin: (ox, oy),
color: r.color.clone(),
color,
metadata: r.metadata.clone(),
raw: r.raw,
baseline_exposure: r.baseline_exposure,
+248
View File
@@ -0,0 +1,248 @@
//! 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.
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};
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,
lens: Vec::new(),
wb_multipliers,
}
}
/// Overlapping views of one scene, one frame per entry of `wb`, in order.
fn shoot(color: &ColorData, wb: &[Multipliers]) -> Vec<Frame> {
let n = wb.len();
let yaws: Vec<f64> = (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<Frame>) -> 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));
}
/// 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<Frame> {
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<Frame>) -> 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, &res.lens).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);
}
+12
View File
@@ -221,6 +221,18 @@ 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, 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
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.
+4 -2
View File
@@ -351,8 +351,8 @@ 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`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); 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. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 |
| 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-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); 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. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 |
| 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`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` |
@@ -797,6 +797,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|---|---|---|
| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) |
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) |
| 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-14 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) |
| 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it |
| 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) |