LR-MERGE-HDR: the merge no longer depends on the order the frames come in

The same bracket merged in another order gave a different result: on real ARW brackets, the app's selection
order and the CLI's file order differed on 0.6 % of the pixels (up to 102 levels), while the same order gave
byte-identical files. Alignment seeds were the frames' input positions, and sums, deghost masks and brightness
ties followed them too.

merge_hdr now puts the frames in brightness order first (ties by median, then a content fingerprint, never by
position), runs everything in that order, and returns the per-frame results (EV, alignment, reference index)
in the caller's order. Test: every permutation of a bracket gives the same radiance and deghost overlay bit for
bit, and the same per-frame results (fails before: "the radiance differs").

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 05:57:13 +00:00
1 parent 68c924047d
commit 146cb7b975
2 files changed
+64 -8

No files matched your search

+39 -8
View File
@@ -1,8 +1,9 @@
//! HDR merge of exposure brackets.
//!
//! 1. **Order and reference.** Frames are ordered by brightness (EXIF exposure, or median
//! luminance without EXIF); the middle one is the reference (geometry, colour and the output's
//! default brightness come from it).
//! luminance without EXIF; ties by content); the middle one is the reference (geometry, colour
//! and the output's default brightness come from it). Everything below runs in that order, so
//! the result is the same whatever order the frames come in.
//! 2. **Auto align** ([`crate::align`]): each frame is registered to the reference and resampled
//! into its geometry.
//! 3. **Exposure normalisation.** The exposure ratio between brightness-adjacent frames is
@@ -125,9 +126,25 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
Some(e) if e.iter().any(|v| (v - e[0]).abs() > 0.1) => e.clone(),
_ => medians.iter().map(|m| (*m as f64).log2()).collect(),
};
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| brightness[a].total_cmp(&brightness[b]).then(a.cmp(&b)));
let reference = order[n / 2];
// ties (equal brightness) by content, never by position
let fingerprint: Vec<f64> =
frames.par_iter().map(|f| f.image.data.iter().step_by(997).map(|p| p[0] as f64 + 2.0 * p[1] as f64 + 3.0 * p[2] as f64).sum()).collect();
let mut input: Vec<usize> = (0..n).collect();
input.sort_by(|&a, &b| {
brightness[a].total_cmp(&brightness[b]).then(medians[a].total_cmp(&medians[b])).then(fingerprint[a].total_cmp(&fingerprint[b]))
});
// From here on frames are in brightness order (`input[p]` is the caller's index of position `p`): alignment seeds,
// sums and deghost masks no longer depend on the order the caller passed. Per-frame results go back at the end.
let mut slots: Vec<Option<Frame>> = frames.into_iter().map(Some).collect();
let mut frames: Vec<Frame> = input.iter().filter_map(|&i| slots.get_mut(i).and_then(Option::take)).collect();
if frames.len() != n {
return Err(MergeError::Mismatch("HDR merge lost a frame while ordering them".into()));
}
let exif: Option<Vec<f64>> = exif.map(|e| input.iter().map(|&i| e[i]).collect());
let medians: Vec<f32> = input.iter().map(|&i| medians[i]).collect();
let brightness: Vec<f64> = input.iter().map(|&i| brightness[i]).collect();
let order: Vec<usize> = (0..n).collect();
let reference = n / 2;
// 2. alignment
let mut alignments = vec![Alignment::identity(); n];
@@ -264,14 +281,28 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
}
None => Plane::new(0, 0),
};
// per-frame results in the caller's order
let mut ev_in = vec![0.0; n];
let mut alignments_in = vec![Alignment::identity(); n];
for (p, &i) in input.iter().enumerate() {
ev_in[i] = ev[p];
alignments_in[i] = alignments[p].clone();
}
let ev_exif = ev_exif.map(|e: Vec<f64>| {
let mut v = vec![0.0; n];
for (p, &i) in input.iter().enumerate() {
v[i] = e[p];
}
v
});
let rf = &frames[reference];
Ok(HdrResult {
radiance: out,
ghost,
reference,
ev,
reference: input[reference],
ev: ev_in,
ev_exif,
alignments,
alignments: alignments_in,
color: rf.color.clone(),
raw: rf.raw,
orientation: rf.orientation,
+25
View File
@@ -125,6 +125,31 @@ fn bracket_merge_recovers_radiance_and_alignment() {
assert!(rmed < 0.05, "recovered highlights: median log error {rmed}");
}
/// The result doesn't depend on the order the frames come in: every permutation of a bracket gives the same radiance and
/// deghost overlay, bit for bit, and the same per-frame results for each frame. (Real brackets merged in the app's
/// selection order and in the CLI's file order differed on 0.6 % of their pixels: alignment seeds followed the input
/// positions.)
#[test]
fn merge_does_not_depend_on_the_frame_order() {
let t = truth();
let shifts = [(3.25f32, -2.0f32), (0.0, 0.0), (-4.0, 1.5)];
let ks = [0.25f32, 1.0, 4.0];
let shot: Vec<Frame> = (0..3).map(|i| shoot(&t, ks[i], shifts[i].0, shifts[i].1, i as u64 + 1)).collect();
let opts = HdrOptions { align: true, deghost: Deghost::Medium };
let base = merge_hdr(shot.clone(), &opts, &no_progress).unwrap();
assert_eq!(base.reference, 1);
for perm in [[2usize, 0, 1], [1, 2, 0], [0, 2, 1]] {
let r = merge_hdr(perm.iter().map(|&i| shot[i].clone()).collect(), &opts, &no_progress).unwrap();
assert!(r.radiance.data == base.radiance.data, "{perm:?}: the radiance differs");
assert!(r.ghost.data == base.ghost.data, "{perm:?}: the deghost overlay differs");
assert_eq!(perm[r.reference], base.reference, "{perm:?}");
for (p, &i) in perm.iter().enumerate() {
assert_eq!(r.ev[p], base.ev[i], "{perm:?}");
assert_eq!(r.alignments[p].h, base.alignments[i].h, "{perm:?}");
}
}
}
#[test]
fn deghost_removes_a_moving_object() {
let t = truth();