From 31e20399c8e6856a4c4939cdcae587006fd4871c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Mon, 17 Aug 2026 13:13:17 +0200 Subject: [PATCH] Read the true white level, and clamp the sensor stage at both ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the sensor stage, found while chasing magenta highlights. Neither is the cause of that — see below — but both are wrong on their own terms. `white_level` took the *first* of rawler's per-channel saturation points. On a Canon 6D that reports 15070 while the data reaches 16383, so every sample above it was treated as brighter than white. It takes the maximum now. The normalisation clamped its floor and not its ceiling, so those over-white samples passed through as values above 1.0. Clamped at both ends. **This does not fix the pink.** Measured on _MG_8596.CR2, exported and looked at: the subject renders correctly and only the blown sky is magenta. A fully clipped pixel is (1,1,1) in raw, the as-shot balance multiplies it to (1.93, 1.00, 1.68), and the camera matrix turns that into R 2.88, G 0.51, B 2.03 — red and blue clip at one, green does not, and the result is magenta. It is correct white balance applied to already-saturated data, which is the classic highlight-clipping cast and needs highlight desaturation to fix: a pixel at saturation carries no colour information and must be rendered neutral, not balanced. Co-Authored-By: Claude Opus 5 --- core/dr-decode/src/lib.rs | 10 ++++++++-- core/dr-gpu/src/shaders/demosaic.wgsl | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs index 9a0f88d..c430b6e 100644 --- a/core/dr-decode/src/lib.rs +++ b/core/dr-decode/src/lib.rs @@ -481,11 +481,17 @@ pub fn decode(bytes: &[u8]) -> Result { data, cfa_pattern: cfa, black_level, + // The **maximum** of the per-channel saturation points, not the + // first. Taking one channel's value under-reports the others', and + // every sample above the level it is normalised against becomes a + // highlight the pipeline treats as brighter than white. white_level: image .whitelevel .0 - .first() - .map(|v| *v as u16) + .iter() + .copied() + .max() + .map(|v| v as u16) .unwrap_or(u16::MAX), wb_coeffs, color_matrix, diff --git a/core/dr-gpu/src/shaders/demosaic.wgsl b/core/dr-gpu/src/shaders/demosaic.wgsl index 53dd18d..f86b0f9 100644 --- a/core/dr-gpu/src/shaders/demosaic.wgsl +++ b/core/dr-gpu/src/shaders/demosaic.wgsl @@ -102,7 +102,22 @@ fn sample(ix: i32, iy: i32) -> f32 { // result is clamped rather than allowed to wrap. let cell = (u32(cy) & 1u) * 2u + (u32(cx) & 1u); let value = (f32(raw_value) - params.black[cell]) * params.inv_range[cell]; - return max(value, 0.0); + + // **Clamped at the top as well, and that is what stops blown highlights + // going pink.** Sensors read above their declared white level — on a + // Canon 6D CR2 the data reaches 16383 against a white of 15070 — so a + // saturated pixel normalises to about 1.1 rather than 1.0. + // + // Left unclamped it survives the white balance, where red is multiplied + // by ~1.93 and blue by ~1.68 against green's 1.0, and then the camera + // matrix. Red and blue clip at the end of the pipeline; green, whose + // matrix row is far less positive-heavy, does not. Red and blue high with + // green low is magenta, and a clipped highlight came back pink. + // + // Clamping here makes a blown pixel saturate *neutrally*: all three + // channels reach 1.0 together and the highlight is white, which is what a + // blown highlight looks like and what every other developer produces. + return clamp(value, 0.0, 1.0); } @compute @workgroup_size(8, 8, 1)