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
+219 -1
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use dr_decode::RawImage;
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
HistogramPass, MaskPass,
HistogramPass, MaskPass, RawHistogram, RawHistogramPass,
};
use dr_pipeline::mask::{MaskLayer, MaskSource};
@@ -724,6 +724,26 @@ pub struct DevelopSession {
/// photographer loses the histogram and keeps the photograph.
histogram: Option<HistogramPass>,
/// TRACES: FR-CULL-3
/// The raw-domain reduction, on the same terms as the display one above:
/// optional, because a session that cannot count the sensor data is still
/// a session that can develop it.
raw_histogram: Option<RawHistogramPass>,
/// The raw reading, once taken.
///
/// **Cached, where the display histogram is recomputed every settled
/// frame, and the difference is not an optimisation.** This measures the
/// demosaiced source, which nothing downstream of the demosaic can change:
/// no slider, no crop, no zoom, no output space moves a single count in
/// it. Recomputing it per frame would be a dispatch and a device sync
/// point spent to arrive back at the number already held — and on the
/// culling pass FR-CULL-3 is written for, that is a cost paid three
/// thousand times over.
///
/// `None` until first asked for, and it stays `None` on a file with no
/// sensor data behind it. The session is one photograph and the demosaiced
/// source is fixed for its life, so there is no invalidation to get wrong.
raw_counts: Option<RawHistogram>,
/// TRACES: FR-CULL-3
/// The focus-peaking overlay, on the same terms as the histogram above:
/// optional, because a device that cannot compile the pass is still a
/// device that can develop the photograph. What is lost is an instrument,
@@ -909,6 +929,10 @@ impl DevelopSession {
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
raw_histogram: RawHistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no raw histogram on this device: {e}"))
.ok(),
raw_counts: None,
peak: FocusPeakPass::new(ctx)
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
.ok(),
@@ -2927,6 +2951,64 @@ impl DevelopSession {
.ok()
}
/// TRACES: FR-CULL-3
/// Whether there is sensor data behind this session at all.
///
/// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built
/// the source from an already-rendered image. There is no white level in
/// such a file and so no scale to measure headroom against: the honest
/// answer for one is that the raw instrument has nothing to say, which is
/// a different statement from a device that could not build the pass, and
/// the panel says the two differently.
pub fn has_sensor_data(&self) -> bool {
!self.demosaiced.is_non_linear()
}
/// TRACES: FR-CULL-3
/// Count the sensor data this photograph was demosaiced from.
///
/// **This is the other histogram, not a variant of the one above**, and
/// the two answer questions that a culling decision needs kept apart.
/// [`Self::histogram`] counts the frame on the canvas, after white
/// balance, the camera matrix, the base curve, the tone curve and the
/// output transform: a clipped bin there is a highlight that is gone as
/// the image currently stands. This counts the demosaiced scene-linear
/// texture, before any of that, on an axis of stops below sensor
/// saturation — so a clipped bin here is a highlight that is gone *in the
/// file*, and no edit will bring it back. FR-CULL-3 exists because the
/// tools that offer the second reading do not develop, and the ones that
/// develop offer only the first — "no shipping tool combines both".
///
/// **It describes the whole frame, not the visible region**, which is the
/// opposite of what [`Self::histogram`] does and deliberate. A crop and a
/// zoom change what is on screen; neither changes what the sensor
/// recorded, and the question this answers — how much latitude does this
/// exposure have — is asked of the capture rather than of the view.
///
/// Computed once and cached, for the reason `raw_counts` gives.
///
/// `None` where the file carries no sensor data, or where the device could
/// not build the reduction. The caller distinguishes those with
/// [`Self::has_sensor_data`].
pub fn raw_histogram(&mut self) -> Option<RawHistogram> {
if self.raw_counts.is_none() {
if !self.has_sensor_data() {
return None;
}
// Scoped so the shared borrow of the pass and of the source ends
// before the cache is written, rather than relying on the reader
// to see that the two field paths are disjoint.
let counted = {
let pass = self.raw_histogram.as_ref()?;
pass.compute(self.demosaiced.texture())
.inspect_err(|e| log::warn!("the raw histogram failed: {e}"))
.ok()
};
self.raw_counts = counted;
}
self.raw_counts.clone()
}
/// TRACES: FR-CULL-3
/// Whether this device could build the focus-peaking overlay.
///
@@ -5817,6 +5899,142 @@ mod tests {
assert_eq!(hist.clipped_shadows(), 32 * 64);
}
/// A flat Bayer frame whose every photosite normalises to `level`.
///
/// Black at zero and a power-of-two white level, so the normalisation is
/// exact and the value the raw histogram sees is the one this asked for
/// rather than one rounded by two divisions.
fn flat_raw(size: u32, level: f32) -> RawImage {
const WHITE: u16 = 16384;
let sample = (level * f32::from(WHITE)).round() as u16;
RawImage {
width: size,
height: size,
data: vec![sample; (size * size) as usize],
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: WHITE,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: dr_decode::BaseCurve::IDENTITY,
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
}
}
/// TRACES: FR-CULL-3
#[test]
fn the_raw_histogram_describes_the_file_and_not_the_view() {
// **The property that makes it a second instrument rather than a
// second rendering of the first**, and the one every other test here
// would pass without. The display histogram beside it deliberately
// follows the edit and the visible region — that is what FR-DSP-7
// asks of it. This must do neither: cropping away half the photograph
// changes what is on the canvas and changes nothing about what the
// sensor recorded, and a culler asking how much latitude an exposure
// has is asking about the capture.
//
// Reading the adjusted output by mistake would pass a plausible-looking
// plot back — which is exactly why this asserts the *denominator* and
// the bin, not merely that something was counted.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
// 0.234253 is the centre of the bin 33 sixteenths below saturation —
// mid-bin on purpose, so the assertion is about the reduction rather
// than about how this machine's `log2` rounds an exact tie.
let raw = flat_raw(16, 3838.0 / 16384.0);
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
let before = session.raw_histogram().expect("a raw session must count");
assert_eq!(before.pixels(), 16 * 16);
assert_eq!(
before.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1 - 33],
16 * 16,
"a flat frame two stops down did not land in one bin"
);
assert_eq!(before.saturated(), 0, "nothing here is at the white level");
assert_eq!(before.at_black(), 0);
session.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
session.render(16, 16).expect("render");
// The display histogram followed the crop, as it is supposed to.
// Asserted as an inequality rather than an exact figure: how a half
// crop of a 16px frame rounds to a viewport is `AdjustPass`'s
// business and has its own tests, and pinning it here would make this
// test fail for a reason it is not about.
let shown = session.histogram().expect("histogram");
assert!(
shown.pixels() < 16 * 16,
"the crop did not reach the display histogram, so this proves nothing"
);
// The raw one did not.
let after = session.raw_histogram().expect("raw histogram");
assert_eq!(
after, before,
"the raw reading followed the crop, so it is measuring the render"
);
}
/// TRACES: FR-CULL-3
#[test]
fn a_blown_frame_reads_as_clipped_in_the_raw_domain() {
// The other end, and the reason the requirement exists. Every
// photosite at the white level is a photograph with no highlight
// headroom left in the file — no edit recovers it — and the instrument
// has to say so in the same terms whatever the develop chain currently
// makes of it.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let raw = flat_raw(16, 1.0);
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
let hist = session.raw_histogram().expect("raw histogram");
assert_eq!(hist.pixels(), 16 * 16);
assert_eq!(hist.saturated(), 16 * 16);
assert_eq!(hist.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1], 16 * 16);
}
/// TRACES: FR-CULL-3
#[test]
fn a_file_with_no_sensor_data_has_no_raw_reading_rather_than_a_wrong_one() {
// The JPEG path. Its source texture is gamma-encoded and carries no
// white level, so there is no scale to measure headroom against — and
// counting it anyway would produce a confident plot of a quantity that
// does not exist, which is the failure mode an instrument must not
// have. The panel says there is nothing to say.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(16);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
assert!(!session.has_sensor_data());
assert!(session.raw_histogram().is_none());
}
#[test]
fn routing_indices_map_back_to_the_right_parameter() {
// A wrong index would silently move the wrong slider's value, which