Add saturation, value, brightness and color blind views

This commit is contained in:
Schluffe
2026-09-27 23:36:15 +02:00
parent 72f481cc24
commit eeec93ddfc
5 changed files with 793 additions and 55 deletions
+52 -8
View File
@@ -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):
+66 -5
View File
@@ -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<Swatch>,
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::<u64>(), 5);
assert_eq!(d.colors.iter().sum::<u64>(), 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 {
+296
View File
@@ -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::<f64>()
.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::<f64>()
.sqrt()
}
pub fn simulate_rgba(rgba: &[u8], mode: Vision) -> Vec<u8> {
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<PairWarning>,
}
#[derive(Default)]
pub struct Report {
pub scenarios: Vec<Scenario>,
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);
}
}
+207 -20
View File
@@ -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<Frame>,
texture: Option<egui::TextureHandle>,
crop_texture: Option<egui::TextureHandle>,
simulated_texture: Option<egui::TextureHandle>,
vision_mode: Vision,
vision_panel: bool,
channel: ui::Channel,
region: Option<Region>,
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(&region.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::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("HSV · 10° bins · saturation below 5% counted as neutral");
}
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));
},
+166 -16
View File
@@ -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::<u64>();
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)]