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.
349 lines
15 KiB
Plaintext
349 lines
15 KiB
Plaintext
// TRACES: FR-DSP-7
|
|
// The live histogram, and what it says about clipping.
|
|
//
|
|
// **One plot, two readings, and a chip row to choose.** The panel draws either
|
|
// the frame the display is about to show (FR-DSP-7) or the sensor data the
|
|
// file holds (FR-CULL-3), from the same `HistogramView` and through the same
|
|
// traces. They are not two presentations of one measurement: the first is
|
|
// after white balance, the camera matrix and the whole tone chain, the second
|
|
// is before any of it on an axis of stops below sensor saturation, and
|
|
// FR-CULL-3 exists precisely because the first "systematically lies about what
|
|
// is recoverable in the raw". A culling decision needs the second; an export
|
|
// decision needs the first. Both are true.
|
|
//
|
|
// **So the words travel with the numbers.** The axis caption, the two clipping
|
|
// titles and the empty-state line are fields of the view rather than literals
|
|
// here, because they differ between the two readings — a raw saturation figure
|
|
// drawn under a heading saying Highlights would be mislabelled exactly where
|
|
// the difference matters.
|
|
//
|
|
// **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";
|
|
import { Segmented } from "controls.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,
|
|
|
|
// The aggregate trace, drawn filled behind the three channels: Rec.709
|
|
// luma for the display reading, the brightest channel for the raw one. In
|
|
// both cases the single curve exposure is read off — which is why it is
|
|
// named for its role in the plot rather than for either quantity.
|
|
overall: [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,
|
|
|
|
// What the two clipping readouts are called. Different words for the two
|
|
// readings — Shadows and Highlights describe a rendering, Black and
|
|
// Saturated describe a sensor — and they belong to the reading rather than
|
|
// to the panel for that reason.
|
|
low-title: string,
|
|
high-title: string,
|
|
|
|
// What the axis is, or what it currently says: the chip row's hint. For
|
|
// the raw reading this is the headroom figure, which is the answer
|
|
// FR-CULL-3 is written for.
|
|
//
|
|
// Kept short in Rust, and that is a layout constraint: `FieldRow` draws
|
|
// the hint as an unwrapped Text, so its natural width becomes this panel's
|
|
// preferred width and the develop column takes the largest of those.
|
|
hint: string,
|
|
|
|
// What the plot says when there is nothing to draw. Never "0%" and never
|
|
// an empty plot — both are claims about a photograph nobody has counted.
|
|
unavailable: 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 {
|
|
/// The frame the display is about to show (FR-DSP-7).
|
|
in property <HistogramView> data;
|
|
/// The sensor data the file holds (FR-CULL-3).
|
|
in property <HistogramView> raw-data;
|
|
|
|
/// Which of the two is on the plot. 0 is the display reading.
|
|
///
|
|
/// **Held here rather than pushed from Rust**, and for the reason
|
|
/// `chosen-peaking` gives about the focus overlay: this is a way of
|
|
/// *looking* rather than a property of a photograph. Someone culling three
|
|
/// thousand frames switches to the raw reading once, and a mode that reset
|
|
/// with each image would ask them to switch it three thousand times. Both
|
|
/// views are pushed on every settled frame, so the choice needs no
|
|
/// callback and no round trip — the raw one costs nothing to keep current,
|
|
/// because it is computed once per photograph and cached.
|
|
property <int> mode: 0;
|
|
|
|
/// The reading currently drawn. Everything below reads this and not the
|
|
/// two above it, so there is exactly one place the choice is made.
|
|
property <HistogramView> shown: root.mode == 1 ? root.raw-data : root.data;
|
|
|
|
|
|
/// TRACES: FR-UI-2
|
|
/// How wide this panel has to be before it starts clipping itself.
|
|
///
|
|
/// Every panel in the develop column declares one, and the column takes
|
|
/// the largest — that is the whole of how the column is sized. It replaced
|
|
/// two guessed constants (280px for a tablet, 380px for a desktop) that
|
|
/// could not track a panel gaining a control, and did not.
|
|
///
|
|
/// Published as `min-width` as well as read by name: the first is what
|
|
/// makes the enclosing layout aggregate these automatically, including for
|
|
/// the panels that come and go with the mode and so cannot be referenced
|
|
/// from outside their `if`.
|
|
out property <length> content-width: panel.preferred-width;
|
|
min-width: root.content-width;
|
|
|
|
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"; }
|
|
|
|
// **Above the plot rather than below it**, because it says what the
|
|
// plot *is*: a photographer glancing at a shape has to know which of
|
|
// the two measurements they are looking at before they read it, not
|
|
// after. Two chips wide, which fits the narrowest column the
|
|
// application supports without the row setting the sidebar's width —
|
|
// see `ChipGrid`.
|
|
Segmented {
|
|
label: "Measured on";
|
|
hint: root.shown.hint;
|
|
options: ["Display", "Raw"];
|
|
selected: root.mode;
|
|
columns: 2;
|
|
picked(i) => { root.mode = i; }
|
|
}
|
|
|
|
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 value 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.
|
|
//
|
|
// They read as quarters of whichever axis is showing: 64 output
|
|
// levels for the display reading, four stops for the raw one. That
|
|
// is why the axis is named in the chip row's hint rather than
|
|
// labelled here — one set of gridlines cannot carry two scales,
|
|
// and drawing numbers against them would make one of the two
|
|
// readings wrong.
|
|
for i in [1, 2, 3]: Rectangle {
|
|
x: parent.width * i / 4;
|
|
width: 1px;
|
|
background: Theme.rule;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
// The aggregate first, so it sits behind: Rec.709 luma for the
|
|
// display reading, the brightest channel for the raw one, and in
|
|
// both cases the curve the three channels sit under. A filled area
|
|
// drawn over a line hides it.
|
|
Trace {
|
|
width: 100%;
|
|
height: 100%;
|
|
heights: root.shown.overall;
|
|
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.shown.red;
|
|
ink: Theme.plot-red;
|
|
shade: 0.85;
|
|
}
|
|
Trace {
|
|
width: 100%;
|
|
height: 100%;
|
|
heights: root.shown.green;
|
|
ink: Theme.plot-green;
|
|
shade: 0.85;
|
|
}
|
|
Trace {
|
|
width: 100%;
|
|
height: 100%;
|
|
heights: root.shown.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.shown.shadows-clipped;
|
|
}
|
|
Rectangle {
|
|
x: parent.width - self.width;
|
|
width: 2px;
|
|
height: 100%;
|
|
background: Theme.warn-ink;
|
|
visible: root.shown.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: root.shown.unavailable;
|
|
width: 100%;
|
|
horizontal-alignment: center;
|
|
y: (parent.height - self.height) / 2;
|
|
visible: !root.shown.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: root.shown.low-title;
|
|
figure: root.shown.shadows-label;
|
|
lit: root.shown.shadows-clipped;
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
ClipReadout {
|
|
title: root.shown.high-title;
|
|
figure: root.shown.highlights-label;
|
|
lit: root.shown.highlights-clipped;
|
|
}
|
|
}
|
|
}
|
|
}
|