//! TRACES: FR-DSP-7 //! Turning bin counts into something a 280-pixel column can be read from. //! //! `dr_gpu` counts; this decides what the counting looks like. The split is //! ARCH §4.3a's: how many columns a panel can show, which peak to scale //! against and when a handful of specular pixels is worth an alarm are all //! questions about *this* interface, and none of them belong beside the shader. //! //! Free-standing functions over plain numbers, deliberately. Every judgement //! here is one that shows up as a wrong picture rather than as a crash — a //! histogram flattened by a black-point spike, a clipping figure that reads //! 0.0% when a quarter of the sky is gone — and none of them can be checked by //! looking at a running application, because a plausible wrong shape and the //! right shape look equally plausible. So they are all reachable without a GPU, //! a window or a photograph. use dr_gpu::{Histogram, HISTOGRAM_BINS}; use crate::HistogramView; /// Columns the plot draws. /// /// **A divisor of [`HISTOGRAM_BINS`], and that is not a detail.** 96 columns /// over 256 bins folds two levels into some columns and three into others, so a /// perfectly even ramp is drawn as a comb — structure the photograph does not /// have. Four levels per column, always. /// /// 64 rather than 128 because the plot is about 256px wide in the develop /// column: four pixels per column is a bar that can be seen, where two is a /// hairline. It also halves the repeater, and this panel is instantiated for /// the life of the window rather than built when it is looked at. pub(crate) const COLUMNS: usize = 64; /// Fraction of the frame that must clip before the indicator lights. /// /// Not zero. Almost every photograph has a few pixels at an extreme — a /// specular glint on chrome, a sensor hot pixel, the dark corner of a vignette /// — and an indicator that fires on all of them is one a photographer stops /// reading within a day. A thousandth of the frame is roughly where clipping /// stops being an artefact and starts being a decision. const CLIP_VISIBLE: f32 = 0.001; /// The whole panel's state, for one counted frame. pub(crate) fn view(hist: &Histogram) -> HistogramView { HistogramView { // Zero pixels means nothing has been rendered yet, not an empty // photograph — the panel draws its frame and no data rather than a flat // line, which would be a claim about an image that does not exist. available: hist.pixels() > 0, luma: model(&scaled(hist.luma())), red: model(&scaled(hist.red())), green: model(&scaled(hist.green())), blue: model(&scaled(hist.blue())), highlights_clipped: fraction(hist.clipped_highlights(), hist.pixels()) >= CLIP_VISIBLE, shadows_clipped: fraction(hist.clipped_shadows(), hist.pixels()) >= CLIP_VISIBLE, highlights_label: percentage(hist.clipped_highlights(), hist.pixels()).into(), shadows_label: percentage(hist.clipped_shadows(), hist.pixels()).into(), } } /// An empty panel — no image open, or a frame that could not be counted. pub(crate) fn empty() -> HistogramView { HistogramView { available: false, luma: model(&[0.0; COLUMNS]), red: model(&[0.0; COLUMNS]), green: model(&[0.0; COLUMNS]), blue: model(&[0.0; COLUMNS]), highlights_clipped: false, shadows_clipped: false, // Not "0%". Nothing has been counted, and claiming no clipping about a // frame that does not exist is the one thing an instrument must not do. highlights_label: UNKNOWN.into(), shadows_label: UNKNOWN.into(), } } /// What a figure reads before there is a frame behind it. const UNKNOWN: &str = "—"; fn model(heights: &[f32; COLUMNS]) -> slint::ModelRc { 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::(), 5, "level {level} was counted in more than one column" ); } } #[test] fn a_clipped_spike_does_not_flatten_everything_else() { // The reason `peak` ignores the ends. A frame that is nine-tenths pure // black — a studio shot on a black backdrop, or any night scene — must // still show the tones the photographer is working on. Scaled against // the black spike they would be a hundredth of the plot's height, which // is a flat line. let mut levels = vec![(0usize, 900_000u32)]; levels.push((128, 1000)); levels.push((129, 500)); let heights = scaled(&bins(&levels)); let mid = heights[128 / (HISTOGRAM_BINS / COLUMNS)]; assert!( mid > 0.9, "the tallest interior column should reach the top, got {mid}" ); // And the spike is still drawn, at the ceiling rather than off it. assert_eq!(heights[0], 1.0); } #[test] fn an_entirely_black_frame_still_draws_its_spike() { // The fallback in `peak`. With every pixel at level 0 the interior is // empty, and dividing by that peak would either panic or produce a // plot with nothing in it — which says "no data" about a frame that has // a great deal of data, all of it bad news. let heights = scaled(&bins(&[(0, 4096)])); assert_eq!(heights[0], 1.0); assert!(heights[1..].iter().all(|h| *h == 0.0)); } #[test] fn an_empty_frame_scales_to_nothing_rather_than_dividing_by_zero() { let heights = scaled(&[0u32; HISTOGRAM_BINS]); assert!(heights.iter().all(|h| *h == 0.0)); } #[test] fn heights_never_leave_the_plot() { // Everything downstream multiplies these by the plot's height, so a // value above 1 paints outside the box and one below 0 paints upward // out of the panel. Checked across the shapes most likely to break it: // all the weight at one end, and all of it in one interior column. for levels in [ vec![(0usize, 100u32)], vec![(255, 100)], vec![(0, 100), (255, 100)], vec![(64, 100)], vec![(0, 1_000_000), (64, 1)], ] { for h in scaled(&bins(&levels)) { assert!((0.0..=1.0).contains(&h), "height {h} left the plot"); } } } #[test] fn folding_cannot_overflow_on_a_large_frame() { // `u32` counts summed two at a time. A saturating add rather than a // wrapping one, because the wrap would land near zero — a histogram // that emptied itself at the exact moment the image became flat. let folded = fold(&[u32::MAX; HISTOGRAM_BINS]); assert_eq!(folded[0], u32::MAX); } #[test] fn a_clipping_figure_distinguishes_none_from_nearly_none() { // `0.0%` and `<0.1%` are different answers, and the second is the one // that appears beside a lit marker. Rounding it to the first would have // the panel contradict itself: an indicator saying "clipped" over a // figure saying "none". assert_eq!(percentage(0, 1000), "0%"); assert_eq!(percentage(1, 1_000_000), "<0.1%"); assert_eq!(percentage(12, 1000), "1.2%"); assert_eq!(percentage(1000, 1000), "100.0%"); // No frame yet: not a claim that nothing is clipped so much as nothing // to claim, and "0%" is the honest reading of zero pixels counted. assert_eq!(percentage(0, 0), "0%"); } #[test] fn an_uncounted_frame_says_it_does_not_know_rather_than_says_zero() { // The panel is emptied when an image is closed and again when one fails // to render, and it stays empty until the first settled frame. Reading // "Highlights 0%" through that gap is a positive claim about a // photograph nobody has counted — the failure mode of an instrument // that is worse than no instrument. let blank = empty(); assert!(!blank.available); assert_eq!(blank.highlights_label, UNKNOWN); assert_eq!(blank.shadows_label, UNKNOWN); assert!(!blank.highlights_clipped && !blank.shadows_clipped); } #[test] fn the_indicator_ignores_a_handful_of_specular_pixels() { // The threshold, from both sides. Below it the marker stays dark while // the figure still reports what it found — an indicator that fired on // every glint would be one nobody reads, and one that hid the number // would be one nobody could check. let just_under = (CLIP_VISIBLE * 1_000_000.0) as u32 - 1; assert!(fraction(just_under, 1_000_000) < CLIP_VISIBLE); assert!(fraction(just_under + 1, 1_000_000) >= CLIP_VISIBLE); } }