Files
DarkRoom/core/dr-gpu/src/shaders/raw_histogram.wgsl
T
dtourolle 4b6c110816 Count the sensor's own numbers, so a cull can see headroom the render hides
FR-CULL-3's remaining two bullets. What existed was a *display* histogram
tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit
code value, and counts clipping as `r == 255`. Its own documentation says a
clipped bin means "a highlight that is actually gone rather than one the
transform might still recover", which is the opposite of what a culling
decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the
explicit grounds that a rendered image "systematically lies about what is
recoverable in the raw", and a readout that measures the render cannot answer
that however it is presented.

So this is a second instrument beside the first rather than a setting on it.
Both are true; they are true about different things; the panel offers both
behind a chip row and the words travel with the numbers, because a raw
saturation figure drawn under a heading saying Highlights would be mislabelled
exactly where the difference matters.

**What is reduced over, and what it cost to decide.** ARCH §5.5 specified the
pre-demosaic CFA samples. This reduces over the demosaiced scene-linear
texture instead, and §5.5 is amended to record the choice rather than let the
specification and the code disagree in silence. The texture is camera-native —
unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black
and white levels, so 1.0 is saturation by construction and the distribution
below it is the headroom question with no calibration to carry. Retaining the
CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently
drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether
or not anyone looks at the histogram, on a platform §6.2 exists because memory
is scarce on.

Three things it therefore cannot say, written into the module docs and into
§5.5 rather than left to be discovered: it counts pixels not photosites, so a
saturated site drags its interpolated neighbours up and per-channel clipping
is smeared by about a demosaic kernel; it cannot see above white, because
demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon
6D reads to 16383 against a declared 15070) so "at saturation" and "a stop
past it" share a bin; and it is measured after the CFA pattern is gone, so it
can name which colour clipped in the reconstructed image but not which
photosite went first.

The axis is stops below saturation, 16 bins per stop over 256 bins — the same
bin count the display reduction uses, so the fold into drawable columns is
shared and a divergence between the two plots would have to be deliberate. A
linear axis spends half its width on the top stop, which is why nobody has
ever drawn a useful linear raw histogram. The fourth series is the brightest
channel rather than luma: these values are unbalanced, so any weighted sum of
them is a number about nothing, and the brightest channel is the one that
saturates first and so the one the headroom question is actually about.

It is a property of the file and not of the render, which has two
consequences. It is computed once per photograph and cached — nothing
downstream of the demosaic can move a count in it — so a cull does not pay the
display histogram's per-frame cost three thousand times. And it describes the
whole frame rather than the visible region, deliberately opposite to
DevelopSession::histogram: a crop changes what is on screen and changes
nothing about what the sensor recorded.

Tags are on the reduction, the type, its constructor and the presentation
arithmetic, each of which has a test that fails if the behaviour goes. The
Slint panel and the push from lib.rs keep their reasoning as prose: nothing
asserts them, and a tag would claim coverage the assertions are not making.
2026-08-29 23:36:21 +02:00

157 lines
7.2 KiB
WebGPU Shading Language

// TRACES: FR-CULL-3
// A reduction of the *scene-linear* frame onto a stops-below-saturation axis.
//
// The shader beside this one, `histogram.wgsl`, counts the frame the display
// is about to show: an 8-bit code value, after white balance, the camera
// matrix, the base curve, the tone curve and the output transform. This one
// counts the texture the demosaic wrote, before any of that. The two differ in
// exactly one place — the axis — and everything else here is deliberately the
// same construction, because the two reductions have the same shape and any
// divergence between them would be a difference nobody chose.
//
// **Why the axis is logarithmic and measured downward from 1.0.** The texture
// is normalised by the sensor's own black and white levels (see
// `demosaic.wgsl`), so 1.0 *is* saturation by construction and there is no
// other meaningful origin. And the question a photographer asks of raw data is
// "how much headroom is left", which is a question in stops: a linear axis
// spends half its width on the top stop and crushes the eleven below it into
// the leftmost pixel, which is why nobody has ever drawn a useful linear raw
// histogram.
//
// So bin 255 is the top 1/16 stop below saturation, bin 239 is one stop below,
// bin 0 is 15.9375 stops below **and everything darker**. A bin is 1/16 of a
// stop, which is about 4.4% in value — fine enough that a clipped highlight is
// visibly its own column rather than smeared over the top quarter of the plot.
//
// **Counted per workgroup first, then merged**, for the reason
// `histogram.wgsl` gives at length: a photograph is not noise, tens of
// thousands of adjacent pixels land in one bin, and having every invocation
// contend for one global atomic serialises the dispatch. Four kilobytes of
// workgroup storage against a 16 KB floor.
const BINS: u32 = 256u;
// Bins per stop. Must equal `BINS_PER_STOP` on the Rust side, which turns a
// bin index back into a stops figure for the readout — the two disagreeing
// would put a correct plot under a wrong number.
const BINS_PER_STOP: f32 = 16.0;
// Two counters past the four series, in the same buffer and for the same
// reason `histogram.wgsl` puts its there: they are gathered from the texel
// read the binning already did, and a second reduction to answer them would be
// a second pass over the whole image for two integers.
const SATURATED: u32 = 4u * BINS;
const AT_BLACK: u32 = 4u * BINS + 1u;
const SLOTS: u32 = 4u * BINS + 2u;
// 16x16. Stated as a constant because the clear and merge loops stride by it,
// and a workgroup size that disagreed would leave slots uncleared.
const THREADS: u32 = 256u;
// How close to 1.0 counts as saturated.
//
// **Not `>= 1.0`, and the margin is arithmetic rather than taste.** A photosite
// at or above the declared white level leaves `demosaic.wgsl` clamped to
// exactly 1.0, but it then travels through an f16 texture and, at a green
// site, through an interpolation kernel whose terms are added in a different
// order than a reader would expect. f16 spaces its values 2^-11 apart just
// below 1.0, so a value that was 1.0 can arrive an ulp or two under it. This
// margin is four of those, and 0.0014 of a stop — a hundredth of the width of
// one bin — so it cannot move a column of the plot, only stop the counter
// missing a genuinely blown pixel.
const SATURATION: f32 = 0.999;
struct Dims {
width: u32,
height: u32,
// std140 rounds a uniform block up to 16 bytes; named rather than left
// implicit so the Rust side's padding is visibly the same shape.
pad_0: u32,
pad_1: u32,
}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> bins: array<atomic<u32>>;
@group(0) @binding(2) var<uniform> dims: Dims;
var<workgroup> tile: array<atomic<u32>, SLOTS>;
// The bin a scene-linear value belongs in.
//
// Zero and below land in bin 0: `log2` of them is not a number, and a
// photosite at or under the black level has no signal to place on a
// logarithmic axis anyway — it is the bottom of the scale, which is where the
// bottom bin is.
//
// Above 1.0 lands in bin 255. Such values exist: the demosaic clamps each
// *photosite* at the white level, but the Malvar correction term can overshoot
// upward between two clipped ones, so the interpolated output can pass 1.0 by
// a little. That is an artefact of the reconstruction and not headroom above
// saturation, and folding it into the top bin says exactly that.
fn bin_of(v: f32) -> u32 {
if (v <= 0.0) {
return 0u;
}
// Stops below saturation, in sixteenths, floored. `-log2` because the axis
// increases downward from 1.0 and a bin index increases upward.
let steps = floor(-log2(v) * BINS_PER_STOP);
return (BINS - 1u) - u32(clamp(steps, 0.0, f32(BINS - 1u)));
}
@compute @workgroup_size(16, 16, 1)
fn main(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) lid: u32,
) {
for (var i = lid; i < SLOTS; i = i + THREADS) {
atomicStore(&tile[i], 0u);
}
workgroupBarrier();
// Guarded rather than dispatched exactly: the workgroup is 16x16 and an
// image is not, so the last row and column of workgroups run off the edge.
if (gid.x < dims.width && gid.y < dims.height) {
let texel = textureLoad(source, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
let r = texel.r;
let g = texel.g;
let b = texel.b;
// The fourth series is the **brightest** channel at each pixel, where
// the display histogram's fourth series is Rec.709 luma. Luma would be
// meaningless here: these are camera-native values with the as-shot
// white balance still un-applied, so red and blue sit a stop or more
// below green on a neutral subject and any weighted sum of them is a
// number about nothing. The brightest channel, by contrast, is exactly
// the quantity the headroom question is about — it is the one that
// reaches saturation first and decides whether the pixel is
// recoverable.
let brightest = max(r, max(g, b));
atomicAdd(&tile[bin_of(r)], 1u);
atomicAdd(&tile[BINS + bin_of(g)], 1u);
atomicAdd(&tile[2u * BINS + bin_of(b)], 1u);
atomicAdd(&tile[3u * BINS + bin_of(brightest)], 1u);
// Any channel, not all three, exactly as the display histogram counts
// it: a saturated red photosite has no gradation left in it however
// much headroom green and blue still hold, and a sunset or a red
// jersey is precisely the subject that clips one channel first.
if (brightest >= SATURATION) {
atomicAdd(&tile[SATURATED], 1u);
}
// And at the other end: a channel that reached the black level has no
// signal, only the read noise the normalisation clamped away.
if (min(r, min(g, b)) <= 0.0) {
atomicAdd(&tile[AT_BLACK], 1u);
}
}
workgroupBarrier();
for (var i = lid; i < SLOTS; i = i + THREADS) {
let count = atomicLoad(&tile[i]);
// Most bins of most workgroups are empty — a 16x16 tile can touch 256
// of 1026 slots at the very most, and usually far fewer.
if (count != 0u) {
atomicAdd(&bins[i], count);
}
}
}