Keep blown highlights grey in a panorama

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.
This commit is contained in:
2026-09-27 16:29:28 -04:00
parent f52c4cb8b6
commit e98b1def98
4 changed files with 74 additions and 6 deletions
+13 -2
View File
@@ -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(&params, tile);
}
+17 -2
View File
@@ -46,7 +46,10 @@ struct Params {
tile_size: vec2<u32>,
// 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<f32>,
};
@group(0) @binding(0) var<uniform> p: Params;
@@ -125,7 +128,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
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<f32>(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<f32>(rgb * wa, wa);
+1 -1
View File
@@ -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) |
+43 -1
View File
@@ -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<MergeFrame> = 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