See what the highlights are doing: a live histogram (FR-DSP-7)

Exposure, blacks and whites were set by eye. Nothing said a highlight had
blown — the canvas shows white where a channel is at 250 and white where it is
at 255, and the difference is the whole question.

**Counted on the GPU, not on the readback.** There is a full frame sitting in
CPU memory on every canvas update right now — `AdjustPass::read_output`, the
bridge spike S1 removes — and walking it would have been thirty lines and no
shader. FR-DSP-7 states the mechanism and not just the feature: "these derive
from a GPU-side reduction into a small buffer. Per-frame CPU readback of image
data is prohibited." A histogram founded on the bridge would be correct today
and deleted by S1, and would meanwhile be the reason the bridge could not go.
What crosses the bus here is 4104 bytes whatever the image size.

The reduction tallies into workgroup memory first and merges once per
workgroup. A photograph is not noise: a clear sky puts tens of thousands of
adjacent pixels in one bin, and contending for that single global atomic
serialises the dispatch.

**On the settled frame only.** `render_now` already knows whether a gesture is
still moving — `draft` is the flag `redraw` derives from `was_coalesced` — so
the dispatch and its transfer happen once when the slider stops rather than on
each of the forty frames a drag emits. Nothing is lost: a histogram flickering
past under a finger is not a reading anyone takes. FR-DSP-7 requires exactly
this, that it not extend the FR-DSP-3 frame budget.

Luma is weighted in 8.8 fixed point — 54, 183, 19, summing to 256 exactly —
rather than in floats. Not thrift: it makes the shader's arithmetic
reproducible bit for bit, which is what lets the test below be an `assert_eq`
against a CPU count rather than a tolerance. ARCH §6.13's line about integer
state, applied where it happens to also be free.

**What the numbers were checked against.** A flat frame must put all 4096
pixels in one bin and one only. A 256-wide ramp must occupy every level with
exactly the same count, which is what catches an off-by-one in the
quantisation — a `floor` where a rounding was needed shifts the whole
photograph one bin left and looks like nothing at all. And a 101x37 frame of
seeded pseudo-random pixels — deliberately not a multiple of the 16x16
workgroup, so the edge tiles run off the image — is compared slot for slot
against a second, obvious CPU implementation. Exact equality, no tolerance.
The CPU version is a deliberate reimplementation rather than shared code: the
bugs worth catching here are ones shared code would commit identically on both
sides.

Above that, the presentation arithmetic is unit-tested headless, because it is
where a wrong answer is invisible. A histogram of the wrong shape looks exactly
as plausible as one of the right shape. So: 64 columns because it divides 256
and an uneven fold draws an even ramp as a comb; the peak excludes the end
columns, or a night scene scaled against its own black spike is a flat line
with no information in it; heights are clamped into the plot; and "0%" is kept
distinct from "<0.1%" and from "—", since an indicator reading "clipped" over
a figure reading "none" is a panel contradicting itself.

Clipping counts a *pixel* with any channel at an extreme, not a channel. Any,
because a blown red has no gradation left in it however much green and blue
still hold — and it is the saturated highlight, the sunset and the red jersey,
that clips first and recovers worst. Per pixel, because counting channels can
report 200% of a frame clipped, and a percentage above 100 is a readout nobody
trusts again.

Two affordances for it, which NFR-A11Y-3 asks for: a bar standing at the end
of the plot the tones are piling against, and a figure saying how much. Either
alone reads.

The panel sits directly under the capture metadata and above every control,
because it is what the controls are judged against. It is hand-built rather
than generated, and ARCH §4.3a is untroubled: a histogram is not an operation
— no parameters, changes nothing, answers a question rather than asking one —
and nothing in it reads a parameter out of a descriptor.

Three plot colours and a neutral luma trace join the palette. That is the
swatch's exception rather than a second one: a per-channel histogram has to
say which channel, and no achromatic treatment distinguishes red from blue, so
the hue is data exactly as the image beside it is. Held well back from full
strength for the reason the theme preamble gives.

The bounded, non-parking map wait moves out of `AdjustPass` into
`readback::await_mapping`, shared with the histogram's transfer. Thirty lines
of load-bearing reasoning about frozen interfaces and lost devices, and two
copies of it would have drifted.

The histogram describes the frame on the canvas, so it is in the output colour
space FR-DSP-7 asks for, and when zoomed it describes the visible region — a
photographer inspecting a highlight at 4x is asking about that highlight. A
device that cannot build the reduction loses the histogram and keeps the
photograph.

Still to do for FR-DSP-7: the pixel colour readout under the cursor.

324 tests pass, clippy and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:55:40 +02:00
co-authored by Claude Opus 5
parent 2330ed25e9
commit 0233df4bf2
11 changed files with 1541 additions and 46 deletions
+119 -1
View File
@@ -10,7 +10,7 @@
//! new operation appears in the panel with no change here (FR-DEV-3c).
use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass};
use dr_pipeline::ops::curve;
use dr_pipeline::{
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Preset, Scope, Unit,
@@ -25,6 +25,12 @@ pub struct DevelopSession {
graph: EditGraph,
demosaiced: DemosaicedImage,
adjust: AdjustPass,
/// TRACES: FR-DSP-7
/// Optional, because a session that cannot count its frames is still a
/// session that can develop them. If the reduction fails to build — an
/// old driver, a device without the storage-buffer atomics it needs — the
/// photographer loses the histogram and keeps the photograph.
histogram: Option<HistogramPass>,
}
impl DevelopSession {
@@ -77,6 +83,9 @@ impl DevelopSession {
graph,
demosaiced,
adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
}
}
@@ -533,6 +542,34 @@ impl DevelopSession {
Ok(slint::Image::from_rgba8(buffer))
}
/// TRACES: FR-DSP-7
/// Count the frame that is currently on the canvas.
///
/// **Reads the frame [`Self::render`] last produced rather than rendering
/// its own.** The histogram has to describe what the photographer is
/// looking at, and rendering a second time to count it would both cost a
/// second pass and open the possibility of the two disagreeing.
///
/// That the frame is the *displayed* one has two consequences worth being
/// explicit about. It is in the output colour space, which is what
/// FR-DSP-7 asks for — the levels counted are the levels the display will
/// show, so a clipped bin means a highlight that is actually gone rather
/// than one the transform might still recover. And when the view is zoomed
/// or cropped it describes the visible region, not the whole file: a
/// photographer inspecting a highlight at 4× is asking about *that*
/// highlight, and a histogram of the parts of the frame off screen would
/// be answering a question nobody asked.
///
/// `None` where nothing has been rendered yet, or where the device could
/// not build the reduction.
pub fn histogram(&self) -> Option<Histogram> {
let pass = self.histogram.as_ref()?;
let frame = self.adjust.output()?;
pass.compute(frame)
.inspect_err(|e| log::warn!("histogram failed: {e}"))
.ok()
}
/// Render the *whole* frame for the crop overlay to be drawn over.
///
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
@@ -1871,6 +1908,87 @@ mod tests {
}
}
/// A frame black on the left half and white on the right, at `size`
/// square. Both ends of the histogram are occupied and both clipping
/// counters are non-zero, and cropping to one half leaves exactly one of
/// them so.
fn split_frame(size: u32) -> Vec<u8> {
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
for _ in 0..size {
for x in 0..size {
let v = if x < size / 2 { 0u8 } else { 255 };
rgba.extend_from_slice(&[v, v, v, 255]);
}
}
rgba
}
/// TRACES: FR-DSP-7
#[test]
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
// The wiring, end to end and against exact numbers: a 64x64 frame that
// is half black and half white must come back as 2048 pixels at level
// 0, 2048 at 255, and both clipping counters at 2048.
//
// Asserted at the session rather than at the pass because the mistake
// this catches is not arithmetic — `dr_gpu` has its own tests for that
// — it is counting the *wrong texture*. Reading a stale target, or the
// demosaiced source instead of the adjusted output, produces a
// perfectly well-formed histogram of an image the photographer is not
// looking at, which is the one failure mode that cannot be seen.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(64);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
session.render(64, 64).expect("render");
let hist = session.histogram().expect("a rendered session must count");
assert_eq!(hist.pixels(), 64 * 64);
assert_eq!(hist.red()[0], 2048, "the black half");
assert_eq!(hist.red()[255], 2048, "the white half");
assert_eq!(hist.clipped_shadows(), 2048);
assert_eq!(hist.clipped_highlights(), 2048);
}
/// TRACES: FR-DSP-7
#[test]
fn the_histogram_follows_the_edit_rather_than_the_file() {
// The property that makes it *live*. A histogram computed once from the
// source would pass the test above and be useless — the whole reason
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
// away the white half must leave a histogram with no white in it and
// no highlight clipping to report.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(64);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
session.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
session.render(64, 64).expect("render");
let hist = session.histogram().expect("histogram");
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
assert_eq!(hist.red()[0], 32 * 64);
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
assert_eq!(hist.clipped_highlights(), 0);
assert_eq!(hist.clipped_shadows(), 32 * 64);
}
#[test]
fn routing_indices_map_back_to_the_right_parameter() {
// A wrong index would silently move the wrong slider's value, which