//! 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 { 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 { 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::(), 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 = [ 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); } }