From eeec93ddfce3c3352263d0be034508aabb801368 Mon Sep 17 00:00:00 2001 From: Schluffe Date: Sun, 27 Sep 2026 23:36:15 +0200 Subject: [PATCH] Add saturation, value, brightness and color blind views --- README.md | 60 +++++++-- src/analysis/mod.rs | 71 +++++++++- src/analysis/vision.rs | 296 +++++++++++++++++++++++++++++++++++++++++ src/app.rs | 239 +++++++++++++++++++++++++++++---- src/ui/mod.rs | 182 ++++++++++++++++++++++--- 5 files changed, 793 insertions(+), 55 deletions(-) create mode 100644 src/analysis/vision.rs diff --git a/README.md b/README.md index e0e5bff..544333e 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,52 @@ cargo run --release -- /path/to/screenshot.png 5. **Save crop PNG** writes a uniquely named PNG in the system temporary directory and displays its path. Drop an image or click **Open image…** to browse for a file in KDE's file picker. -The HSV histogram has 36 ten-degree bins. Switch between pixel counts and equal -weight per distinct RGB color. Colors with saturation below 5% are counted -separately as neutrals. The palette is exact (no quantization), ordered by count, -then RGB for deterministic ties. Fully transparent pixels are excluded; partially -transparent pixels retain their stored RGB values and count once. +## Color analysis and vision checks + +The **Color analysis** tab has four distributions: + +- **Hue**: HSV hue in 36 ten-degree bins; saturation below 5% counts as neutral. +- **Saturation**: HSV saturation, from neutral to fully saturated. +- **Brightness (V)**: HSV value, the maximum RGB channel. This is not perceived + lightness: fully saturated red and green both have 100% value. +- **Luminance**: relative luminance in linear sRGB, weighted for contrast. + +Saturation, value, and luminance use 20 five-percentage-point bins, including +100% in the final bin. Each has a mean. Switch between pixel frequency and equal +weight per distinct RGB color; hover bars for counts and percentages. The exact +palette is ordered by count, then RGB for deterministic ties. Fully transparent +pixels are excluded; partially transparent pixels retain their stored RGB values +and count once. Pixel inspection includes HSV and relative luminance. + +Use **Compare vision** below the selection to show the original crop alongside +protanopia, deuteranopia, tritanopia, or grayscale. The first three use the +Machado–Oliveira–Fernandes full-severity model, applied in linear RGB and then +encoded back to sRGB. Grayscale uses relative luminance and tests removal of color +cues; it does not model all aspects of achromatopsia. Preview mode does not change +histograms, copied pixel values, or the exported PNG: those always use the original. + +**Vision check** automatically checks all four scenarios. It flags pairs whose +OKLab distance falls from at least 0.08 to at most 0.04, losing at least 50% of the +original separation. These are project-specific screening heuristics, not +validated visibility thresholds or WCAG criteria. Each scenario shows original +and simulated swatches, numeric hex values, and up to six closest candidate pairs. +The preview button opens the corresponding simulation for the current crop. + +The check is bounded to the 64 most frequent **opaque** colors and reports both +color and pixel coverage. Partially/fully transparent pixels have no known +backdrop and are excluded from this check. No findings does **not** establish +accessibility. The check cannot infer adjacency, text/background relationships, +or the gameplay meaning of a color. Review important cues in context and combine +color with shapes, labels, or differences in lightness. Select a smaller region +when an important detail is outside the reported palette coverage. + +References: + +- [Chrome's contrast audits and vision simulations](https://developer.chrome.com/docs/chromium/cvd) +- [Machado, Oliveira & Fernandes simulation model](https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html) +- [Published matrix coefficients in QGIS](https://api.qgis.org/api/qgsprevieweffect_8cpp_source.html) +- [OKLab color space](https://bottosson.github.io/posts/oklab/) +- [W3C: use of color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) Choosing a source always opens KDE's picker. The last portal token is saved at `$XDG_CONFIG_HOME/whoshue/config.toml` (normally `~/.config/whoshue/config.toml`) @@ -55,7 +96,7 @@ cannot strand the cached D-Bus connection on a stopped executor. many unique colors are more expensive; release builds are recommended. - Game-specific behavior on focus loss/minimization needs desktop testing. - Perceptual OKLCH views, color/hue highlighting, and comparing saved selections - are future features; this first version uses HSV. + are future features; the distributions currently use HSV and relative luminance. ## Validation @@ -67,10 +108,13 @@ cargo fmt --check Tests cover hue boundaries, neutral/transparent pixel handling, weighting, cropping, selection coordinates, pixel-format conversion, frozen-frame behavior, -resize handling, headless UI rendering, file-picker cancellation/reopening, and +resize handling, all histogram/vision UI views, file-picker cancellation/reopening, and repeated portal requests during/after capture. The portal regression test requires `dbus-run-session` and permission to create a local socket; it uses its own private -bus and does not open desktop dialogs. Actual portal capture requires a running +bus and does not open desktop dialogs. Color tests cover histogram endpoints and +weighting, sRGB gamma, simulation reference colors, alpha handling, warning +positives/negatives, palette coverage, and preserving source pixels. +Actual portal capture requires a running Wayland desktop and permission through KDE's picker. For a capture-only diagnostic (saves the first full source frame): diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index 0dcc587..bb157e6 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,5 +1,24 @@ //! Analysis always uses source pixels, never the scaled preview. use std::collections::HashMap; +pub mod vision; +pub const LEVEL_BINS: usize = 20; + +#[derive(Default)] +pub struct Distribution { + pub pixels: [u64; LEVEL_BINS], + pub colors: [u64; LEVEL_BINS], + pub pixel_sum: f64, + pub color_sum: f64, +} +impl Distribution { + fn add(&mut self, value: f64, count: u64) { + let bin = ((value.clamp(0.0, 1.0) * LEVEL_BINS as f64) as usize).min(LEVEL_BINS - 1); + self.pixels[bin] += count; + self.colors[bin] += 1; + self.pixel_sum += value * count as f64; + self.color_sum += value; + } +} use crate::capture::Frame; @@ -42,9 +61,14 @@ impl Region { pub struct Swatch { pub rgb: [u8; 3], pub count: u64, + pub opaque_count: u64, } pub struct Analysis { + pub saturation: Distribution, + pub brightness: Distribution, + pub luminance: Distribution, + pub vision: vision::Report, pub palette: Vec, pub pixels: u64, pub neutral_pixels: u64, @@ -56,6 +80,10 @@ pub struct Analysis { impl Default for Analysis { fn default() -> Self { Self { + saturation: Distribution::default(), + brightness: Distribution::default(), + luminance: Distribution::default(), + vision: vision::Report::default(), palette: Vec::new(), pixels: 0, neutral_pixels: 0, @@ -86,17 +114,22 @@ pub fn hsv(rgb: [u8; 3]) -> [f32; 3] { } pub fn analyze(rgba: &[u8]) -> Analysis { - let mut counts = HashMap::<[u8; 3], u64>::new(); + let mut counts = HashMap::<[u8; 3], (u64, u64)>::new(); for pixel in rgba.as_chunks::<4>().0 { // Ignore fully transparent pixels; count other captured RGB values exactly. if pixel[3] != 0 { - *counts.entry([pixel[0], pixel[1], pixel[2]]).or_default() += 1; + let entry = counts.entry([pixel[0], pixel[1], pixel[2]]).or_default(); + entry.0 += 1; + entry.1 += u64::from(pixel[3] == 255); } } let mut result = Analysis::default(); - for (rgb, count) in counts { + for (rgb, (count, opaque_count)) in counts { result.pixels += count; - let [h, s, _] = hsv(rgb); + let [h, s, v] = hsv(rgb); + result.saturation.add(s as f64, count); + result.brightness.add(v as f64, count); + result.luminance.add(vision::luminance(rgb), count); if s < NEUTRAL_SATURATION { result.neutral_pixels += count; result.neutral_colors += 1; @@ -105,11 +138,16 @@ pub fn analyze(rgba: &[u8]) -> Analysis { result.hue_pixels[bin] += count; result.hue_colors[bin] += 1; } - result.palette.push(Swatch { rgb, count }); + result.palette.push(Swatch { + rgb, + count, + opaque_count, + }); } result .palette .sort_unstable_by(|a, b| b.count.cmp(&a.count).then(a.rgb.cmp(&b.rgb))); + result.vision = vision::check(&result.palette); result } @@ -145,6 +183,29 @@ mod tests { ); } + #[test] + fn saturation_value_and_luminance_include_endpoints_with_both_weights() { + let a = analyze(&[ + 0, 0, 0, 255, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 0, 255, 128, 128, 128, 255, + 0, 255, 0, 0, + ]); + for d in [&a.saturation, &a.brightness, &a.luminance] { + assert_eq!(d.pixels.iter().sum::(), 5); + assert_eq!(d.colors.iter().sum::(), 4); + } + assert_eq!(a.saturation.pixels[0], 3); + assert_eq!(a.saturation.pixels[19], 2); + assert_eq!(a.saturation.colors[19], 1); + assert_eq!(a.brightness.pixels[0], 1); + assert_eq!(a.brightness.pixels[19], 3); + assert_eq!(a.luminance.pixels[19], 1); + assert!((a.saturation.pixel_sum / 5.0 - 0.4).abs() < 1e-9); + assert!((a.saturation.color_sum / 4.0 - 0.25).abs() < 1e-9); + let empty = analyze(&[]); + assert_eq!(empty.brightness.pixel_sum, 0.0); + assert_eq!(empty.vision.colors_checked, 0); + } + #[test] fn crop_uses_source_coordinates_and_clamps() { let frame = Frame { diff --git a/src/analysis/vision.rs b/src/analysis/vision.rs new file mode 100644 index 0000000..4eefa51 --- /dev/null +++ b/src/analysis/vision.rs @@ -0,0 +1,296 @@ +//! Machado, Oliveira & Fernandes (2009), severity 1.0 matrices in linear RGB. +//! https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html +//! Model also used by Chromium: https://developer.chrome.com/docs/chromium/cvd +//! Matrix coefficients cross-checked against QGIS's published implementation: +//! https://api.qgis.org/api/qgsprevieweffect_8cpp_source.html +//! Pair warnings are our own heuristic, not a WCAG test or clinical prediction. +use super::Swatch; +use std::{collections::HashMap, sync::OnceLock}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Vision { + #[default] + Original, + Protanopia, + Deuteranopia, + Tritanopia, + Grayscale, +} + +impl Vision { + pub const CHECKS: [Self; 4] = [ + Self::Protanopia, + Self::Deuteranopia, + Self::Tritanopia, + Self::Grayscale, + ]; + pub fn label(self) -> &'static str { + match self { + Self::Original => "Original", + Self::Protanopia => "Protanopia (red-cone loss)", + Self::Deuteranopia => "Deuteranopia (green-cone loss)", + Self::Tritanopia => "Tritanopia (blue-cone loss)", + Self::Grayscale => "Grayscale (no color cues)", + } + } + + fn linear(self, rgb: [f64; 3]) -> [f64; 3] { + let matrix = match self { + Self::Original => return rgb, + Self::Grayscale => return [luminance_linear(rgb); 3], + Self::Protanopia => [ + [0.152286, 1.052583, -0.204868], + [0.114503, 0.786281, 0.099216], + [-0.003882, -0.048116, 1.051998], + ], + Self::Deuteranopia => [ + [0.367322, 0.860646, -0.227968], + [0.280085, 0.672501, 0.047413], + [-0.011820, 0.042940, 0.968881], + ], + Self::Tritanopia => [ + [1.255528, -0.076749, -0.178779], + [-0.078411, 0.930809, 0.147602], + [0.004733, 0.691367, 0.303900], + ], + }; + matrix.map(|row| { + row.iter() + .zip(rgb) + .map(|(a, b)| a * b) + .sum::() + .clamp(0.0, 1.0) + }) + } + + pub fn simulate(self, rgb: [u8; 3]) -> [u8; 3] { + if self == Self::Original { + return rgb; + } + self.linear(linear_rgb(rgb)).map(encode) + } +} + +fn decode(value: u8) -> f64 { + let value = value as f64 / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +fn encode(value: f64) -> u8 { + let value = value.clamp(0.0, 1.0); + let value = if value <= 0.0031308 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (value * 255.0).round() as u8 +} + +pub fn linear_rgb(rgb: [u8; 3]) -> [f64; 3] { + static LINEAR: OnceLock<[f64; 256]> = OnceLock::new(); + let table = LINEAR.get_or_init(|| std::array::from_fn(|i| decode(i as u8))); + rgb.map(|v| table[v as usize]) +} + +fn luminance_linear([r, g, b]: [f64; 3]) -> f64 { + 0.2126 * r + 0.7152 * g + 0.0722 * b +} +pub fn luminance(rgb: [u8; 3]) -> f64 { + luminance_linear(linear_rgb(rgb)) +} + +// OKLab reference transform: https://bottosson.github.io/posts/oklab/ +fn oklab([r, g, b]: [f64; 3]) -> [f64; 3] { + let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); + let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); + let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); + [ + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ] +} + +fn distance(a: [f64; 3], b: [f64; 3]) -> f64 { + a.iter() + .zip(b) + .map(|(a, b)| (a - b).powi(2)) + .sum::() + .sqrt() +} + +pub fn simulate_rgba(rgba: &[u8], mode: Vision) -> Vec { + let mut output = rgba.to_vec(); + if mode == Vision::Original { + return output; + } + let mut cache = HashMap::new(); + for pixel in output.as_chunks_mut::<4>().0 { + if pixel[3] == 0 { + continue; + } + let rgb = [pixel[0], pixel[1], pixel[2]]; + pixel[..3].copy_from_slice(cache.entry(rgb).or_insert_with(|| mode.simulate(rgb))); + } + output +} + +pub const MAX_COLORS: usize = 64; +#[derive(Debug)] +pub struct PairWarning { + pub a: [u8; 3], + pub b: [u8; 3], + pub before: f64, + pub after: f64, +} + +pub struct Scenario { + pub mode: Vision, + pub pairs: Vec, +} + +#[derive(Default)] +pub struct Report { + pub scenarios: Vec, + pub colors_checked: usize, + pub opaque_colors: usize, + pub pixels_checked: u64, + pub opaque_pixels: u64, +} + +pub fn check(palette: &[Swatch]) -> Report { + // Only opaque colors: imported alpha pixels have an unknown backdrop. + let mut colors: Vec<_> = palette.iter().filter(|s| s.opaque_count > 0).collect(); + colors.sort_unstable_by(|a, b| b.opaque_count.cmp(&a.opaque_count).then(a.rgb.cmp(&b.rgb))); + let opaque_colors = colors.len(); + let opaque_pixels = colors.iter().map(|s| s.opaque_count).sum(); + colors.truncate(MAX_COLORS); + let mut report = Report { + colors_checked: colors.len(), + opaque_colors, + pixels_checked: colors.iter().map(|s| s.opaque_count).sum(), + opaque_pixels, + scenarios: Vec::new(), + }; + let originals: Vec<_> = colors.iter().map(|s| oklab(linear_rgb(s.rgb))).collect(); + for mode in Vision::CHECKS { + let simulated: Vec<_> = colors + .iter() + .map(|s| oklab(mode.linear(linear_rgb(s.rgb)))) + .collect(); + let mut pairs = Vec::new(); + for a in 0..colors.len() { + for b in a + 1..colors.len() { + let before = distance(originals[a], originals[b]); + let after = distance(simulated[a], simulated[b]); + // Deliberately exclude pairs already similar in the original. + // These are tunable screening thresholds, not visibility limits. + if before >= 0.08 && after <= 0.04 && after <= before * 0.5 { + pairs.push(PairWarning { + a: colors[a].rgb, + b: colors[b].rgb, + before, + after, + }); + } + } + } + pairs.sort_by(|a, b| { + a.after + .total_cmp(&b.after) + .then(a.a.cmp(&b.a)) + .then(a.b.cmp(&b.b)) + }); + report.scenarios.push(Scenario { mode, pairs }); + } + report +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn gamma_roundtrip_luminance_and_neutrals() { + for c in 0..=255 { + assert_eq!(encode(decode(c)), c); + for mode in Vision::CHECKS { + assert_eq!(mode.simulate([c; 3]), [c; 3]); + } + } + assert_eq!(luminance([0; 3]), 0.0); + assert!((luminance([255; 3]) - 1.0).abs() < 1e-9); + assert!((luminance([128; 3]) - 0.2158605).abs() < 1e-6); + assert!(luminance([0, 255, 0]) > luminance([255, 0, 0])); + } + #[test] + fn known_primary_simulations_use_linear_light() { + // Independently calculated from severity-1 matrix columns + sRGB OETF. + assert_eq!(Vision::Protanopia.simulate([255, 0, 0]), [109, 95, 0]); + assert_eq!(Vision::Deuteranopia.simulate([255, 0, 0]), [163, 144, 0]); + assert_eq!(Vision::Tritanopia.simulate([0, 0, 255]), [0, 107, 150]); + assert_eq!(Vision::Grayscale.simulate([255, 0, 0]), [127; 3]); + } + #[test] + fn preserves_alpha_and_original_pixels() { + let pixels = [255, 0, 0, 128, 4, 5, 6, 0]; + assert_eq!(simulate_rgba(&pixels, Vision::Original), pixels); + let simulated = simulate_rgba(&pixels, Vision::Deuteranopia); + assert_eq!(simulated[3], 128); + assert_eq!(&simulated[4..], &pixels[4..]); + } + #[test] + fn warns_for_merging_colors_but_not_existing_gray_similarity() { + // Red and this green have nearly equal luminance but different hues. + let analysis = super::super::analyze(&[255, 0, 0, 255, 0, 148, 0, 255]); + let report = check(&analysis.palette); + assert_eq!(report.scenarios.last().unwrap().pairs.len(), 1); + let gray = super::super::analyze(&[ + 128, 128, 128, 255, 129, 129, 129, 255, 0, 0, 0, 255, 255, 255, 255, 255, + ]); + assert!( + check(&gray.palette) + .scenarios + .iter() + .all(|s| s.pairs.is_empty()) + ); + } + #[test] + fn catches_red_green_confusion_under_deuteranopia() { + let a = super::super::analyze(&[255, 0, 0, 255, 0, 173, 0, 255]); + let report = check(&a.palette); + let deutan = report + .scenarios + .iter() + .find(|s| s.mode == Vision::Deuteranopia) + .unwrap(); + assert_eq!(deutan.pairs.len(), 1); + assert!(deutan.pairs[0].before > deutan.pairs[0].after * 2.0); + } + + #[test] + fn coverage_is_bounded_and_alpha_is_not_a_pass() { + let palette: Vec<_> = (0..100) + .map(|i| Swatch { + rgb: [i, 0, 0], + count: 1, + opaque_count: 1, + }) + .collect(); + let report = check(&palette); + assert_eq!( + ( + report.colors_checked, + report.opaque_colors, + report.pixels_checked, + report.opaque_pixels + ), + (64, 100, 64, 100) + ); + let transparent = super::super::analyze(&[255, 0, 0, 128, 0, 148, 0, 0]); + assert_eq!(check(&transparent.palette).colors_checked, 0); + } +} diff --git a/src/app.rs b/src/app.rs index f33cc2c..0d91597 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,9 @@ //! Window/region preview and pixel-color analysis. use crate::{ - analysis::{self, Analysis, Region}, + analysis::{ + self, Analysis, Region, + vision::{self, Vision}, + }, capture::{self, Frame, SharedCapture, Status}, config::Config, ui, @@ -15,6 +18,10 @@ pub struct WhosHueApp { frame: Option, texture: Option, crop_texture: Option, + simulated_texture: Option, + vision_mode: Vision, + vision_panel: bool, + channel: ui::Channel, region: Option, analysis: Analysis, frozen: bool, @@ -43,6 +50,10 @@ impl WhosHueApp { frame: None, texture: None, crop_texture: None, + simulated_texture: None, + vision_mode: Vision::Original, + vision_panel: false, + channel: ui::Channel::Hue, region: None, analysis: Analysis::default(), frozen: false, @@ -68,6 +79,7 @@ impl WhosHueApp { self.frame = None; self.texture = None; self.crop_texture = None; + self.simulated_texture = None; self.region = None; self.analysis = Analysis::default(); self.frozen = false; @@ -126,6 +138,26 @@ impl WhosHueApp { region.height, &pixels, ); + self.refresh_simulation(ctx); + } + } + + fn refresh_simulation(&mut self, ctx: &egui::Context) { + if self.vision_mode == Vision::Original { + self.simulated_texture = None; + return; + } + if let Some(frame) = &self.frame { + let region = self.region.unwrap_or_else(|| Region::full(frame)); + let pixels = vision::simulate_rgba(®ion.crop(frame), self.vision_mode); + Self::texture( + ctx, + &mut self.simulated_texture, + "vision-preview", + region.width, + region.height, + &pixels, + ); } } @@ -305,38 +337,125 @@ impl WhosHueApp { .integer(), ); }); - egui::ScrollArea::both().id_salt("crop_scroll").show(ui, |ui| { - if let Some(texture) = &self.crop_texture { - let response = ui.add(egui::Image::new((texture.id(), texture.size_vec2() * self.zoom)).sense(egui::Sense::click())); - if let Some(pos) = response.hover_pos() { - let x = ((pos.x-response.rect.left()) / self.zoom).floor() as u32; - let y = ((pos.y-response.rect.top()) / self.zoom).floor() as u32; - if x < region.width && y < region.height { - let frame = self.frame.as_ref().unwrap(); - let index = (((region.y+y)*frame.width + region.x+x)*4) as usize; - let rgb = [frame.rgba[index],frame.rgba[index+1],frame.rgba[index+2]]; - let [h,s,v] = analysis::hsv(rgb); - let hex = format!("#{:02X}{:02X}{:02X}",rgb[0],rgb[1],rgb[2]); - if response.clicked() { ui.ctx().copy_text(hex.clone()); } - response.on_hover_text(format!("({}, {}) {hex}\nRGB {}, {}, {}\nHSV {:.1}°, {:.1}%, {:.1}%\nClick to copy hex",region.x+x,region.y+y,rgb[0],rgb[1],rgb[2],h,s*100.0,v*100.0)); + let previous_mode = self.vision_mode; + ui.horizontal_wrapped(|ui| { + ui.label("Compare vision:"); + egui::ComboBox::from_id_salt("vision_preview") + .selected_text(self.vision_mode.label()) + .show_ui(ui, |ui| { + for mode in std::iter::once(Vision::Original).chain(Vision::CHECKS) { + ui.selectable_value(&mut self.vision_mode, mode, mode.label()); } - } - } + }); }); + if previous_mode != self.vision_mode { + self.refresh_simulation(ui.ctx()); + } + // Whole-image reset above may have changed the crop this frame. + let region = self.region.unwrap_or(Region { + x: 0, + y: 0, + width, + height, + }); + egui::ScrollArea::both() + .id_salt("crop_scroll") + .show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label("Original · pixel values and PNG"); + self.crop_inspector(ui, region); + }); + if let Some(texture) = &self.simulated_texture { + ui.vertical(|ui| { + ui.label(self.vision_mode.label()); + ui.image((texture.id(), texture.size_vec2() * self.zoom)); + }); + } + }); + }); + } + + fn crop_inspector(&self, ui: &mut egui::Ui, region: Region) { + let Some(texture) = &self.crop_texture else { + return; + }; + let response = ui.add( + egui::Image::new((texture.id(), texture.size_vec2() * self.zoom)) + .sense(egui::Sense::click()), + ); + if let Some(pos) = response.hover_pos() { + let x = ((pos.x - response.rect.left()) / self.zoom).floor() as u32; + let y = ((pos.y - response.rect.top()) / self.zoom).floor() as u32; + if x < region.width && y < region.height { + let frame = self.frame.as_ref().unwrap(); + let index = (((region.y + y) * frame.width + region.x + x) * 4) as usize; + let rgb = [ + frame.rgba[index], + frame.rgba[index + 1], + frame.rgba[index + 2], + ]; + let [h, s, v] = analysis::hsv(rgb); + let hex = format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]); + if response.clicked() { + ui.ctx().copy_text(hex.clone()); + } + response.on_hover_text(format!( + "({}, {}) {hex}\nRGB {}, {}, {}\nHSV {:.1}°, {:.1}%, {:.1}%\nRelative luminance {:.1}%\nClick to copy hex", + region.x + x, region.y + y, rgb[0], rgb[1], rgb[2], h, s * 100.0, v * 100.0, + vision::luminance(rgb) * 100.0, + )); + } + } } fn sidebar(&mut self, ui: &mut egui::Ui) { - ui.heading("Hue distribution"); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.vision_panel, false, "Color analysis"); + let count: usize = self + .analysis + .vision + .scenarios + .iter() + .map(|s| s.pairs.len()) + .sum(); + ui.selectable_value( + &mut self.vision_panel, + true, + format!("Vision check ({count})"), + ); + }); + ui.separator(); + if self.vision_panel { + let old_mode = self.vision_mode; + egui::ScrollArea::vertical() + .id_salt("vision_warnings") + .show(ui, |ui| { + ui::vision_report(ui, &self.analysis.vision, &mut self.vision_mode); + }); + if old_mode != self.vision_mode { + self.refresh_simulation(ui.ctx()); + } + return; + } + ui.heading("Color distribution"); + ui.horizontal_wrapped(|ui| { + for channel in ui::Channel::ALL { + ui.selectable_value(&mut self.channel, channel, channel.label()); + } + }); ui.horizontal(|ui| { ui.selectable_value(&mut self.distinct, false, "By pixel count"); ui.selectable_value(&mut self.distinct, true, "By distinct color"); }); - ui::histogram(ui, &self.analysis, self.distinct); - ui.label(format!( - "Neutrals: {} pixels / {} colors", - self.analysis.neutral_pixels, self.analysis.neutral_colors - )); - ui.small("HSV · 10° bins · saturation below 5% counted as neutral"); + ui::histogram(ui, &self.analysis, self.distinct, self.channel); + if self.channel == ui::Channel::Hue { + ui.label(format!( + "Neutrals: {} pixels / {} colors", + self.analysis.neutral_pixels, self.analysis.neutral_colors + )); + } + ui.small(self.channel.description()); ui.separator(); ui.heading("Exact palette"); ui.label(format!( @@ -470,7 +589,7 @@ impl eframe::App for WhosHueApp { } ui.separator(); egui::Panel::right("analysis_panel") - .default_size(330.0) + .default_size(370.0) .min_size(300.0) .show(ui, |ui| self.sidebar(ui)); egui::CentralPanel::default().show(ui, |ui| self.preview(ui)); @@ -569,6 +688,74 @@ mod tests { assert_eq!(app.frame.as_ref().unwrap().rgba, [0, 255, 0, 255]); } + #[test] + fn vision_preview_preserves_original_and_updates_after_recrop() { + let ctx = egui::Context::default(); + let mut app = WhosHueApp::empty(Config::default()); + let original = sample(1, 4); + let original_pixels = original.rgba.clone(); + app.set_frame(&ctx, original); + app.vision_mode = Vision::Deuteranopia; + app.refresh_simulation(&ctx); + assert!(app.simulated_texture.is_some()); + assert_eq!(app.frame.as_ref().unwrap().rgba, original_pixels); + assert_eq!(app.analysis.palette[0].rgb, [255, 0, 0]); + app.region = Some(Region { + x: 1, + y: 0, + width: 1, + height: 1, + }); + app.analyze(&ctx); + assert_eq!(app.simulated_texture.as_ref().unwrap().size(), [1, 1]); + assert_eq!(app.analysis.pixels, 1); + app.vision_mode = Vision::Original; + app.refresh_simulation(&ctx); + assert!(app.simulated_texture.is_none()); + } + + #[test] + fn all_histogram_and_vision_views_render() { + let ctx = egui::Context::default(); + let mut app = WhosHueApp::empty(Config::default()); + app.set_frame( + &ctx, + Frame { + seq: 1, + width: 2, + height: 1, + rgba: vec![255, 0, 0, 255, 0, 173, 0, 255], + }, + ); + for mode in Vision::CHECKS { + app.vision_mode = mode; + app.refresh_simulation(&ctx); + for channel in ui::Channel::ALL { + app.channel = channel; + for warnings in [false, true] { + app.vision_panel = warnings; + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1200.0, 800.0), + )), + ..Default::default() + }, + |ui| { + egui::Panel::right("test_sidebar") + .default_size(370.0) + .show(ui, |ui| app.sidebar(ui)); + egui::CentralPanel::default().show(ui, |ui| app.preview(ui)); + }, + ); + assert!(!output.shapes.is_empty()); + output.textures_delta.clear(); + } + } + } + } + #[test] fn preview_and_palette_render_with_source_image() { let ctx = egui::Context::default(); @@ -584,7 +771,7 @@ mod tests { }, |ui| { egui::Panel::right("test_analysis") - .default_size(330.0) + .default_size(370.0) .show(ui, |ui| app.sidebar(ui)); egui::CentralPanel::default().show(ui, |ui| app.preview(ui)); }, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c10241d..2615d0a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,7 @@ -use crate::analysis::{Analysis, HUE_BINS, Region}; +use crate::analysis::{ + Analysis, Region, + vision::{Report, Vision}, +}; use eframe::egui::{self, Color32, Pos2, Rect}; /// Map a drag in a displayed image to a nonempty, source-pixel-aligned crop. @@ -21,45 +24,192 @@ pub fn selection(a: Pos2, b: Pos2, image: Rect, width: u32, height: u32) -> Regi } } -pub fn histogram(ui: &mut egui::Ui, analysis: &Analysis, distinct: bool) { - let values = if distinct { +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Channel { + #[default] + Hue, + Saturation, + Brightness, + Luminance, +} +impl Channel { + pub const ALL: [Self; 4] = [ + Self::Hue, + Self::Saturation, + Self::Brightness, + Self::Luminance, + ]; + pub fn label(self) -> &'static str { + match self { + Self::Hue => "Hue", + Self::Saturation => "Saturation", + Self::Brightness => "Brightness (V)", + Self::Luminance => "Luminance", + } + } + pub fn description(self) -> &'static str { + match self { + Self::Hue => "HSV hue · 10° bins · saturation below 5% counted as neutral", + Self::Saturation => "HSV saturation · 0% neutral → 100% fully saturated", + Self::Brightness => "HSV value · maximum RGB channel · not perceived lightness", + Self::Luminance => { + "Relative luminance · linear sRGB weighted for contrast · not HSV value" + } + } + } +} + +pub fn histogram(ui: &mut egui::Ui, analysis: &Analysis, distinct: bool, channel: Channel) { + let distribution = match channel { + Channel::Hue => None, + Channel::Saturation => Some(&analysis.saturation), + Channel::Brightness => Some(&analysis.brightness), + Channel::Luminance => Some(&analysis.luminance), + }; + let values: &[u64] = if let Some(d) = distribution { + if distinct { &d.colors } else { &d.pixels } + } else if distinct { &analysis.hue_colors } else { &analysis.hue_pixels }; + let count = values.len(); + let total = values.iter().sum::(); let max = values.iter().copied().max().unwrap_or(0).max(1) as f32; let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width(), 110.0), egui::Sense::hover(), ); for (bin, value) in values.iter().enumerate() { - let x = rect.left() + rect.width() * bin as f32 / HUE_BINS as f32; + let t = bin as f32 / (count - 1) as f32; + let x = rect.left() + rect.width() * bin as f32 / count as f32; let bar = Rect::from_min_max( egui::pos2(x, rect.bottom() - 100.0 * *value as f32 / max), - egui::pos2(x + rect.width() / HUE_BINS as f32 - 1.0, rect.bottom()), + egui::pos2(x + rect.width() / count as f32 - 1.0, rect.bottom()), ); - let color: Color32 = - egui::ecolor::Hsva::new(bin as f32 / HUE_BINS as f32, 0.85, 0.9, 1.0).into(); + let color: Color32 = match channel { + Channel::Hue => { + egui::ecolor::Hsva::new(bin as f32 / count as f32, 0.85, 0.9, 1.0).into() + } + Channel::Saturation => egui::ecolor::Hsva::new(0.57, t, 0.9, 1.0).into(), + Channel::Brightness | Channel::Luminance => { + Color32::from_gray((60.0 + 195.0 * t) as u8) + } + }; ui.painter().rect_filled(bar, 0.0, color); let hit = Rect::from_min_max( egui::pos2(x, rect.top()), - egui::pos2(x + rect.width() / HUE_BINS as f32, rect.bottom()), + egui::pos2(x + rect.width() / count as f32, rect.bottom()), ); - ui.interact(hit, ui.id().with(bin), egui::Sense::hover()) + let range = if channel == Channel::Hue { + format!("{}–{}°", bin * 10, (bin + 1) * 10) + } else { + format!("{}–{}%", bin * 5, (bin + 1) * 5) + }; + ui.interact(hit, ui.id().with(("histogram", bin)), egui::Sense::hover()) .on_hover_text(format!( - "{}–{}°: {} {}", - bin * 10, - (bin + 1) * 10, - value, - if distinct { "colors" } else { "pixels" } + "{range}: {value} {} ({:.1}%)", + if distinct { "colors" } else { "pixels" }, + 100.0 * *value as f64 / total.max(1) as f64 )); } ui.horizontal(|ui| { - ui.label("0° red"); + ui.label(if channel == Channel::Hue { + "0° red" + } else { + "0%" + }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label("360° red"); + ui.label(if channel == Channel::Hue { + "360° red" + } else { + "100%" + }); }); }); + if let Some(d) = distribution { + let (sum, n) = if distinct { + (d.color_sum, analysis.palette.len() as u64) + } else { + (d.pixel_sum, analysis.pixels) + }; + if n > 0 { + ui.label(format!("Mean: {:.1}%", 100.0 * sum / n as f64)); + } + } +} + +fn swatch(ui: &mut egui::Ui, rgb: [u8; 3]) { + let (rect, response) = ui.allocate_exact_size(egui::vec2(24.0, 20.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, 2.0, Color32::from_rgb(rgb[0], rgb[1], rgb[2])); + response.on_hover_text(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); +} + +pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) { + ui.heading("Color-vision check"); + ui.label("Potential color confusion, not an accessibility pass/fail."); + if report.colors_checked < 2 { + ui.label("Select at least two opaque colors to check."); + return; + } + ui.label(format!( + "Checked {} / {} opaque colors ({:.1}% of opaque pixels).", + report.colors_checked, + report.opaque_colors, + 100.0 * report.pixels_checked as f64 / report.opaque_pixels.max(1) as f64 + )); + ui.small("Checks the 64 most frequent opaque colors. Transparent pixels and spatial relationships are excluded."); + ui.small("Review gameplay cues and text in context; use shape, labels, or lightness as well as color."); + ui.collapsing("How warnings are chosen", |ui| { + ui.label("A pair is flagged when its OKLab distance falls from at least 0.08 to at most 0.04, losing at least 50% of its separation. These are screening heuristics, not validated visibility thresholds."); + ui.label("Simulations use full-severity Machado matrices. Grayscale tests loss of color cues, not the full experience of achromatopsia. Individual vision varies."); + ui.hyperlink_to("Simulation model", "https://developer.chrome.com/docs/chromium/cvd"); + }); + for scenario in &report.scenarios { + ui.separator(); + ui.strong(scenario.mode.label()); + if scenario.pairs.is_empty() { + ui.label("No strong color-merging candidates in checked colors."); + continue; + } + ui.label(format!( + "⚠ {} potentially confusing pairs", + scenario.pairs.len() + )); + if ui + .button(format!("Preview {}", scenario.mode.label())) + .clicked() + { + *preview = scenario.mode; + } + for pair in scenario.pairs.iter().take(6) { + ui.horizontal(|ui| { + swatch(ui, pair.a); + swatch(ui, pair.b); + ui.label("→"); + swatch(ui, scenario.mode.simulate(pair.a)); + swatch(ui, scenario.mode.simulate(pair.b)); + ui.label(format!( + "{:.0}% closer", + 100.0 * (1.0 - pair.after / pair.before) + )) + .on_hover_text(format!( + "OKLab distance {:.3} → {:.3}", + pair.before, pair.after + )); + }); + ui.small(format!( + "#{:02X}{:02X}{:02X} ↔ #{:02X}{:02X}{:02X}", + pair.a[0], pair.a[1], pair.a[2], pair.b[0], pair.b[1], pair.b[2] + )); + } + if scenario.pairs.len() > 6 { + ui.small( + "Showing the six closest simulated pairs. Select a smaller region to investigate.", + ); + } + } } #[cfg(test)]