LR-MERGE-HDR: pixels next to clipped ones don't tint HDR merges

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
This commit is contained in:
Claude committed 2026-10-13 09:51:11 +00:00
1 parent 146cb7b975
commit 951d52788a
5 files changed
+84 -7

No files matched your search

+34 -5
View File
@@ -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<Frame>, opts: &HdrOptions, progress: &Progress)
let darkest = order[0];
let clips: Vec<f32> = 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<Vec<f32>> = 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<Frame>, 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<f32> {
let (w, h) = (img.width, img.height);
let m: Vec<f32> = 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
}
+38
View File
@@ -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<Frame> = (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();
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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) |
+7
View File
@@ -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