diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs
index aff017892..fbb24cad7 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 abfc471ec..27b395824 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 e78c356fb..abd58b695 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 0ab638f35..b57958ed4 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 81d9ad7fc..6585271ad 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