Add saturation, value, brightness and color blind views
This commit is contained in:
@@ -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
|
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.
|
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
|
## Color analysis and vision checks
|
||||||
weight per distinct RGB color. Colors with saturation below 5% are counted
|
|
||||||
separately as neutrals. The palette is exact (no quantization), ordered by count,
|
The **Color analysis** tab has four distributions:
|
||||||
then RGB for deterministic ties. Fully transparent pixels are excluded; partially
|
|
||||||
transparent pixels retain their stored RGB values and count once.
|
- **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
|
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`)
|
`$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.
|
many unique colors are more expensive; release builds are recommended.
|
||||||
- Game-specific behavior on focus loss/minimization needs desktop testing.
|
- Game-specific behavior on focus loss/minimization needs desktop testing.
|
||||||
- Perceptual OKLCH views, color/hue highlighting, and comparing saved selections
|
- 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
|
## Validation
|
||||||
|
|
||||||
@@ -67,10 +108,13 @@ cargo fmt --check
|
|||||||
|
|
||||||
Tests cover hue boundaries, neutral/transparent pixel handling, weighting,
|
Tests cover hue boundaries, neutral/transparent pixel handling, weighting,
|
||||||
cropping, selection coordinates, pixel-format conversion, frozen-frame behavior,
|
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
|
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
|
`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.
|
Wayland desktop and permission through KDE's picker.
|
||||||
|
|
||||||
For a capture-only diagnostic (saves the first full source frame):
|
For a capture-only diagnostic (saves the first full source frame):
|
||||||
|
|||||||
+66
-5
@@ -1,5 +1,24 @@
|
|||||||
//! Analysis always uses source pixels, never the scaled preview.
|
//! Analysis always uses source pixels, never the scaled preview.
|
||||||
use std::collections::HashMap;
|
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;
|
use crate::capture::Frame;
|
||||||
|
|
||||||
@@ -42,9 +61,14 @@ impl Region {
|
|||||||
pub struct Swatch {
|
pub struct Swatch {
|
||||||
pub rgb: [u8; 3],
|
pub rgb: [u8; 3],
|
||||||
pub count: u64,
|
pub count: u64,
|
||||||
|
pub opaque_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Analysis {
|
pub struct Analysis {
|
||||||
|
pub saturation: Distribution,
|
||||||
|
pub brightness: Distribution,
|
||||||
|
pub luminance: Distribution,
|
||||||
|
pub vision: vision::Report,
|
||||||
pub palette: Vec<Swatch>,
|
pub palette: Vec<Swatch>,
|
||||||
pub pixels: u64,
|
pub pixels: u64,
|
||||||
pub neutral_pixels: u64,
|
pub neutral_pixels: u64,
|
||||||
@@ -56,6 +80,10 @@ pub struct Analysis {
|
|||||||
impl Default for Analysis {
|
impl Default for Analysis {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
saturation: Distribution::default(),
|
||||||
|
brightness: Distribution::default(),
|
||||||
|
luminance: Distribution::default(),
|
||||||
|
vision: vision::Report::default(),
|
||||||
palette: Vec::new(),
|
palette: Vec::new(),
|
||||||
pixels: 0,
|
pixels: 0,
|
||||||
neutral_pixels: 0,
|
neutral_pixels: 0,
|
||||||
@@ -86,17 +114,22 @@ pub fn hsv(rgb: [u8; 3]) -> [f32; 3] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze(rgba: &[u8]) -> Analysis {
|
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 {
|
for pixel in rgba.as_chunks::<4>().0 {
|
||||||
// Ignore fully transparent pixels; count other captured RGB values exactly.
|
// Ignore fully transparent pixels; count other captured RGB values exactly.
|
||||||
if pixel[3] != 0 {
|
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();
|
let mut result = Analysis::default();
|
||||||
for (rgb, count) in counts {
|
for (rgb, (count, opaque_count)) in counts {
|
||||||
result.pixels += count;
|
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 {
|
if s < NEUTRAL_SATURATION {
|
||||||
result.neutral_pixels += count;
|
result.neutral_pixels += count;
|
||||||
result.neutral_colors += 1;
|
result.neutral_colors += 1;
|
||||||
@@ -105,11 +138,16 @@ pub fn analyze(rgba: &[u8]) -> Analysis {
|
|||||||
result.hue_pixels[bin] += count;
|
result.hue_pixels[bin] += count;
|
||||||
result.hue_colors[bin] += 1;
|
result.hue_colors[bin] += 1;
|
||||||
}
|
}
|
||||||
result.palette.push(Swatch { rgb, count });
|
result.palette.push(Swatch {
|
||||||
|
rgb,
|
||||||
|
count,
|
||||||
|
opaque_count,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
.palette
|
.palette
|
||||||
.sort_unstable_by(|a, b| b.count.cmp(&a.count).then(a.rgb.cmp(&b.rgb)));
|
.sort_unstable_by(|a, b| b.count.cmp(&a.count).then(a.rgb.cmp(&b.rgb)));
|
||||||
|
result.vision = vision::check(&result.palette);
|
||||||
result
|
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]
|
#[test]
|
||||||
fn crop_uses_source_coordinates_and_clamps() {
|
fn crop_uses_source_coordinates_and_clamps() {
|
||||||
let frame = Frame {
|
let frame = Frame {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
-26
@@ -1,6 +1,9 @@
|
|||||||
//! Window/region preview and pixel-color analysis.
|
//! Window/region preview and pixel-color analysis.
|
||||||
use crate::{
|
use crate::{
|
||||||
analysis::{self, Analysis, Region},
|
analysis::{
|
||||||
|
self, Analysis, Region,
|
||||||
|
vision::{self, Vision},
|
||||||
|
},
|
||||||
capture::{self, Frame, SharedCapture, Status},
|
capture::{self, Frame, SharedCapture, Status},
|
||||||
config::Config,
|
config::Config,
|
||||||
ui,
|
ui,
|
||||||
@@ -15,6 +18,10 @@ pub struct WhosHueApp {
|
|||||||
frame: Option<Frame>,
|
frame: Option<Frame>,
|
||||||
texture: Option<egui::TextureHandle>,
|
texture: Option<egui::TextureHandle>,
|
||||||
crop_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>,
|
region: Option<Region>,
|
||||||
analysis: Analysis,
|
analysis: Analysis,
|
||||||
frozen: bool,
|
frozen: bool,
|
||||||
@@ -43,6 +50,10 @@ impl WhosHueApp {
|
|||||||
frame: None,
|
frame: None,
|
||||||
texture: None,
|
texture: None,
|
||||||
crop_texture: None,
|
crop_texture: None,
|
||||||
|
simulated_texture: None,
|
||||||
|
vision_mode: Vision::Original,
|
||||||
|
vision_panel: false,
|
||||||
|
channel: ui::Channel::Hue,
|
||||||
region: None,
|
region: None,
|
||||||
analysis: Analysis::default(),
|
analysis: Analysis::default(),
|
||||||
frozen: false,
|
frozen: false,
|
||||||
@@ -68,6 +79,7 @@ impl WhosHueApp {
|
|||||||
self.frame = None;
|
self.frame = None;
|
||||||
self.texture = None;
|
self.texture = None;
|
||||||
self.crop_texture = None;
|
self.crop_texture = None;
|
||||||
|
self.simulated_texture = None;
|
||||||
self.region = None;
|
self.region = None;
|
||||||
self.analysis = Analysis::default();
|
self.analysis = Analysis::default();
|
||||||
self.frozen = false;
|
self.frozen = false;
|
||||||
@@ -126,6 +138,26 @@ impl WhosHueApp {
|
|||||||
region.height,
|
region.height,
|
||||||
&pixels,
|
&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(),
|
.integer(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
egui::ScrollArea::both().id_salt("crop_scroll").show(ui, |ui| {
|
let previous_mode = self.vision_mode;
|
||||||
if let Some(texture) = &self.crop_texture {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = ui.add(egui::Image::new((texture.id(), texture.size_vec2() * self.zoom)).sense(egui::Sense::click()));
|
ui.label("Compare vision:");
|
||||||
if let Some(pos) = response.hover_pos() {
|
egui::ComboBox::from_id_salt("vision_preview")
|
||||||
let x = ((pos.x-response.rect.left()) / self.zoom).floor() as u32;
|
.selected_text(self.vision_mode.label())
|
||||||
let y = ((pos.y-response.rect.top()) / self.zoom).floor() as u32;
|
.show_ui(ui, |ui| {
|
||||||
if x < region.width && y < region.height {
|
for mode in std::iter::once(Vision::Original).chain(Vision::CHECKS) {
|
||||||
let frame = self.frame.as_ref().unwrap();
|
ui.selectable_value(&mut self.vision_mode, mode, mode.label());
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
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) {
|
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.horizontal(|ui| {
|
||||||
ui.selectable_value(&mut self.distinct, false, "By pixel count");
|
ui.selectable_value(&mut self.distinct, false, "By pixel count");
|
||||||
ui.selectable_value(&mut self.distinct, true, "By distinct color");
|
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);
|
||||||
ui.label(format!(
|
if self.channel == ui::Channel::Hue {
|
||||||
"Neutrals: {} pixels / {} colors",
|
ui.label(format!(
|
||||||
self.analysis.neutral_pixels, self.analysis.neutral_colors
|
"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.separator();
|
||||||
ui.heading("Exact palette");
|
ui.heading("Exact palette");
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
@@ -470,7 +589,7 @@ impl eframe::App for WhosHueApp {
|
|||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
egui::Panel::right("analysis_panel")
|
egui::Panel::right("analysis_panel")
|
||||||
.default_size(330.0)
|
.default_size(370.0)
|
||||||
.min_size(300.0)
|
.min_size(300.0)
|
||||||
.show(ui, |ui| self.sidebar(ui));
|
.show(ui, |ui| self.sidebar(ui));
|
||||||
egui::CentralPanel::default().show(ui, |ui| self.preview(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]);
|
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]
|
#[test]
|
||||||
fn preview_and_palette_render_with_source_image() {
|
fn preview_and_palette_render_with_source_image() {
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
@@ -584,7 +771,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
|ui| {
|
|ui| {
|
||||||
egui::Panel::right("test_analysis")
|
egui::Panel::right("test_analysis")
|
||||||
.default_size(330.0)
|
.default_size(370.0)
|
||||||
.show(ui, |ui| app.sidebar(ui));
|
.show(ui, |ui| app.sidebar(ui));
|
||||||
egui::CentralPanel::default().show(ui, |ui| app.preview(ui));
|
egui::CentralPanel::default().show(ui, |ui| app.preview(ui));
|
||||||
},
|
},
|
||||||
|
|||||||
+166
-16
@@ -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};
|
use eframe::egui::{self, Color32, Pos2, Rect};
|
||||||
|
|
||||||
/// Map a drag in a displayed image to a nonempty, source-pixel-aligned crop.
|
/// 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) {
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
let values = if distinct {
|
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
|
&analysis.hue_colors
|
||||||
} else {
|
} else {
|
||||||
&analysis.hue_pixels
|
&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 max = values.iter().copied().max().unwrap_or(0).max(1) as f32;
|
||||||
let (rect, _) = ui.allocate_exact_size(
|
let (rect, _) = ui.allocate_exact_size(
|
||||||
egui::vec2(ui.available_width(), 110.0),
|
egui::vec2(ui.available_width(), 110.0),
|
||||||
egui::Sense::hover(),
|
egui::Sense::hover(),
|
||||||
);
|
);
|
||||||
for (bin, value) in values.iter().enumerate() {
|
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(
|
let bar = Rect::from_min_max(
|
||||||
egui::pos2(x, rect.bottom() - 100.0 * *value as f32 / 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 =
|
let color: Color32 = match channel {
|
||||||
egui::ecolor::Hsva::new(bin as f32 / HUE_BINS as f32, 0.85, 0.9, 1.0).into();
|
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);
|
ui.painter().rect_filled(bar, 0.0, color);
|
||||||
let hit = Rect::from_min_max(
|
let hit = Rect::from_min_max(
|
||||||
egui::pos2(x, rect.top()),
|
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!(
|
.on_hover_text(format!(
|
||||||
"{}–{}°: {} {}",
|
"{range}: {value} {} ({:.1}%)",
|
||||||
bin * 10,
|
if distinct { "colors" } else { "pixels" },
|
||||||
(bin + 1) * 10,
|
100.0 * *value as f64 / total.max(1) as f64
|
||||||
value,
|
|
||||||
if distinct { "colors" } else { "pixels" }
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
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.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user