Add more filter options and improve layout
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+95
-25
@@ -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<egui::TextureHandle>,
|
||||
simulated_texture: Option<egui::TextureHandle>,
|
||||
vision_mode: Vision,
|
||||
adjustments: Adjustments,
|
||||
vision_panel: bool,
|
||||
channel: ui::Channel,
|
||||
region: Option<Region>,
|
||||
@@ -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");
|
||||
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);
|
||||
});
|
||||
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 {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user