From 951d52788ac6c1ee6e86ca77ffaf0ec304cfb2a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:22:19 +0000 Subject: [PATCH] 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 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