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
+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