mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
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:
2 files changed
+64
-8
No files matched your search
+39
-8
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user