From e98b1def98038924813cf508752e42d8333cb0c9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 27 Sep 2026 14:27:07 -0400 Subject: [PATCH] Keep blown highlights grey in a panorama MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clipped photosite reaches the merge as (1, 1, 1), which the as-shot balance turns magenta. The preview balanced it with no highlight rule at all, so every blown cloud was pink on the alignment page. The DNG had the quieter form of the same fault: a frame's gain below one moved a blown sample off the white level, and a feather mixed it into a neighbour's real sky, so the develop's own desaturation no longer recognised it. The merge shader and the preview now write a blown sample, before the gain, as the camera value the composite's balance calls grey — the develop pipeline's neutral, fading in from CLIP_ONSET. --- core/dr-gpu/src/merge.rs | 15 ++++++++-- core/dr-gpu/src/shaders/merge.wgsl | 19 +++++++++++-- docs/dev/traceability.md | 2 +- ui/dr-ui/src/merge.rs | 44 +++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/core/dr-gpu/src/merge.rs b/core/dr-gpu/src/merge.rs index 76cb764..4325068 100644 --- a/core/dr-gpu/src/merge.rs +++ b/core/dr-gpu/src/merge.rs @@ -73,6 +73,10 @@ pub struct MergeOutput { pub chunk: (u32, u32), /// Multiplies a normalised sample (1.0 = white) to the sensor's scale. pub sample_scale: f32, + /// The white balance the composite will be developed with — the + /// inverse of its `AsShotNeutral` — so that a blown sample can be + /// written as the camera value that balance calls grey. + pub balance: [f32; 3], } impl MergeOutput { @@ -109,7 +113,8 @@ struct WarpParams { tile_origin: [f32; 2], tile_size: [u32; 2], feather: f32, - _pad: f32, + clip_onset: f32, + balance: [f32; 4], } #[repr(C)] @@ -334,7 +339,13 @@ impl MergePass { tile_origin: [rect.0 as f32, rect.1 as f32], tile_size: [rect.2, rect.3], feather: output.feather, - _pad: 0.0, + clip_onset: dr_pipeline::CLIP_ONSET, + balance: [ + output.balance[0].max(1e-3), + output.balance[1].max(1e-3), + output.balance[2].max(1e-3), + 0.0, + ], }; self.accumulate(¶ms, tile); } diff --git a/core/dr-gpu/src/shaders/merge.wgsl b/core/dr-gpu/src/shaders/merge.wgsl index 07fc4df..332eaab 100644 --- a/core/dr-gpu/src/shaders/merge.wgsl +++ b/core/dr-gpu/src/shaders/merge.wgsl @@ -46,7 +46,10 @@ struct Params { tile_size: vec2, // Pixels over which the weight ramps from the edge to full. feather: f32, - _pad: f32, + // Where a sample starts to count as blown (`CLIP_ONSET`), and the + // white balance the composite will be developed with. + clip_onset: f32, + balance: vec4, }; @group(0) @binding(0) var p: Params; @@ -125,7 +128,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3) { return; } // Colour is the alpha-weighted mean of the texels that exist. - let rgb = s.rgb / s.a * p.gain; + let cam = s.rgb / s.a; + // **A blown sample is written as grey, before the gain.** A clipped + // photosite arrives as (1, 1, 1), which is not a colour: balanced, it + // is magenta, and the develop's highlight desaturation only rescues it + // while it is still at the white level. A gain below one moved it off + // that level, and a feather mixed it into a neighbour's real sky, so + // the composite's blown clouds came out pink. Written instead as the + // camera value the balance maps to grey — the develop pipeline's own + // neutral, the brightest balanced channel — it survives both. + let clipped = smoothstep(p.clip_onset, 1.0, max(cam.r, max(cam.g, cam.b))); + let balanced = cam * p.balance.rgb; + let grey = vec3(max(balanced.r, max(balanced.g, balanced.b))) / p.balance.rgb; + let rgb = mix(cam, grey, clipped) * p.gain; let wa = w * s.a; let i = gid.y * p.chunk_size.x + gid.x; acc[i] = acc[i] + vec4(rgb * wa, wa); diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index 916c524..c1c7673 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -113,7 +113,7 @@ _None._ | FR-MRG-3 | [`core/dr-decode/examples/linear_dng.rs:1`](../../core/dr-decode/examples/linear_dng.rs#L1), [`core/dr-decode/src/lib.rs:143`](../../core/dr-decode/src/lib.rs#L143), [`core/dr-decode/src/lib.rs:150`](../../core/dr-decode/src/lib.rs#L150), [`core/dr-decode/src/lib.rs:604`](../../core/dr-decode/src/lib.rs#L604), [`core/dr-decode/src/profile.rs:395`](../../core/dr-decode/src/profile.rs#L395), [`core/dr-export/src/dng.rs:1`](../../core/dr-export/src/dng.rs#L1), [`core/dr-gpu/src/demosaic.rs:302`](../../core/dr-gpu/src/demosaic.rs#L302), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1) | | FR-MRG-4 | [`core/dr-export/src/inscribed.rs:1`](../../core/dr-export/src/inscribed.rs#L1), [`core/dr-pano/src/fill.rs:1`](../../core/dr-pano/src/fill.rs#L1), [`core/dr-pano/src/migan.rs:1`](../../core/dr-pano/src/migan.rs#L1), [`core/dr-pano/src/projection.rs:1`](../../core/dr-pano/src/projection.rs#L1), [`ui/dr-ui/src/library/paths.rs:365`](../../ui/dr-ui/src/library/paths.rs#L365), [`ui/dr-ui/src/library/paths.rs:369`](../../ui/dr-ui/src/library/paths.rs#L369), [`ui/dr-ui/src/merge.rs:148`](../../ui/dr-ui/src/merge.rs#L148), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1), [`ui/dr-ui/ui/merge.slint:46`](../../ui/dr-ui/ui/merge.slint#L46) | | FR-MRG-5 | [`core/dr-pano/src/align.rs:1`](../../core/dr-pano/src/align.rs#L1), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | -| FR-MRG-6 | [`core/dr-pipeline/src/sidecar.rs:1055`](../../core/dr-pipeline/src/sidecar.rs#L1055), [`core/dr-pipeline/src/sidecar.rs:116`](../../core/dr-pipeline/src/sidecar.rs#L116), [`core/dr-pipeline/src/sidecar.rs:126`](../../core/dr-pipeline/src/sidecar.rs#L126), [`core/dr-pipeline/src/sidecar.rs:2284`](../../core/dr-pipeline/src/sidecar.rs#L2284), [`core/dr-pipeline/src/sidecar.rs:860`](../../core/dr-pipeline/src/sidecar.rs#L860), [`ui/dr-ui/src/merge.rs:850`](../../ui/dr-ui/src/merge.rs#L850) | +| FR-MRG-6 | [`core/dr-pipeline/src/sidecar.rs:1055`](../../core/dr-pipeline/src/sidecar.rs#L1055), [`core/dr-pipeline/src/sidecar.rs:116`](../../core/dr-pipeline/src/sidecar.rs#L116), [`core/dr-pipeline/src/sidecar.rs:126`](../../core/dr-pipeline/src/sidecar.rs#L126), [`core/dr-pipeline/src/sidecar.rs:2284`](../../core/dr-pipeline/src/sidecar.rs#L2284), [`core/dr-pipeline/src/sidecar.rs:860`](../../core/dr-pipeline/src/sidecar.rs#L860), [`ui/dr-ui/src/merge.rs:853`](../../ui/dr-ui/src/merge.rs#L853) | | FR-MRG-7 | [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/app.slint:589`](../../ui/dr-ui/ui/app.slint#L589), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-8 | [`core/dr-pano/src/xfeat.rs:1`](../../core/dr-pano/src/xfeat.rs#L1), [`core/dr-segment/examples/onnx_probe.rs:1`](../../core/dr-segment/examples/onnx_probe.rs#L1) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:206`](../../core/dr-sync-nextcloud/src/auth.rs#L206), [`core/dr-sync-nextcloud/src/auth.rs:49`](../../core/dr-sync-nextcloud/src/auth.rs#L49), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`ui/dr-ui/src/launch.rs:316`](../../ui/dr-ui/src/launch.rs#L316), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:521`](../../ui/dr-ui/src/launch_ui.rs#L521) | diff --git a/ui/dr-ui/src/merge.rs b/ui/dr-ui/src/merge.rs index af4a2b9..6f4cda9 100644 --- a/ui/dr-ui/src/merge.rs +++ b/ui/dr-ui/src/merge.rs @@ -560,6 +560,7 @@ fn run_inner( let black = first.raw.black_level[0]; let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1); let profile = dng_profile(first, white_level); + let balance = profile.as_shot_neutral.map(|n| 1.0 / n.max(1e-3)); let (_, mut carried) = crate::export::header_for_file(&first.meta); // The composite is dated at the middle of its sweep — the mean of the // frames' capture times — so it sorts among the frames it was made @@ -578,6 +579,7 @@ fn run_inner( feather: request.feather_px, chunk: request.chunk, sample_scale: white_level as f32, + balance, }; let merge_frames: Vec = frames .iter() @@ -619,6 +621,7 @@ fn run_inner( feather: request.feather_px, chunk: request.chunk, sample_scale: white_level as f32, + balance, }; let (qw, qh) = (qout.width() as usize, qout.height() as usize); let mut qrgb = vec![0.0f32; qw * qh * 3]; @@ -987,8 +990,10 @@ fn preview_planes( continue; } let i = (y as usize * g.width + x as usize) * 3; + let cam = + grey_if_blown([colour[k][i], colour[k][i + 1], colour[k][i + 2]], look.wb); for c in 0..3 { - sum[c] += colour[k][i + c] * gains[k]; + sum[c] += cam[c] * gains[k]; } n += 1; } @@ -1133,6 +1138,27 @@ impl Look { } } +/// A blown camera sample as the grey `wb` balances it to; the merge +/// shader's rule, for the preview. +/// +/// A clipped photosite is (1, 1, 1), which balances to magenta. The develop +/// pipeline pulls it back to the brightest balanced channel as it nears +/// white (`CLIP_ONSET`); this does the same in camera space, before any +/// gain moves it off the white level or an overlap averages it with a +/// neighbour's real sky — after either, nothing downstream can tell it was +/// blown and the clouds come out pink. +fn grey_if_blown(cam: [f32; 3], wb: [f32; 3]) -> [f32; 3] { + let peak = cam[0].max(cam[1]).max(cam[2]); + let t = ((peak - dr_pipeline::CLIP_ONSET) / (1.0 - dr_pipeline::CLIP_ONSET)).clamp(0.0, 1.0); + let clipped = t * t * (3.0 - 2.0 * t); + if clipped <= 0.0 { + return cam; + } + let wb = wb.map(|w| w.max(1e-3)); + let grey = (cam[0] * wb[0]).max(cam[1] * wb[1]).max(cam[2] * wb[2]); + [0, 1, 2].map(|c| cam[c] + (grey / wb[c] - cam[c]) * clipped) +} + fn invert3(m: &[f32; 9]) -> Option<[f32; 9]> { let (a, b, c, d, e, f, g, h, i) = (m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8]); let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); @@ -1353,6 +1379,22 @@ fn dump_fill_input( mod tests { use super::*; + #[test] + fn a_blown_sample_stays_grey_under_a_gain_below_one() { + // The 6D's as-shot balance: a clipped (1, 1, 1) balances to + // (1.93, 1, 1.68), magenta. After the rule, balanced, every channel + // is equal — and still equal once a gain of 0.9 has scaled it. + let wb = [1.93, 1.0, 1.68]; + let cam = grey_if_blown([1.0, 1.0, 1.0], wb).map(|v| v * 0.9); + let b = [cam[0] * wb[0], cam[1] * wb[1], cam[2] * wb[2]]; + assert!( + (b[0] - b[1]).abs() < 1e-5 && (b[1] - b[2]).abs() < 1e-5, + "{b:?}" + ); + // And a sample well below white is left alone. + assert_eq!(grey_if_blown([0.5, 0.8, 0.4], wb), [0.5, 0.8, 0.4]); + } + #[test] fn a_worker_that_hangs_up_mid_job_is_reported_as_gone() { // The failure the page could not see: a panic drops the sender with