mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
Reference in New Issue
Block a user