From 67fb971175af765770b99c0090d7c561da5d9d8c Mon Sep 17 00:00:00 2001 From: Schluffe Date: Wed, 30 Sep 2026 22:33:22 +0200 Subject: [PATCH] Add more filter options and improve layout --- README.md | 19 +++++- src/analysis/adjustments.rs | 92 +++++++++++++++++++++++++++ src/analysis/mod.rs | 1 + src/analysis/vision.rs | 14 +++++ src/app.rs | 122 ++++++++++++++++++++++++++++-------- 5 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 src/analysis/adjustments.rs diff --git a/README.md b/README.md index 544333e..dd9fac6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ cargo run --release -- /path/to/screenshot.png source pixels and resets if the source dimensions change. 3. **Freeze** holds the displayed frame and analysis; **Resume** takes the newest 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. 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. @@ -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 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 +Use **Filter** below the selection to apply protanopia, deuteranopia, +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 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. +**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 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 diff --git a/src/analysis/adjustments.rs b/src/analysis/adjustments.rs new file mode 100644 index 0000000..b7e6632 --- /dev/null +++ b/src/analysis/adjustments.rs @@ -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 { + 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..]); + } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index bb157e6..58ee6d5 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,5 +1,6 @@ //! Analysis always uses source pixels, never the scaled preview. use std::collections::HashMap; +pub mod adjustments; pub mod vision; pub const LEVEL_BINS: usize = 20; diff --git a/src/analysis/vision.rs b/src/analysis/vision.rs index 4eefa51..969b78f 100644 --- a/src/analysis/vision.rs +++ b/src/analysis/vision.rs @@ -11,6 +11,7 @@ use std::{collections::HashMap, sync::OnceLock}; pub enum Vision { #[default] Original, + Invert, Protanopia, Deuteranopia, Tritanopia, @@ -27,6 +28,7 @@ impl Vision { pub fn label(self) -> &'static str { match self { Self::Original => "Original", + Self::Invert => "Invert", Self::Protanopia => "Protanopia (red-cone loss)", Self::Deuteranopia => "Deuteranopia (green-cone loss)", Self::Tritanopia => "Tritanopia (blue-cone loss)", @@ -37,6 +39,7 @@ impl Vision { fn linear(self, rgb: [f64; 3]) -> [f64; 3] { let matrix = match self { Self::Original => return rgb, + Self::Invert => return rgb.map(|v| decode(255 - encode(v))), Self::Grayscale => return [luminance_linear(rgb); 3], Self::Protanopia => [ [0.152286, 1.052583, -0.204868], @@ -67,6 +70,9 @@ impl Vision { if self == Self::Original { return rgb; } + if self == Self::Invert { + return rgb.map(|v| 255 - v); + } self.linear(linear_rgb(rgb)).map(encode) } } @@ -243,6 +249,14 @@ mod tests { assert_eq!(&simulated[4..], &pixels[4..]); } #[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() { // Red and this green have nearly equal luminance but different hues. let analysis = super::super::analyze(&[255, 0, 0, 255, 0, 148, 0, 255]); diff --git a/src/app.rs b/src/app.rs index 0d91597..837f129 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use crate::{ analysis::{ self, Analysis, Region, + adjustments::Adjustments, vision::{self, Vision}, }, capture::{self, Frame, SharedCapture, Status}, @@ -20,6 +21,7 @@ pub struct WhosHueApp { crop_texture: Option, simulated_texture: Option, vision_mode: Vision, + adjustments: Adjustments, vision_panel: bool, channel: ui::Channel, region: Option, @@ -52,13 +54,14 @@ impl WhosHueApp { crop_texture: None, simulated_texture: None, vision_mode: Vision::Original, + adjustments: Adjustments::default(), vision_panel: false, channel: ui::Channel::Hue, region: None, analysis: Analysis::default(), frozen: false, distinct: false, - zoom: 2.0, + zoom: 1.0, drag_origin: None, was_frozen: false, last_update: Instant::now(), @@ -143,13 +146,18 @@ impl WhosHueApp { } 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; 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); + 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( ctx, &mut self.simulated_texture, @@ -322,7 +330,9 @@ impl WhosHueApp { height, }); ui.separator(); - ui.horizontal(|ui| { + let previous_mode = self.vision_mode; + let previous_adjustments = self.adjustments; + ui.horizontal_wrapped(|ui| { ui.label(format!( "Selection: {} × {} at {}, {}", region.width, region.height, region.x, region.y @@ -332,23 +342,49 @@ impl WhosHueApp { self.analyze(ui.ctx()); } ui.add( - egui::Slider::new(&mut self.zoom, 1.0..=16.0) + egui::Slider::new(&mut self.zoom, 0.1..=16.0) .text("Zoom") - .integer(), + .logarithmic(true) + .max_decimals(2) + .suffix("×"), ); - }); - let previous_mode = self.vision_mode; - ui.horizontal_wrapped(|ui| { - ui.label("Compare vision:"); + ui.label("Filter:"); 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) { + for mode in [Vision::Original, Vision::Invert] + .into_iter() + .chain(Vision::CHECKS) + { 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()); } // Whole-image reset above may have changed the crop this frame. @@ -361,23 +397,26 @@ impl WhosHueApp { 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)); - }); - } - }); + ui.label( + if self.vision_mode == Vision::Original + && 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); }); } 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; }; let response = ui.add( @@ -714,6 +753,33 @@ mod tests { 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] fn all_histogram_and_vision_views_render() { let ctx = egui::Context::default(); @@ -727,7 +793,11 @@ mod tests { 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.refresh_simulation(&ctx); for channel in ui::Channel::ALL {