From 146cb7b975fa0b542018e20924448e6a9beda226 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:57:03 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/merge/src/hdr.rs | 47 ++++++++++++++++++++++++++++++++------- crates/merge/tests/hdr.rs | 25 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7b..aff0178927 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 68b9ba4b95..abfc471ec5 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(); From 951d52788ac6c1ee6e86ca77ffaf0ec304cfb2a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:22:19 +0000 Subject: [PATCH 2/2] LR-MERGE-HDR: pixels next to clipped ones don't tint HDR merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the darkest frame on real ARW brackets, each frame is linear to 1 % up to the clip far from clipped pixels, but within 2 px of them the +2 EV frame's green reads 3–5 % low: demosaicing interpolates it from clipped photosites. That frame carries the most weight (k = 4) and those pixels were unclipped themselves, so they kept it, and bright windows took a magenta tinge. A frame's weight now goes by its brightest channel within 1 px (3 × 3; wider margins changed nothing more). On two real interior brackets (ILCE-7M3, 0 / −2 / +2 EV), bright pixels whose green against red and blue is over 10 % below the darkest frame's, in camera RGB: dining room 0.32 % → 0.08 %, kitchen 2.86 % → 0.28 %. Rendered at −3 EV with main's colour tags (#1123), window pixels with G < 0.85·min(R, B) go 50 → 3 and 16 → 10 (the darkest frame alone: 2 and 205); most of the visible magenta those brackets showed before came from the missing colour tags. Test: a bracket whose +2 EV frame has 5 % less green next to its clipped pixels merges without the deficit (fails before: −1.96 %). Docs: docs/merge.md, the LR-MERGE-HDR row, docs/whats-new.md (also for the frame-order fix). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/merge/src/hdr.rs | 39 ++++++++++++++++++++++++++++++++++----- crates/merge/tests/hdr.rs | 38 ++++++++++++++++++++++++++++++++++++++ docs/merge.md | 5 ++++- docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 +++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index aff0178927..fbb24cad74 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -10,10 +10,11 @@ //! measured robustly — the median of log ratios over pixels well exposed in both — and chained //! to the reference; EXIF is the fallback when too few pixels overlap. Radiance //! `Rᵢ = vᵢ / kᵢ` is expressed in reference units. -//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer -//! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none -//! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a -//! tiny weight so fully saturated pixels still get a value. +//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel within 1 px))`: +//! longer exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels +//! none (all channels together, so a clipped channel never shifts colour), nor their neighbours, +//! which demosaicing interpolated from clipped photosites. The darkest frame keeps a tiny weight +//! so fully saturated pixels still get a value. //! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test //! against the reference: the reference radiance (or, where the reference is clipped/too dark, //! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that @@ -221,6 +222,8 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) let darkest = order[0]; let clips: Vec = frames.iter().map(|f| f.clip).collect(); + // how close each pixel of each frame is to saturation: its brightest channel within 1 px (see `saturation`) + let sat: Vec> = frames.par_iter().map(|f| saturation(&f.image)).collect(); let mut out = Rgb32f::new(w, h); let fr = &frames; let vd = &valid; @@ -235,7 +238,7 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) continue; } let v = fr[i].image.data[i_px]; - let m = maxc(v); + let m = sat[i][i_px]; let mut wt = k[i] * (1.0 - smoothstep(0.80 * clips[i], 0.96 * clips[i], m)); if i == darkest { wt += 1e-6; @@ -478,3 +481,29 @@ fn dilate(p: &Plane, r: usize) -> Plane { } out } + +/// A frame's saturation per pixel: the brightest channel within 1 px (3 × 3). A demosaiced pixel next to clipped +/// photosites is interpolated from them and reads low in the channel that clipped (green, in camera RGB), so it loses +/// its weight with them. On real ARW brackets the brightest frame's green was 3–5 % low within 2 px of clipped pixels +/// (and linear to 1 % elsewhere), and as that frame carries the most weight, bright windows got magenta fringes. +fn saturation(img: &Rgb32f) -> Vec { + let (w, h) = (img.width, img.height); + let m: Vec = img.data.iter().map(|p| maxc(*p)).collect(); + if w == 0 || h == 0 { + return m; + } + let mut rows = vec![0f32; w * h]; + rows.par_chunks_mut(w).zip(m.par_chunks(w)).for_each(|(out, src)| { + for (x, o) in out.iter_mut().enumerate() { + *o = src[x.saturating_sub(1)..=(x + 1).min(w - 1)].iter().copied().fold(0.0, f32::max); + } + }); + let mut out = vec![0f32; w * h]; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let (a, b) = (y.saturating_sub(1), (y + 1).min(h - 1)); + for (x, v) in row.iter_mut().enumerate() { + *v = (a..=b).map(|yy| rows[yy * w + x]).fold(0.0, f32::max); + } + }); + out +} diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index abfc471ec5..27b3958245 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -150,6 +150,44 @@ fn merge_does_not_depend_on_the_frame_order() { } } +/// Pixels demosaiced next to clipped photosites read low in the clipped channel (real ARW brackets: the brightest +/// frame's green 3–5 % low within 2 px of clipped pixels, linear to 1 % elsewhere); the merge must not take them from +/// that frame, or bright windows get magenta fringes. Here the +2 EV frame's green is 5 % low around its clipped areas. +#[test] +fn pixels_next_to_clipped_ones_do_not_tint_the_merge() { + let t = truth(); + let ks = [0.25f32, 1.0, 4.0]; + let mut frames: Vec = (0..3).map(|i| shoot(&t, ks[i], 0.0, 0.0, i as u64 + 1)).collect(); + let (w, h) = (frames[2].image.width, frames[2].image.height); + let clipped = |img: &Rgb32f, x: usize, y: usize| img.data[y * w + x].iter().any(|&v| v >= 1.0); + let mut ring = vec![false; w * h]; + for y in 1..h - 1 { + for x in 1..w - 1 { + let img = &frames[2].image; + ring[y * w + x] = !clipped(img, x, y) && (y - 1..=y + 1).any(|yy| (x - 1..=x + 1).any(|xx| clipped(img, xx, yy))); + } + } + for (p, &r) in frames[2].image.data.iter_mut().zip(&ring) { + if r { + p[1] *= 0.95; + } + } + let r = merge_hdr(frames, &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + let ratio = |p: [f32; 3]| p[1] / (p[0] + p[2]); + let mut devs = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let want = t.get(x + M, y + M); + if ring[y * w + x] && want[0] > 0.01 && want[2] > 0.01 { + devs.push(ratio(r.radiance.get(x, y)) / ratio(want) - 1.0); + } + } + } + assert!(devs.len() > 500, "ring pixels: {}", devs.len()); + let med = pct(devs, 0.5); + assert!(med.abs() < 0.01, "green against red and blue on the ring: {:+.2} %", 100.0 * med); +} + #[test] fn deghost_removes_a_moving_object() { let t = truth(); diff --git a/docs/merge.md b/docs/merge.md index e78c356fbc..abd58b6953 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -25,7 +25,10 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max -channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise +channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped +channel, so it goes with them (on real ARW brackets the brightest frame's green was 3–5 % low there, and bright +windows took a magenta tinge). The frames are taken in brightness order (ties by content), so the result doesn't +depend on the order they come in. Deghost: a per-pixel consistency test against the reference radiance (with a noise floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. **Panorama** (`pano/`): MOPS-style features, pairwise RANSAC homographies with Brown & Lowe's diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0ab638f35d..b57958ed40 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -346,7 +346,7 @@ 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` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; 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 | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; the same result whatever order the frames come in (`merge_does_not_depend_on_the_frame_order`); pixels next to clipped ones lose their weight with them (`pixels_next_to_clipped_ones_do_not_tint_the_merge`); 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 | | 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-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` | no dialog; the options of the last merge of that kind (defaults the first time) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 81d9ad7fc7..6585271adc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-13 +### Cleaner HDR merges +- Bright windows in an HDR made with Photo ▸ Photo Merge ▸ HDR no longer take a magenta tinge from the brighter + photos along the areas they clipped: right next to a clipped area, a photo's colour isn't used. It showed most when + the highlights were darkened or saturated. +- The same photos give the same HDR whatever order they are selected in; before, a different order could change a + few pixels. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's