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
+330
View File
@@ -0,0 +1,330 @@
//! TRACES: FR-DSP-7
//! Turning bin counts into something a 280-pixel column can be read from.
//!
//! `dr_gpu` counts; this decides what the counting looks like. The split is
//! ARCH §4.3a's: how many columns a panel can show, which peak to scale
//! against and when a handful of specular pixels is worth an alarm are all
//! questions about *this* interface, and none of them belong beside the shader.
//!
//! Free-standing functions over plain numbers, deliberately. Every judgement
//! here is one that shows up as a wrong picture rather than as a crash — a
//! histogram flattened by a black-point spike, a clipping figure that reads
//! 0.0% when a quarter of the sky is gone — and none of them can be checked by
//! looking at a running application, because a plausible wrong shape and the
//! right shape look equally plausible. So they are all reachable without a GPU,
//! a window or a photograph.
use dr_gpu::{Histogram, HISTOGRAM_BINS};
use crate::HistogramView;
/// Columns the plot draws.
///
/// **A divisor of [`HISTOGRAM_BINS`], and that is not a detail.** 96 columns
/// over 256 bins folds two levels into some columns and three into others, so a
/// perfectly even ramp is drawn as a comb — structure the photograph does not
/// have. Four levels per column, always.
///
/// 64 rather than 128 because the plot is about 256px wide in the develop
/// column: four pixels per column is a bar that can be seen, where two is a
/// hairline. It also halves the repeater, and this panel is instantiated for
/// the life of the window rather than built when it is looked at.
pub(crate) const COLUMNS: usize = 64;
/// Fraction of the frame that must clip before the indicator lights.
///
/// Not zero. Almost every photograph has a few pixels at an extreme — a
/// specular glint on chrome, a sensor hot pixel, the dark corner of a vignette
/// — and an indicator that fires on all of them is one a photographer stops
/// reading within a day. A thousandth of the frame is roughly where clipping
/// stops being an artefact and starts being a decision.
const CLIP_VISIBLE: f32 = 0.001;
/// The whole panel's state, for one counted frame.
pub(crate) fn view(hist: &Histogram) -> HistogramView {
HistogramView {
// Zero pixels means nothing has been rendered yet, not an empty
// photograph — the panel draws its frame and no data rather than a flat
// line, which would be a claim about an image that does not exist.
available: hist.pixels() > 0,
luma: model(&scaled(hist.luma())),
red: model(&scaled(hist.red())),
green: model(&scaled(hist.green())),
blue: model(&scaled(hist.blue())),
highlights_clipped: fraction(hist.clipped_highlights(), hist.pixels()) >= CLIP_VISIBLE,
shadows_clipped: fraction(hist.clipped_shadows(), hist.pixels()) >= CLIP_VISIBLE,
highlights_label: percentage(hist.clipped_highlights(), hist.pixels()).into(),
shadows_label: percentage(hist.clipped_shadows(), hist.pixels()).into(),
}
}
/// An empty panel — no image open, or a frame that could not be counted.
pub(crate) fn empty() -> HistogramView {
HistogramView {
available: false,
luma: model(&[0.0; COLUMNS]),
red: model(&[0.0; COLUMNS]),
green: model(&[0.0; COLUMNS]),
blue: model(&[0.0; COLUMNS]),
highlights_clipped: false,
shadows_clipped: false,
// Not "0%". Nothing has been counted, and claiming no clipping about a
// frame that does not exist is the one thing an instrument must not do.
highlights_label: UNKNOWN.into(),
shadows_label: UNKNOWN.into(),
}
}
/// What a figure reads before there is a frame behind it.
const UNKNOWN: &str = "—";
fn model(heights: &[f32; COLUMNS]) -> slint::ModelRc<f32> {
slint::ModelRc::new(slint::VecModel::from(heights.to_vec()))
}
/// Fold 256 levels into [`COLUMNS`] columns and scale to 0..1.
pub(crate) fn scaled(bins: &[u32; HISTOGRAM_BINS]) -> [f32; COLUMNS] {
let folded = fold(bins);
let peak = peak(&folded);
if peak == 0 {
return [0.0; COLUMNS];
}
let mut out = [0.0f32; COLUMNS];
for (o, count) in out.iter_mut().zip(folded.iter()) {
// Clamped because `peak` deliberately ignores the end columns, so the
// spike at a clipped white is taller than the scale it is drawn on.
*o = (*count as f32 / peak as f32).min(1.0);
}
out
}
/// Sum adjacent levels into the columns the plot has room for.
fn fold(bins: &[u32; HISTOGRAM_BINS]) -> [u32; COLUMNS] {
const PER_COLUMN: usize = HISTOGRAM_BINS / COLUMNS;
let mut out = [0u32; COLUMNS];
for (level, count) in bins.iter().enumerate() {
// Saturating: a 24 MP frame of one colour is 24 million in one bin,
// which fits, but two folded bins of a hypothetical larger frame need
// not — and a histogram that wrapped to near-zero at the exact moment
// the image went flat would be worse than useless.
out[level / PER_COLUMN] = out[level / PER_COLUMN].saturating_add(*count);
}
out
}
/// The count the plot's full height stands for.
///
/// **The end columns are excluded, and this is the single most consequential
/// decision in the file.** A photograph shot against a black backdrop puts a
/// third of its pixels in level 0; scaled against that, every tone the
/// photographer is actually working with is drawn two pixels tall and the
/// histogram says nothing. The same happens at the top with a blown sky. Both
/// spikes are exactly what the clipping indicators report separately and in
/// figures, so nothing is hidden by leaving them off the scale — the plot stops
/// being dominated by the one fact it was already stating twice.
///
/// Falls back to the true maximum when the ends are all there is, so a frame
/// that really is entirely black still draws something rather than nothing.
fn peak(folded: &[u32; COLUMNS]) -> u32 {
let interior = folded[1..COLUMNS - 1].iter().copied().max().unwrap_or(0);
if interior > 0 {
interior
} else {
folded.iter().copied().max().unwrap_or(0)
}
}
fn fraction(clipped: u32, pixels: u32) -> f32 {
if pixels == 0 {
0.0
} else {
clipped as f32 / pixels as f32
}
}
/// How much of the frame is gone, as a figure rather than a colour.
///
/// NFR-A11Y-3 asks that no status be carried by hue alone, and this is the
/// text half of that: the marker beside it says *whether*, and this says how
/// much — which is the more useful half anyway, since the choice between
/// pulling a stop back and leaving a specular highlight alone is a choice about
/// magnitude.
///
/// Distinguishes "none" from "not none but under a tenth of a percent": those
/// are different answers, and rounding the second to `0.0%` would tell a
/// photographer their highlights were safe when the indicator beside it is lit.
fn percentage(clipped: u32, pixels: u32) -> String {
if pixels == 0 || clipped == 0 {
return "0%".into();
}
let pct = 100.0 * fraction(clipped, pixels);
if pct < 0.1 {
"<0.1%".into()
} else {
format!("{pct:.1}%")
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A histogram with `count` pixels at each named level and nothing else.
///
/// Built through the GPU pass's own constructor path would need a device;
/// these are assertions about arithmetic, so they take the counts directly.
fn bins(levels: &[(usize, u32)]) -> [u32; HISTOGRAM_BINS] {
let mut out = [0u32; HISTOGRAM_BINS];
for (level, count) in levels {
out[*level] = *count;
}
out
}
#[test]
fn every_column_covers_the_same_number_of_levels() {
// The comb bug. With a column count that does not divide 256, some
// columns gather three levels and some two, so a perfectly even ramp is
// drawn with every third bar 50% taller — which reads as structure in
// the image that is not there. Asserted on the fold rather than on the
// constant, because the constant being wrong is only a problem through
// what the fold then does with it.
assert_eq!(HISTOGRAM_BINS % COLUMNS, 0);
let flat = [7u32; HISTOGRAM_BINS];
let folded = fold(&flat);
let first = folded[0];
assert!(first > 0);
assert!(
folded.iter().all(|c| *c == first),
"an even distribution must fold to even columns, got {folded:?}"
);
}
#[test]
fn a_level_lands_in_the_column_that_covers_it() {
// Routing, level by level. An off-by-one here draws the whole
// photograph one column left of where it belongs, which is invisible on
// any image and wrong on all of them.
let per = HISTOGRAM_BINS / COLUMNS;
for level in [0usize, 1, per, per + 1, HISTOGRAM_BINS - 1] {
let folded = fold(&bins(&[(level, 5)]));
let expected = level / per;
assert_eq!(
folded[expected], 5,
"level {level} missed column {expected}"
);
assert_eq!(
folded.iter().sum::<u32>(),
5,
"level {level} was counted in more than one column"
);
}
}
#[test]
fn a_clipped_spike_does_not_flatten_everything_else() {
// The reason `peak` ignores the ends. A frame that is nine-tenths pure
// black — a studio shot on a black backdrop, or any night scene — must
// still show the tones the photographer is working on. Scaled against
// the black spike they would be a hundredth of the plot's height, which
// is a flat line.
let mut levels = vec![(0usize, 900_000u32)];
levels.push((128, 1000));
levels.push((129, 500));
let heights = scaled(&bins(&levels));
let mid = heights[128 / (HISTOGRAM_BINS / COLUMNS)];
assert!(
mid > 0.9,
"the tallest interior column should reach the top, got {mid}"
);
// And the spike is still drawn, at the ceiling rather than off it.
assert_eq!(heights[0], 1.0);
}
#[test]
fn an_entirely_black_frame_still_draws_its_spike() {
// The fallback in `peak`. With every pixel at level 0 the interior is
// empty, and dividing by that peak would either panic or produce a
// plot with nothing in it — which says "no data" about a frame that has
// a great deal of data, all of it bad news.
let heights = scaled(&bins(&[(0, 4096)]));
assert_eq!(heights[0], 1.0);
assert!(heights[1..].iter().all(|h| *h == 0.0));
}
#[test]
fn an_empty_frame_scales_to_nothing_rather_than_dividing_by_zero() {
let heights = scaled(&[0u32; HISTOGRAM_BINS]);
assert!(heights.iter().all(|h| *h == 0.0));
}
#[test]
fn heights_never_leave_the_plot() {
// Everything downstream multiplies these by the plot's height, so a
// value above 1 paints outside the box and one below 0 paints upward
// out of the panel. Checked across the shapes most likely to break it:
// all the weight at one end, and all of it in one interior column.
for levels in [
vec![(0usize, 100u32)],
vec![(255, 100)],
vec![(0, 100), (255, 100)],
vec![(64, 100)],
vec![(0, 1_000_000), (64, 1)],
] {
for h in scaled(&bins(&levels)) {
assert!((0.0..=1.0).contains(&h), "height {h} left the plot");
}
}
}
#[test]
fn folding_cannot_overflow_on_a_large_frame() {
// `u32` counts summed two at a time. A saturating add rather than a
// wrapping one, because the wrap would land near zero — a histogram
// that emptied itself at the exact moment the image became flat.
let folded = fold(&[u32::MAX; HISTOGRAM_BINS]);
assert_eq!(folded[0], u32::MAX);
}
#[test]
fn a_clipping_figure_distinguishes_none_from_nearly_none() {
// `0.0%` and `<0.1%` are different answers, and the second is the one
// that appears beside a lit marker. Rounding it to the first would have
// the panel contradict itself: an indicator saying "clipped" over a
// figure saying "none".
assert_eq!(percentage(0, 1000), "0%");
assert_eq!(percentage(1, 1_000_000), "<0.1%");
assert_eq!(percentage(12, 1000), "1.2%");
assert_eq!(percentage(1000, 1000), "100.0%");
// No frame yet: not a claim that nothing is clipped so much as nothing
// to claim, and "0%" is the honest reading of zero pixels counted.
assert_eq!(percentage(0, 0), "0%");
}
#[test]
fn an_uncounted_frame_says_it_does_not_know_rather_than_says_zero() {
// The panel is emptied when an image is closed and again when one fails
// to render, and it stays empty until the first settled frame. Reading
// "Highlights 0%" through that gap is a positive claim about a
// photograph nobody has counted — the failure mode of an instrument
// that is worse than no instrument.
let blank = empty();
assert!(!blank.available);
assert_eq!(blank.highlights_label, UNKNOWN);
assert_eq!(blank.shadows_label, UNKNOWN);
assert!(!blank.highlights_clipped && !blank.shadows_clipped);
}
#[test]
fn the_indicator_ignores_a_handful_of_specular_pixels() {
// The threshold, from both sides. Below it the marker stays dark while
// the figure still reports what it found — an indicator that fired on
// every glint would be one nobody reads, and one that hid the number
// would be one nobody could check.
let just_under = (CLIP_VISIBLE * 1_000_000.0) as u32 - 1;
assert!(fraction(just_under, 1_000_000) < CLIP_VISIBLE);
assert!(fraction(just_under + 1, 1_000_000) >= CLIP_VISIBLE);
}
}