Add more filter options and improve layout

This commit is contained in:
Schluffe
2026-09-30 22:33:22 +02:00
parent eeec93ddfc
commit 67fb971175
5 changed files with 219 additions and 29 deletions
+16 -3
View File
@@ -23,7 +23,8 @@ cargo run --release -- /path/to/screenshot.png
source pixels and resets if the source dimensions change. source pixels and resets if the source dimensions change.
3. **Freeze** holds the displayed frame and analysis; **Resume** takes the newest 3. **Freeze** holds the displayed frame and analysis; **Resume** takes the newest
available frame. **Stop sharing** closes capture and keeps the last image. available frame. **Stop sharing** closes capture and keeps the last image.
4. Inspect the nearest-neighbor zoomed crop. Hover for source coordinates, 4. Inspect the nearest-neighbor zoomed crop. Zoom ranges from 0.1× to 16×,
starting at 1×. Hover for source coordinates,
RGB/hex and HSV; click to copy hex. Click palette swatches to copy them too. RGB/hex and HSV; click to copy hex. Click palette swatches to copy them too.
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.
@@ -45,13 +46,25 @@ palette is ordered by count, then RGB for deterministic ties. Fully transparent
pixels are excluded; partially transparent pixels retain their stored RGB values pixels are excluded; partially transparent pixels retain their stored RGB values
and count once. Pixel inspection includes HSV and relative luminance. and count once. Pixel inspection includes HSV and relative luminance.
Use **Compare vision** below the selection to show the original crop alongside Use **Filter** below the selection to apply protanopia, deuteranopia,
protanopia, deuteranopia, tritanopia, or grayscale. The first three use the tritanopia, or grayscale directly to the crop preview, or select **Original**
to use the live color adjustment sliders. The first three use the
Machado–Oliveira–Fernandes full-severity model, applied in linear RGB and then 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 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 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. histograms, copied pixel values, or the exported PNG: those always use the original.
**Invert** replaces each RGB channel with its complement (255 minus the channel).
With **Original** selected, adjust **Hue shift** (−180° to +180°), **Saturation**
(−100% to +100% relative change), and **Brightness (V)** (−100 to +100 percentage
points of HSV value). Changes update the crop preview immediately, including live
captures. Saturation keeps neutral colors neutral; saturation and value clamp to
their valid ranges. Brightness adjusts HSV value, not relative luminance.
**Reset adjustments** restores the unmodified preview. Adjustments are remembered
while another filter is selected and applied again when returning to Original.
Filters preserve alpha and always start from source pixels, so edits do not
accumulate rounding errors.
**Vision check** automatically checks all four scenarios. It flags pairs whose **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 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 original separation. These are project-specific screening heuristics, not
+92
View File
@@ -0,0 +1,92 @@
//! Live HSV adjustments, always applied to original source pixels.
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Adjustments {
pub hue: f32,
pub saturation: f32,
pub brightness: f32,
}
impl Adjustments {
fn apply(self, rgb: [u8; 3]) -> [u8; 3] {
let [h, s, v] = super::hsv(rgb);
let h = (h + self.hue).rem_euclid(360.0) / 60.0;
// Relative saturation keeps neutral pixels neutral.
let s = (s * (1.0 + self.saturation / 100.0)).clamp(0.0, 1.0);
let v = (v + self.brightness / 100.0).clamp(0.0, 1.0);
let c = v * s;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let rgb = match h as u8 {
0 => [c, x, 0.0],
1 => [x, c, 0.0],
2 => [0.0, c, x],
3 => [0.0, x, c],
4 => [x, 0.0, c],
_ => [c, 0.0, x],
};
rgb.map(|channel| ((channel + v - c) * 255.0).round() as u8)
}
pub fn apply_rgba(self, rgba: &[u8]) -> Vec<u8> {
let mut output = rgba.to_vec();
if self == Self::default() {
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(|| self.apply(rgb)));
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hue_wraps_and_rotates_primary_colors() {
let shift = |hue| Adjustments {
hue,
..Default::default()
};
assert_eq!(shift(120.0).apply([255, 0, 0]), [0, 255, 0]);
assert_eq!(shift(-120.0).apply([255, 0, 0]), [0, 0, 255]);
assert_eq!(shift(120.0).apply([0, 0, 255]), [255, 0, 0]);
}
#[test]
fn saturation_and_brightness_have_bounded_endpoints() {
let mut adjustment = Adjustments {
saturation: -100.0,
..Default::default()
};
assert_eq!(adjustment.apply([255, 0, 0]), [255; 3]);
adjustment.saturation = 100.0;
assert_eq!(adjustment.apply([128; 3]), [128; 3]);
assert_eq!(adjustment.apply([255, 128, 128]), [255, 1, 1]);
adjustment.brightness = -100.0;
assert_eq!(adjustment.apply([255, 0, 0]), [0; 3]);
adjustment.brightness = 100.0;
assert_eq!(adjustment.apply([0; 3]), [255; 3]);
}
#[test]
fn identity_and_alpha_are_preserved() {
let pixels = [23, 87, 149, 128, 12, 34, 56, 0];
assert_eq!(Adjustments::default().apply_rgba(&pixels), pixels);
let adjusted = Adjustments {
hue: 90.0,
..Default::default()
}
.apply_rgba(&pixels);
assert_ne!(&adjusted[..3], &pixels[..3]);
assert_eq!(adjusted[3], 128);
assert_eq!(&adjusted[4..], &pixels[4..]);
}
}
+1
View File
@@ -1,5 +1,6 @@
//! 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 adjustments;
pub mod vision; pub mod vision;
pub const LEVEL_BINS: usize = 20; pub const LEVEL_BINS: usize = 20;
+14
View File
@@ -11,6 +11,7 @@ use std::{collections::HashMap, sync::OnceLock};
pub enum Vision { pub enum Vision {
#[default] #[default]
Original, Original,
Invert,
Protanopia, Protanopia,
Deuteranopia, Deuteranopia,
Tritanopia, Tritanopia,
@@ -27,6 +28,7 @@ impl Vision {
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
Self::Original => "Original", Self::Original => "Original",
Self::Invert => "Invert",
Self::Protanopia => "Protanopia (red-cone loss)", Self::Protanopia => "Protanopia (red-cone loss)",
Self::Deuteranopia => "Deuteranopia (green-cone loss)", Self::Deuteranopia => "Deuteranopia (green-cone loss)",
Self::Tritanopia => "Tritanopia (blue-cone loss)", Self::Tritanopia => "Tritanopia (blue-cone loss)",
@@ -37,6 +39,7 @@ impl Vision {
fn linear(self, rgb: [f64; 3]) -> [f64; 3] { fn linear(self, rgb: [f64; 3]) -> [f64; 3] {
let matrix = match self { let matrix = match self {
Self::Original => return rgb, Self::Original => return rgb,
Self::Invert => return rgb.map(|v| decode(255 - encode(v))),
Self::Grayscale => return [luminance_linear(rgb); 3], Self::Grayscale => return [luminance_linear(rgb); 3],
Self::Protanopia => [ Self::Protanopia => [
[0.152286, 1.052583, -0.204868], [0.152286, 1.052583, -0.204868],
@@ -67,6 +70,9 @@ impl Vision {
if self == Self::Original { if self == Self::Original {
return rgb; return rgb;
} }
if self == Self::Invert {
return rgb.map(|v| 255 - v);
}
self.linear(linear_rgb(rgb)).map(encode) self.linear(linear_rgb(rgb)).map(encode)
} }
} }
@@ -243,6 +249,14 @@ mod tests {
assert_eq!(&simulated[4..], &pixels[4..]); assert_eq!(&simulated[4..], &pixels[4..]);
} }
#[test] #[test]
fn invert_uses_rgb_complements_and_preserves_alpha() {
assert_eq!(Vision::Invert.simulate([0, 128, 255]), [255, 127, 0]);
let pixels = [23, 87, 149, 128, 4, 5, 6, 0];
let inverted = simulate_rgba(&pixels, Vision::Invert);
assert_eq!(inverted, [232, 168, 106, 128, 4, 5, 6, 0]);
assert_eq!(simulate_rgba(&inverted, Vision::Invert), pixels);
}
#[test]
fn warns_for_merging_colors_but_not_existing_gray_similarity() { fn warns_for_merging_colors_but_not_existing_gray_similarity() {
// Red and this green have nearly equal luminance but different hues. // 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 analysis = super::super::analyze(&[255, 0, 0, 255, 0, 148, 0, 255]);
+95 -25
View File
@@ -2,6 +2,7 @@
use crate::{ use crate::{
analysis::{ analysis::{
self, Analysis, Region, self, Analysis, Region,
adjustments::Adjustments,
vision::{self, Vision}, vision::{self, Vision},
}, },
capture::{self, Frame, SharedCapture, Status}, capture::{self, Frame, SharedCapture, Status},
@@ -20,6 +21,7 @@ pub struct WhosHueApp {
crop_texture: Option<egui::TextureHandle>, crop_texture: Option<egui::TextureHandle>,
simulated_texture: Option<egui::TextureHandle>, simulated_texture: Option<egui::TextureHandle>,
vision_mode: Vision, vision_mode: Vision,
adjustments: Adjustments,
vision_panel: bool, vision_panel: bool,
channel: ui::Channel, channel: ui::Channel,
region: Option<Region>, region: Option<Region>,
@@ -52,13 +54,14 @@ impl WhosHueApp {
crop_texture: None, crop_texture: None,
simulated_texture: None, simulated_texture: None,
vision_mode: Vision::Original, vision_mode: Vision::Original,
adjustments: Adjustments::default(),
vision_panel: false, vision_panel: false,
channel: ui::Channel::Hue, channel: ui::Channel::Hue,
region: None, region: None,
analysis: Analysis::default(), analysis: Analysis::default(),
frozen: false, frozen: false,
distinct: false, distinct: false,
zoom: 2.0, zoom: 1.0,
drag_origin: None, drag_origin: None,
was_frozen: false, was_frozen: false,
last_update: Instant::now(), last_update: Instant::now(),
@@ -143,13 +146,18 @@ impl WhosHueApp {
} }
fn refresh_simulation(&mut self, ctx: &egui::Context) { fn refresh_simulation(&mut self, ctx: &egui::Context) {
if self.vision_mode == Vision::Original { if self.vision_mode == Vision::Original && self.adjustments == Adjustments::default() {
self.simulated_texture = None; self.simulated_texture = None;
return; return;
} }
if let Some(frame) = &self.frame { if let Some(frame) = &self.frame {
let region = self.region.unwrap_or_else(|| Region::full(frame)); let region = self.region.unwrap_or_else(|| Region::full(frame));
let pixels = vision::simulate_rgba(&region.crop(frame), self.vision_mode); let crop = region.crop(frame);
let pixels = if self.vision_mode == Vision::Original {
self.adjustments.apply_rgba(&crop)
} else {
vision::simulate_rgba(&crop, self.vision_mode)
};
Self::texture( Self::texture(
ctx, ctx,
&mut self.simulated_texture, &mut self.simulated_texture,
@@ -322,7 +330,9 @@ impl WhosHueApp {
height, height,
}); });
ui.separator(); ui.separator();
ui.horizontal(|ui| { let previous_mode = self.vision_mode;
let previous_adjustments = self.adjustments;
ui.horizontal_wrapped(|ui| {
ui.label(format!( ui.label(format!(
"Selection: {} × {} at {}, {}", "Selection: {} × {} at {}, {}",
region.width, region.height, region.x, region.y region.width, region.height, region.x, region.y
@@ -332,23 +342,49 @@ impl WhosHueApp {
self.analyze(ui.ctx()); self.analyze(ui.ctx());
} }
ui.add( ui.add(
egui::Slider::new(&mut self.zoom, 1.0..=16.0) egui::Slider::new(&mut self.zoom, 0.1..=16.0)
.text("Zoom") .text("Zoom")
.integer(), .logarithmic(true)
.max_decimals(2)
.suffix("×"),
); );
}); ui.label("Filter:");
let previous_mode = self.vision_mode;
ui.horizontal_wrapped(|ui| {
ui.label("Compare vision:");
egui::ComboBox::from_id_salt("vision_preview") egui::ComboBox::from_id_salt("vision_preview")
.selected_text(self.vision_mode.label()) .selected_text(self.vision_mode.label())
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for mode in std::iter::once(Vision::Original).chain(Vision::CHECKS) { for mode in [Vision::Original, Vision::Invert]
.into_iter()
.chain(Vision::CHECKS)
{
ui.selectable_value(&mut self.vision_mode, mode, mode.label()); ui.selectable_value(&mut self.vision_mode, mode, mode.label());
} }
}); });
}); });
if previous_mode != self.vision_mode { if self.vision_mode == Vision::Original {
ui.horizontal_wrapped(|ui| {
ui.add(
egui::Slider::new(&mut self.adjustments.hue, -180.0..=180.0)
.text("Hue shift")
.suffix("°"),
);
ui.add(
egui::Slider::new(&mut self.adjustments.saturation, -100.0..=100.0)
.text("Saturation")
.suffix("%"),
)
.on_hover_text("Relative saturation change; neutral colors stay neutral.");
ui.add(
egui::Slider::new(&mut self.adjustments.brightness, -100.0..=100.0)
.text("Brightness (V)")
.suffix("%"),
)
.on_hover_text("Shift HSV value by percentage points, clamped to 0–100%.");
if ui.button("Reset adjustments").clicked() {
self.adjustments = Adjustments::default();
}
});
}
if previous_mode != self.vision_mode || previous_adjustments != self.adjustments {
self.refresh_simulation(ui.ctx()); self.refresh_simulation(ui.ctx());
} }
// Whole-image reset above may have changed the crop this frame. // Whole-image reset above may have changed the crop this frame.
@@ -361,23 +397,26 @@ impl WhosHueApp {
egui::ScrollArea::both() egui::ScrollArea::both()
.id_salt("crop_scroll") .id_salt("crop_scroll")
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal_top(|ui| { ui.label(
ui.vertical(|ui| { if self.vision_mode == Vision::Original
ui.label("Original · pixel values and PNG"); && self.adjustments != Adjustments::default()
{
"Custom adjustments"
} else {
self.vision_mode.label()
},
);
ui.small("Pixel values and PNG use original colors");
self.crop_inspector(ui, region); 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) { fn crop_inspector(&self, ui: &mut egui::Ui, region: Region) {
let Some(texture) = &self.crop_texture else { let Some(texture) = self
.simulated_texture
.as_ref()
.or(self.crop_texture.as_ref())
else {
return; return;
}; };
let response = ui.add( let response = ui.add(
@@ -714,6 +753,33 @@ mod tests {
assert!(app.simulated_texture.is_none()); assert!(app.simulated_texture.is_none());
} }
#[test]
fn custom_adjustments_survive_filter_switches_and_recropping() {
let ctx = egui::Context::default();
let mut app = WhosHueApp::empty(Config::default());
app.set_frame(&ctx, sample(1, 4));
app.adjustments.hue = 120.0;
app.refresh_simulation(&ctx);
assert!(app.simulated_texture.is_some());
app.vision_mode = Vision::Invert;
app.refresh_simulation(&ctx);
app.vision_mode = Vision::Original;
app.region = Some(Region {
x: 1,
y: 0,
width: 1,
height: 1,
});
app.analyze(&ctx);
assert_eq!(app.adjustments.hue, 120.0);
assert_eq!(app.simulated_texture.as_ref().unwrap().size(), [1, 1]);
assert_eq!(app.analysis.palette[0].rgb, [255, 0, 0]);
assert_eq!(app.frame.as_ref().unwrap().rgba, sample(1, 4).rgba);
app.adjustments = Adjustments::default();
app.refresh_simulation(&ctx);
assert!(app.simulated_texture.is_none());
}
#[test] #[test]
fn all_histogram_and_vision_views_render() { fn all_histogram_and_vision_views_render() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
@@ -727,7 +793,11 @@ mod tests {
rgba: vec![255, 0, 0, 255, 0, 173, 0, 255], rgba: vec![255, 0, 0, 255, 0, 173, 0, 255],
}, },
); );
for mode in Vision::CHECKS { app.adjustments.hue = 45.0;
for mode in [Vision::Original, Vision::Invert]
.into_iter()
.chain(Vision::CHECKS)
{
app.vision_mode = mode; app.vision_mode = mode;
app.refresh_simulation(&ctx); app.refresh_simulation(&ctx);
for channel in ui::Channel::ALL { for channel in ui::Channel::ALL {