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>
249 lines
9.3 KiB
Plaintext
249 lines
9.3 KiB
Plaintext
// TRACES: FR-DSP-7
|
|
// The live histogram, and what it says about clipping.
|
|
//
|
|
// **Why this is hand-built when the rest of the column is generated.** The
|
|
// develop panel is built from operation capabilities, and a histogram is not an
|
|
// operation: it has no parameters, changes nothing about the photograph, and
|
|
// answers a question rather than asking one. It is an *instrument* — the same
|
|
// kind of thing as the zoom readout — so it is written, and ARCH §4.3a is
|
|
// untroubled by it: nothing here reads a parameter out of a descriptor.
|
|
//
|
|
// **Everything numeric was decided in Rust.** The heights arriving here are
|
|
// already 0..1 against a chosen scale, and the clipping figures are already
|
|
// strings. That is not tidiness — it is the only way the arithmetic is
|
|
// testable. A peak chosen in Slint could only be checked by looking at it, and
|
|
// a histogram that is the wrong shape looks exactly as plausible as one that is
|
|
// right (see `src/histogram.rs`).
|
|
|
|
import { Theme } from "theme.slint";
|
|
import { PanelHeading, Caption, Panel } from "widgets.slint";
|
|
|
|
// One counted frame, ready to draw.
|
|
//
|
|
// Heights are 0..1 with y up, darkest level first — the same convention the
|
|
// tone curve's samples use, so the two plots in this column cannot end up
|
|
// disagreeing about which way is up.
|
|
export struct HistogramView {
|
|
// Whether a frame has been counted at all. Distinct from an all-zero
|
|
// histogram, which would be a claim about an image rather than the absence
|
|
// of one.
|
|
available: bool,
|
|
|
|
luma: [float],
|
|
red: [float],
|
|
green: [float],
|
|
blue: [float],
|
|
|
|
// Past the threshold worth reporting — see `CLIP_VISIBLE` in Rust.
|
|
highlights-clipped: bool,
|
|
shadows-clipped: bool,
|
|
|
|
// How much, as text. NFR-A11Y-3: the marker beside these says *whether*
|
|
// by hue and position, and these say *how much* in a form that survives
|
|
// being unable to tell the two apart.
|
|
highlights-label: string,
|
|
shadows-label: string,
|
|
}
|
|
|
|
// One series, as a run of columns.
|
|
//
|
|
// Two shapes from one component because the two differ only in where the column
|
|
// starts: the luminance trace is filled from the baseline, the three channels
|
|
// are a line. Splitting them into two components would duplicate the column
|
|
// arithmetic, which is the part that has to stay identical or the four series
|
|
// would no longer be plotted against the same axis.
|
|
//
|
|
// One thin Rectangle per column: Slint has no polyline primitive, and this is
|
|
// the same construction `CurveEditor` uses a few files away.
|
|
component Trace inherits Rectangle {
|
|
/// 0..1, y up.
|
|
in property <[float]> heights;
|
|
in property <color> ink;
|
|
/// Filled from the baseline rather than drawn as a line.
|
|
in property <bool> filled: false;
|
|
in property <float> shade: 1.0;
|
|
|
|
background: transparent;
|
|
|
|
for h[i] in root.heights: Rectangle {
|
|
// The next column's height, so a line segment can span the gap between
|
|
// them. Without this a steep edge draws as a dotted stair rather than a
|
|
// rising line — the same fix `CurveEditor` makes for the same reason.
|
|
property <float> next: i + 1 < root.heights.length
|
|
? root.heights[i + 1] : h;
|
|
|
|
x: parent.width * i / max(root.heights.length, 1);
|
|
width: parent.width / max(root.heights.length, 1) + 1px;
|
|
|
|
y: root.filled
|
|
? parent.height * (1.0 - h)
|
|
: parent.height * (1.0 - max(h, self.next));
|
|
height: root.filled
|
|
? parent.height * h
|
|
// A floor, so a flat stretch of the trace is still a line and not a
|
|
// gap in one.
|
|
: max(parent.height * abs(self.next - h), 1.5px);
|
|
|
|
background: root.ink;
|
|
opacity: root.shade;
|
|
}
|
|
}
|
|
|
|
// A clipping readout: a lit marker and a figure.
|
|
//
|
|
// **Two affordances for one fact, and NFR-A11Y-3 is why.** No status in this
|
|
// application is carried by hue alone, and clipping is the status most worth
|
|
// getting right — it is the one the histogram exists to warn about. So the
|
|
// marker appears and disappears (a shape, not a tint) and the figure states the
|
|
// magnitude in words. Either alone is enough to read it.
|
|
component ClipReadout inherits HorizontalLayout {
|
|
in property <string> title;
|
|
in property <string> figure;
|
|
in property <bool> lit;
|
|
|
|
spacing: Theme.gap-sm;
|
|
|
|
Rectangle {
|
|
width: 6px;
|
|
height: 6px;
|
|
y: (parent.height - self.height) / 2;
|
|
border-radius: 1px;
|
|
// `warn-ink`, the palette's one sanctioned hue: a blown highlight is a
|
|
// caution about the photograph, which is exactly the kind of thing that
|
|
// token exists to say instantly.
|
|
background: Theme.warn-ink;
|
|
visible: root.lit;
|
|
}
|
|
|
|
Caption { text: root.title; }
|
|
Caption { text: root.figure; warn: root.lit; }
|
|
}
|
|
|
|
// TRACES: FR-DSP-7
|
|
export component HistogramPanel inherits Rectangle {
|
|
in property <HistogramView> data;
|
|
|
|
background: transparent;
|
|
height: panel.preferred-height;
|
|
|
|
panel := Panel {
|
|
// Flat, like every panel in this column: the rule between it and its
|
|
// neighbour belongs to the column that stacks them.
|
|
flat: true;
|
|
width: 100%;
|
|
|
|
PanelHeading { text: "HISTOGRAM"; }
|
|
|
|
Rectangle {
|
|
// Tall enough to read a shape off and no taller. The column is the
|
|
// photographer's instrument panel, and every pixel this takes is a
|
|
// slider pushed below the fold.
|
|
height: 84px;
|
|
background: Theme.ground;
|
|
border-width: 1px;
|
|
border-color: Theme.rule;
|
|
// The traces are positioned by fraction of the plot and a value at
|
|
// the ceiling lands exactly on the border; clipping keeps the top
|
|
// row of pixels inside the box rather than over its edge.
|
|
clip: true;
|
|
|
|
// Quarter gridlines, so a tone can be placed on the axis without
|
|
// counting. Same construction and same weight as the tone curve's,
|
|
// because the two plots sit in one column and any difference
|
|
// between them would read as meaning something.
|
|
for i in [1, 2, 3]: Rectangle {
|
|
x: parent.width * i / 4;
|
|
width: 1px;
|
|
background: Theme.rule;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
// Luminance first, so it sits behind: it is the envelope the three
|
|
// channels decompose, and a filled area drawn over a line hides it.
|
|
Trace {
|
|
width: 100%;
|
|
height: 100%;
|
|
heights: root.data.luma;
|
|
ink: Theme.plot-luma;
|
|
filled: true;
|
|
}
|
|
|
|
// The channels over it, held back so three overlapping traces stay
|
|
// legible where they cross — which on a neutral subject is
|
|
// everywhere.
|
|
Trace {
|
|
width: 100%;
|
|
height: 100%;
|
|
heights: root.data.red;
|
|
ink: Theme.plot-red;
|
|
shade: 0.85;
|
|
}
|
|
Trace {
|
|
width: 100%;
|
|
height: 100%;
|
|
heights: root.data.green;
|
|
ink: Theme.plot-green;
|
|
shade: 0.85;
|
|
}
|
|
Trace {
|
|
width: 100%;
|
|
height: 100%;
|
|
heights: root.data.blue;
|
|
ink: Theme.plot-blue;
|
|
shade: 0.85;
|
|
}
|
|
|
|
// The clipping markers, drawn on the plot's own ends.
|
|
//
|
|
// Here as well as in the figures below because this is where the
|
|
// eye already is: a bar standing at the edge the tones are piling
|
|
// against says which end has gone in the same glance that reads the
|
|
// shape, and the figures underneath say how much.
|
|
Rectangle {
|
|
x: 0;
|
|
width: 2px;
|
|
height: 100%;
|
|
background: Theme.warn-ink;
|
|
visible: root.data.shadows-clipped;
|
|
}
|
|
Rectangle {
|
|
x: parent.width - self.width;
|
|
width: 2px;
|
|
height: 100%;
|
|
background: Theme.warn-ink;
|
|
visible: root.data.highlights-clipped;
|
|
}
|
|
|
|
// Nothing rendered yet. Said rather than shown as an empty plot,
|
|
// which would be a claim that the photograph has no tones in it.
|
|
Caption {
|
|
text: "no frame yet";
|
|
width: 100%;
|
|
horizontal-alignment: center;
|
|
y: (parent.height - self.height) / 2;
|
|
visible: !root.data.available;
|
|
}
|
|
}
|
|
|
|
// Always drawn, never hidden. With no frame counted the figures read
|
|
// "—", which is a different statement from "0%" and the true one: the
|
|
// panel does not yet know. Hiding the row instead would move the
|
|
// controls below it up and back down as each image opens.
|
|
HorizontalLayout {
|
|
ClipReadout {
|
|
title: "Shadows";
|
|
figure: root.data.shadows-label;
|
|
lit: root.data.shadows-clipped;
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
ClipReadout {
|
|
title: "Highlights";
|
|
figure: root.data.highlights-label;
|
|
lit: root.data.highlights-clipped;
|
|
}
|
|
}
|
|
}
|
|
}
|