NFR-A11Y-3 — no status conveyed by hue alone — read as untagged, and outstanding.md said "no compliance work found". Both were wrong. Five places already implement it, and four of them name the requirement in a comment explaining the design; what none of them had was a TRACES line. - `histogram.rs::percentage` states clipping as a figure and keeps `<0.1%` distinct from `0%`, so the text cannot say "none" while the marker beside it is lit. - `histogram.slint`'s ClipReadout is the other half: a marker that appears and disappears rather than changing tint, and the figure next to it. Either alone reads. - `library.slint`'s star strip is a solid star against an outline, differing in shape and luminance, over an achromatic palette. - `library.slint`'s FlagMark is a tick against a cross, and a reject also dims its whole cell. - `peaking.slint`'s colour chips say "Red" and "Cyan". A control for choosing between hues, presented only as hues, is unusable by exactly the person most likely to need it. The tag is honest about being wider than the evidence, and outstanding.md now records both gaps. Only the clipping clause has a test that would fail if the behaviour were removed; the three Slint components are argued rather than asserted. And the requirement's first named example — catalog colour labels — has no interface at all: `label` is a nullable column nothing writes or shows. That clause is untestable rather than satisfied, and closes when the label UI is built with a shape from the start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
683 lines
30 KiB
Rust
683 lines
30 KiB
Rust
//! TRACES: FR-DSP-7 | FR-CULL-3
|
|
//! Turning bin counts into something a 280-pixel column can be read from.
|
|
//!
|
|
//! **Two instruments through one panel.** `dr_gpu` produces two reductions
|
|
//! that answer different questions — [`dr_gpu::Histogram`] counts the frame the
|
|
//! display is about to show (FR-DSP-7), [`dr_gpu::RawHistogram`] counts the
|
|
//! sensor data the file actually holds (FR-CULL-3) — and both are drawn by the
|
|
//! same plot, from the same [`HistogramView`], with a chip row to choose
|
|
//! between them. That is why the axis caption, the two clipping titles and the
|
|
//! empty-state line are all fields of the view rather than literals in Slint:
|
|
//! the words differ between the two readings as much as the numbers do, and a
|
|
//! panel that drew the raw counts under headings saying Shadows and Highlights
|
|
//! would be mislabelling them precisely where it matters.
|
|
//!
|
|
//! `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, RawHistogram, HISTOGRAM_BINS, RAW_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 words the display reading is drawn under.
|
|
///
|
|
/// Here rather than in the panel because the raw reading beside it uses
|
|
/// different ones for the same two counters, and a heading that stayed put
|
|
/// while the numbers under it changed meaning would be worse than no heading.
|
|
const DISPLAY_LOW: &str = "Shadows";
|
|
const DISPLAY_HIGH: &str = "Highlights";
|
|
/// What the plot's horizontal axis is, said once in the chip row's hint.
|
|
///
|
|
/// Short on purpose, and this is a layout constraint rather than a stylistic
|
|
/// one: the hint is an unwrapped `Text` in a `FieldRow`, so its natural width
|
|
/// is this panel's preferred width, and the develop column takes the largest
|
|
/// preferred width of any panel in it. A sentence here would hold the whole
|
|
/// sidebar open.
|
|
const DISPLAY_AXIS: &str = "output levels, 0 to 255";
|
|
/// What the plot says when nothing has been counted.
|
|
const NO_FRAME: &str = "no frame yet";
|
|
/// What a figure reads before there is a frame behind it.
|
|
const UNKNOWN: &str = "—";
|
|
|
|
/// The two reductions must agree on their bin count.
|
|
///
|
|
/// [`scaled`] folds either of them, and the fold is only free of a comb
|
|
/// because [`COLUMNS`] divides the bin count exactly. Asserted at compile time
|
|
/// rather than tested, so a change in `dr_gpu` stops the build here with this
|
|
/// sentence attached instead of drawing a plot with structure the photograph
|
|
/// does not have.
|
|
const _: () = assert!(RAW_HISTOGRAM_BINS == HISTOGRAM_BINS);
|
|
|
|
/// The same three, for the raw reading.
|
|
const RAW_LOW: &str = "Black";
|
|
const RAW_HIGH: &str = "Saturated";
|
|
const RAW_AXIS: &str = "stops below saturation";
|
|
|
|
/// The whole panel's state, for one counted display 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,
|
|
overall: 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(),
|
|
low_title: DISPLAY_LOW.into(),
|
|
high_title: DISPLAY_HIGH.into(),
|
|
hint: DISPLAY_AXIS.into(),
|
|
unavailable: NO_FRAME.into(),
|
|
}
|
|
}
|
|
|
|
/// An empty display panel — no image open, or a frame that could not be
|
|
/// counted.
|
|
pub(crate) fn empty() -> HistogramView {
|
|
HistogramView {
|
|
available: false,
|
|
overall: 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(),
|
|
low_title: DISPLAY_LOW.into(),
|
|
high_title: DISPLAY_HIGH.into(),
|
|
hint: DISPLAY_AXIS.into(),
|
|
unavailable: NO_FRAME.into(),
|
|
}
|
|
}
|
|
|
|
/// The same panel, for one counted sensor frame.
|
|
///
|
|
/// Untagged, deliberately. Every judgement it makes — the fold, the scale, the
|
|
/// clipping threshold, the headroom figure, the words — belongs to a function
|
|
/// beside it that is tested without a device; this is the assembly, and a tag
|
|
/// here would claim coverage that the assertions are not making. See
|
|
/// `CONTRIBUTING.md` on closing a requirement with a test that would fail if
|
|
/// the behaviour were removed.
|
|
///
|
|
/// **Every number here is folded and scaled by the code the display reading
|
|
/// uses**, and that is deliberate rather than convenient: the two plots are
|
|
/// drawn in the same box, in the same column, one chip apart, and a difference
|
|
/// in how they are folded or what they are scaled against would read as a
|
|
/// difference in the photograph. What changes between them is the axis and the
|
|
/// words for it, and nothing else.
|
|
///
|
|
/// The shared fold is only sound because the two reductions have the same bin
|
|
/// count. That is a compile-time fact rather than a hope — `scaled` takes
|
|
/// `&[u32; HISTOGRAM_BINS]` and is handed `&[u32; RAW_HISTOGRAM_BINS]`, so if
|
|
/// `dr_gpu` ever changed one of them this file would stop compiling rather
|
|
/// than start drawing a comb.
|
|
pub(crate) fn raw_view(hist: &RawHistogram) -> HistogramView {
|
|
HistogramView {
|
|
available: hist.pixels() > 0,
|
|
// The brightest channel where the display reading draws luma — see
|
|
// `RawHistogram::brightest` for why a weighted sum of camera-native
|
|
// values would be a number about nothing.
|
|
overall: model(&scaled(hist.brightest())),
|
|
red: model(&scaled(hist.red())),
|
|
green: model(&scaled(hist.green())),
|
|
blue: model(&scaled(hist.blue())),
|
|
highlights_clipped: fraction(hist.saturated(), hist.pixels()) >= CLIP_VISIBLE,
|
|
shadows_clipped: fraction(hist.at_black(), hist.pixels()) >= CLIP_VISIBLE,
|
|
highlights_label: percentage(hist.saturated(), hist.pixels()).into(),
|
|
shadows_label: percentage(hist.at_black(), hist.pixels()).into(),
|
|
low_title: RAW_LOW.into(),
|
|
high_title: RAW_HIGH.into(),
|
|
// The headroom figure rather than the axis, when there is one. It is
|
|
// the answer FR-CULL-3 is written for — "the embedded JPEG's histogram
|
|
// misrepresents available highlight headroom" — and a photographer
|
|
// culling a folder wants the number, not a reminder of what the axis
|
|
// is.
|
|
hint: headroom_label(hist.brightest(), hist.pixels()).into(),
|
|
unavailable: NO_FRAME.into(),
|
|
}
|
|
}
|
|
|
|
/// Why there is no raw reading to draw.
|
|
///
|
|
/// Three genuinely different answers, and the panel must not flatten them into
|
|
/// one. "Nothing is open" is a state that passes; "this file has no sensor
|
|
/// data" is permanent for this photograph and says the instrument does not
|
|
/// apply; "this device could not build the reduction" says it applies and is
|
|
/// missing, which is the one worth reporting as a fault.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) enum RawAbsence {
|
|
/// No photograph open, or none rendered yet.
|
|
NoImage,
|
|
/// The file was never raw — a JPEG, or anything else already rendered.
|
|
NotRaw,
|
|
/// The device could not compile or run the reduction.
|
|
NoDevice,
|
|
}
|
|
|
|
impl RawAbsence {
|
|
/// What the empty plot says. Kept to a few words for the width reason
|
|
/// [`DISPLAY_AXIS`] gives.
|
|
fn note(self) -> &'static str {
|
|
match self {
|
|
RawAbsence::NoImage => NO_FRAME,
|
|
RawAbsence::NotRaw => "no sensor data",
|
|
RawAbsence::NoDevice => "no reduction here",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
/// An empty raw panel, saying which of the three reasons applies.
|
|
pub(crate) fn raw_empty(why: RawAbsence) -> HistogramView {
|
|
HistogramView {
|
|
available: false,
|
|
overall: 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,
|
|
highlights_label: UNKNOWN.into(),
|
|
shadows_label: UNKNOWN.into(),
|
|
low_title: RAW_LOW.into(),
|
|
high_title: RAW_HIGH.into(),
|
|
hint: RAW_AXIS.into(),
|
|
unavailable: why.note().into(),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
/// How far the brightest real content sits below sensor saturation, in stops.
|
|
///
|
|
/// Takes the counts rather than the [`RawHistogram`] they came out of, on the
|
|
/// same principle as everything else in this file: a `RawHistogram` can only
|
|
/// be produced by a device, and every judgement here is one that shows up as a
|
|
/// wrong number rather than as a crash.
|
|
///
|
|
/// **The top [`CLIP_VISIBLE`] of the frame is skipped**, for the same reason
|
|
/// the clipping indicator has a threshold at all: almost every photograph has
|
|
/// a few pixels at an extreme — a specular glint on chrome, a hot pixel, the
|
|
/// sun itself — and a headroom figure that read 0.0 on all of them would be a
|
|
/// figure nobody looks at twice. What is wanted is the highlight a
|
|
/// photographer is protecting, not the brightest pixel in the file.
|
|
///
|
|
/// Measured on the brightest channel, because that is the one that saturates
|
|
/// first and so the one that decides whether a pixel survives.
|
|
///
|
|
/// `None` where nothing has been counted — distinct from zero, which is a
|
|
/// photograph with no headroom left at all.
|
|
pub(crate) fn headroom(brightest: &[u32; RAW_HISTOGRAM_BINS], pixels: u32) -> Option<f32> {
|
|
if pixels == 0 {
|
|
return None;
|
|
}
|
|
// `max(1)` so the same count that lights the clipping indicator also moves
|
|
// this figure: without it a frame sitting exactly on the threshold would
|
|
// read "clipped" beside "three stops of headroom", and a panel that
|
|
// contradicts itself in two adjacent readouts is one nobody trusts again.
|
|
// It also keeps a small frame — a proxy, a thumbnail — from ignoring
|
|
// everything, since a thousandth of it rounds to none.
|
|
let ignore = ((pixels as f32 * CLIP_VISIBLE) as u32).max(1);
|
|
let mut above = 0u32;
|
|
for (bin, count) in brightest.iter().enumerate().rev() {
|
|
above = above.saturating_add(*count);
|
|
if above >= ignore {
|
|
return Some(RawHistogram::stops(bin));
|
|
}
|
|
}
|
|
// Unreachable while `pixels` is non-zero — the counts sum to it, and
|
|
// `ignore` is a thousandth of it — but the floor of the axis is the honest
|
|
// answer to a frame with nothing in any bin, and a panic is not.
|
|
Some(RawHistogram::stops(0))
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
/// The headroom figure as the chip row's hint reads it.
|
|
pub(crate) fn headroom_label(brightest: &[u32; RAW_HISTOGRAM_BINS], pixels: u32) -> String {
|
|
match headroom(brightest, pixels) {
|
|
// Nothing counted: say what the axis is, since there is no figure to
|
|
// put on it.
|
|
None => RAW_AXIS.into(),
|
|
// The end of the scale is said in words, not as `0.0`. They are the
|
|
// same fact and not the same sentence: a measurement that came out
|
|
// small reads differently from one that ran out, and on a culling pass
|
|
// that is the difference between "tight" and "gone". Only the top bin
|
|
// produces it — the axis is divided finely enough that the next one
|
|
// down is already a tenth of a stop.
|
|
Some(stops) if stops <= 0.0 => "no headroom left".into(),
|
|
// Rounded to a tenth, which is finer than any decision made from it
|
|
// and coarse enough not to flicker between two frames of one scene.
|
|
Some(stops) => format!("{stops:.1} stops of headroom"),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/// TRACES: NFR-A11Y-3
|
|
/// 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.
|
|
/// `a_clipping_figure_distinguishes_none_from_nearly_none` is the test that
|
|
/// would fail if this became a colour again.
|
|
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);
|
|
}
|
|
|
|
/// A brightest-channel series with `count` pixels this many bins below
|
|
/// saturation, and nothing else.
|
|
fn brightest(entries: &[(usize, u32)]) -> [u32; RAW_HISTOGRAM_BINS] {
|
|
let mut out = [0u32; RAW_HISTOGRAM_BINS];
|
|
for (below, count) in entries {
|
|
out[RAW_HISTOGRAM_BINS - 1 - *below] = *count;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
#[test]
|
|
fn headroom_is_the_distance_from_the_brightest_content_to_saturation() {
|
|
// The figure FR-CULL-3 is written for, in the units it is written in.
|
|
// A frame whose highest content sits two stops down has two stops of
|
|
// latitude, and one whose content reaches the white level has none —
|
|
// and the difference between those two answers is the whole of whether
|
|
// a photograph is worth keeping.
|
|
let per_stop = dr_gpu::RAW_HISTOGRAM_BINS_PER_STOP;
|
|
|
|
let two_down = headroom(&brightest(&[(2 * per_stop, 10_000)]), 10_000);
|
|
assert_eq!(two_down, Some(2.0));
|
|
|
|
let at_the_top = headroom(&brightest(&[(0, 10_000)]), 10_000);
|
|
assert_eq!(at_the_top, Some(0.0));
|
|
|
|
// Nothing counted is not the same statement as no headroom, and the
|
|
// panel must not turn the first into the second.
|
|
assert_eq!(headroom(&[0u32; RAW_HISTOGRAM_BINS], 0), None);
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
#[test]
|
|
fn headroom_ignores_a_handful_of_specular_pixels() {
|
|
// **The reason the figure is a percentile and not a maximum.** Almost
|
|
// every photograph has a few pixels at the ceiling — a glint on
|
|
// chrome, a hot pixel, the sun in the corner — and a headroom readout
|
|
// that reported 0.0 on all of them would be a readout nobody looks at
|
|
// twice. The threshold is the same one the clipping indicator uses,
|
|
// for the same reason.
|
|
let per_stop = dr_gpu::RAW_HISTOGRAM_BINS_PER_STOP;
|
|
let pixels = 1_000_000u32;
|
|
let threshold = (CLIP_VISIBLE * pixels as f32) as u32;
|
|
let glint = threshold - 1;
|
|
|
|
let with_glint = brightest(&[(0, glint), (3 * per_stop, pixels - glint)]);
|
|
assert_eq!(
|
|
headroom(&with_glint, pixels),
|
|
Some(3.0),
|
|
"a specular glint took three stops of headroom off the reading"
|
|
);
|
|
|
|
// At the threshold it is no longer a glint, and the figure has to
|
|
// follow the data — this is the same count that lights the clipping
|
|
// indicator, and the two readouts sit an inch apart.
|
|
let real = brightest(&[(0, threshold), (3 * per_stop, pixels - threshold)]);
|
|
assert_eq!(headroom(&real, pixels), Some(0.0));
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
#[test]
|
|
fn the_headroom_hint_says_none_rather_than_rounding_to_zero() {
|
|
// `0.0 stops of headroom` and `no headroom left` are the same fact and
|
|
// not the same sentence: the first reads as a measurement that came
|
|
// out small, the second as the end of the scale. On a culling pass
|
|
// this is the difference between "tight" and "gone".
|
|
let per_stop = dr_gpu::RAW_HISTOGRAM_BINS_PER_STOP;
|
|
assert_eq!(
|
|
headroom_label(&brightest(&[(2 * per_stop, 100)]), 100),
|
|
"2.0 stops of headroom"
|
|
);
|
|
assert_eq!(
|
|
headroom_label(&brightest(&[(0, 100)]), 100),
|
|
"no headroom left"
|
|
);
|
|
// One bin down is a sixteenth of a stop, which is a real if small
|
|
// amount of latitude and must not be rounded away into the sentence
|
|
// above it.
|
|
assert_eq!(
|
|
headroom_label(&brightest(&[(1, 100)]), 100),
|
|
"0.1 stops of headroom"
|
|
);
|
|
// And with nothing counted the hint falls back to naming the axis,
|
|
// rather than stating a figure about a photograph nobody has read.
|
|
assert_eq!(headroom_label(&[0u32; RAW_HISTOGRAM_BINS], 0), RAW_AXIS);
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
#[test]
|
|
fn an_absent_raw_reading_says_which_kind_of_absent_it_is() {
|
|
// Three different facts, and flattening them loses the one that
|
|
// matters. "No sensor data" is permanent for this photograph and means
|
|
// the instrument does not apply; "no reduction here" means it applies
|
|
// and this device could not build it, which is a fault worth
|
|
// reporting; "no frame yet" passes on its own. A single "unavailable"
|
|
// would have a photographer looking for a driver problem on a JPEG.
|
|
let notes: Vec<String> = [
|
|
RawAbsence::NoImage,
|
|
RawAbsence::NotRaw,
|
|
RawAbsence::NoDevice,
|
|
]
|
|
.iter()
|
|
.map(|why| raw_empty(*why).unavailable.to_string())
|
|
.collect();
|
|
|
|
for note in ¬es {
|
|
assert!(!note.is_empty(), "an empty raw panel said nothing at all");
|
|
assert_eq!(
|
|
notes.iter().filter(|n| *n == note).count(),
|
|
1,
|
|
"two reasons read identically: {note}"
|
|
);
|
|
}
|
|
|
|
// And an empty panel never states a clipping figure — the failure the
|
|
// display panel's own test describes, in the instrument where it would
|
|
// be worse, because a raw clipping claim is a claim about the file.
|
|
let blank = raw_empty(RawAbsence::NotRaw);
|
|
assert!(!blank.available);
|
|
assert_eq!(blank.highlights_label, UNKNOWN);
|
|
assert_eq!(blank.shadows_label, UNKNOWN);
|
|
assert!(!blank.highlights_clipped && !blank.shadows_clipped);
|
|
}
|
|
|
|
/// TRACES: FR-CULL-3
|
|
#[test]
|
|
fn the_two_readings_are_labelled_apart() {
|
|
// The panel draws both in the same box, one chip apart. If they shared
|
|
// their headings, a photographer who had forgotten which was selected
|
|
// would read a raw saturation figure as a display clipping figure —
|
|
// and those disagree by exactly the amount FR-CULL-3 exists to expose.
|
|
let display = empty();
|
|
let raw = raw_empty(RawAbsence::NoImage);
|
|
assert_ne!(display.low_title, raw.low_title);
|
|
assert_ne!(display.high_title, raw.high_title);
|
|
assert_ne!(display.hint, raw.hint);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|