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.
This commit is contained in:
2026-08-29 23:36:21 +02:00
parent a2a0131693
commit 4b6c110816
10 changed files with 1793 additions and 47 deletions
+38
View File
@@ -320,6 +320,11 @@ fn reset_view_state(window: &AppWindow) {
// beside the next one's filename is a confident, precise lie, and the gap
// before the new frame settles is exactly long enough to read it.
window.set_histogram(histogram::empty());
// And the raw reading with it. It belongs to one file more completely than
// the display histogram does — nothing about the edit can move it — which
// makes leaving it standing over the next photograph's filename worse
// rather than better: it would look exactly as current as it is wrong.
window.set_raw_histogram(histogram::raw_empty(histogram::RawAbsence::NoImage));
// TRACES: FR-CULL-3
// The marks go down with it, and for the same reason. What is *not* reset
// is whether peaking is switched on: that is a way of looking at a folder
@@ -1669,6 +1674,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.as_ref()
.map_or_else(histogram::empty, histogram::view),
);
// **The raw reading, pushed on the same path but not
// recomputed on it.** `DevelopSession::raw_histogram`
// caches: the demosaiced source is fixed for the life
// of the session, so this costs one dispatch per
// photograph and a clone of four kilobytes per settled
// frame thereafter. It is pushed from here anyway
// rather than once at open, because this is the one
// path every photograph takes and a panel that had to
// be told separately would be empty on some route into
// develop mode.
//
// Three outcomes, said apart. A file that was never
// raw has no white level and so no scale to measure
// headroom against; a device that could not build the
// reduction has a fault worth reporting; and only the
// third is a reading.
let raw = if !s.has_sensor_data() {
histogram::raw_empty(histogram::RawAbsence::NotRaw)
} else {
s.raw_histogram().as_ref().map_or_else(
|| histogram::raw_empty(histogram::RawAbsence::NoDevice),
histogram::raw_view,
)
};
window.set_raw_histogram(raw);
}
// TRACES: FR-CULL-3 | NFR-P14
@@ -1705,6 +1736,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// No frame, so nothing to describe. The stale plot would
// otherwise sit beside the error message looking current.
window.set_histogram(histogram::empty());
// **The raw reading is left standing, and that is not an
// oversight.** It describes the sensor data behind the
// session, which a failed *render* says nothing about: the
// demosaic succeeded or there would be no session at all.
// Emptying it here would take away the one instrument
// still telling the truth, at the moment the other one
// stopped.
// TRACES: FR-CULL-3
// And nothing to mark. Focus marks over the last frame
// that rendered, beside a message saying this one did not,