The author could not run cargo, so this is the formatter's first pass over the new module and its presentation half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
680 lines
30 KiB
Rust
680 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
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|