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