diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7..aff017892 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -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, 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 = (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 = + 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 = (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> = frames.into_iter().map(Some).collect(); + let mut frames: Vec = 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> = exif.map(|e| input.iter().map(|&i| e[i]).collect()); + let medians: Vec = input.iter().map(|&i| medians[i]).collect(); + let brightness: Vec = input.iter().map(|&i| brightness[i]).collect(); + let order: Vec = (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, 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| { + 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, diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index 68b9ba4b9..abfc471ec 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -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 = (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();