mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-18 18:01:18 -05:00
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:
5 files changed
+84
-7
No files matched your search
+34
-5
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user