Add color mapping
This commit is contained in:
@@ -29,6 +29,35 @@ 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.
|
||||||
|
|
||||||
|
## Color grouping and mapping
|
||||||
|
|
||||||
|
**Group similar colors** is enabled by default in the **Palette**. Nearby shades
|
||||||
|
from scaling or compression share a row showing their combined percentage and
|
||||||
|
shade count. Adjust **Tolerance** (default 8, range 0–32) to control the maximum
|
||||||
|
difference in each RGB channel from the group's most frequent color. Groups do
|
||||||
|
not chain together through intermediate shades. Turn grouping off or set tolerance
|
||||||
|
to zero for the exact palette. Hover a row to highlight its pixels in magenta in
|
||||||
|
the crop preview; this temporary highlight is never exported.
|
||||||
|
|
||||||
|
Freeze a capture or import an image, then click **Map** beside a palette row.
|
||||||
|
In **Color mapping**, click its replacement swatch to choose another color or enter
|
||||||
|
RGB values. Every original shade in that group maps to the chosen replacement.
|
||||||
|
Add multiple mappings to try a new palette. Each mapping keeps its original group
|
||||||
|
members even if you later change tolerance or crop. If a new mapping overlaps an
|
||||||
|
older one, the new mapping owns those shades; the older mapping keeps any others.
|
||||||
|
Replacements are simultaneous, so swapping two colors works without cascading.
|
||||||
|
Alpha is preserved and fully transparent pixels are left untouched.
|
||||||
|
|
||||||
|
Toggle **Preview mappings** to compare with the original palette. Remove an
|
||||||
|
individual mapping with **×** or use **Reset mappings**. Mappings survive crop
|
||||||
|
changes, pause during live capture, and return when frozen again. Opening a new
|
||||||
|
image or choosing a new capture source clears them.
|
||||||
|
|
||||||
|
Mappings apply before the preview adjustments or vision filter. Source preview,
|
||||||
|
analysis, copied pixel values, and **Save crop PNG** still use the original.
|
||||||
|
**Save mapped crop PNG** exports the crop with replacements, preserving alpha
|
||||||
|
and excluding preview adjustments and filters.
|
||||||
|
|
||||||
## Color analysis and vision checks
|
## Color analysis and vision checks
|
||||||
|
|
||||||
The **Color analysis** tab has four distributions:
|
The **Color analysis** tab has four distributions:
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Palette grouping and simultaneous replacement using original RGB values.
|
||||||
|
use super::Swatch;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ColorGroup {
|
||||||
|
pub rgb: [u8; 3],
|
||||||
|
pub count: u64,
|
||||||
|
pub members: Vec<[u8; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most frequent colors seed groups. Every member is within `tolerance` of
|
||||||
|
/// its seed in each RGB channel; nearby shades cannot form unbounded chains.
|
||||||
|
/// Spatial buckets keep large palettes from requiring an all-pairs search.
|
||||||
|
pub fn group_palette(palette: &[Swatch], tolerance: u8) -> Vec<ColorGroup> {
|
||||||
|
if tolerance == 0 {
|
||||||
|
return palette
|
||||||
|
.iter()
|
||||||
|
.map(|s| ColorGroup {
|
||||||
|
rgb: s.rgb,
|
||||||
|
count: s.count,
|
||||||
|
members: vec![s.rgb],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
let mut sorted: Vec<_> = palette.iter().collect();
|
||||||
|
sorted.sort_by_key(|s| (std::cmp::Reverse(s.count), s.rgb));
|
||||||
|
let mut groups: Vec<ColorGroup> = Vec::new();
|
||||||
|
let mut buckets = HashMap::<[i16; 3], usize>::new();
|
||||||
|
let side = i16::from(tolerance) + 1;
|
||||||
|
for swatch in sorted {
|
||||||
|
let bucket = swatch.rgb.map(|v| i16::from(v) / side);
|
||||||
|
let mut best = None;
|
||||||
|
for x in -1..=1 {
|
||||||
|
for y in -1..=1 {
|
||||||
|
for z in -1..=1 {
|
||||||
|
if let Some(&index) =
|
||||||
|
buckets.get(&[bucket[0] + x, bucket[1] + y, bucket[2] + z])
|
||||||
|
{
|
||||||
|
let seed = groups[index].rgb;
|
||||||
|
let delta =
|
||||||
|
std::array::from_fn::<_, 3, _>(|i| seed[i].abs_diff(swatch.rgb[i]));
|
||||||
|
if delta.iter().all(|&d| d <= tolerance) {
|
||||||
|
let distance: u32 = delta.iter().map(|&d| u32::from(d).pow(2)).sum();
|
||||||
|
let candidate = (distance, index);
|
||||||
|
if best.is_none_or(|old| candidate < old) {
|
||||||
|
best = Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((_, index)) = best {
|
||||||
|
groups[index].count += swatch.count;
|
||||||
|
groups[index].members.push(swatch.rgb);
|
||||||
|
} else {
|
||||||
|
buckets.insert(bucket, groups.len());
|
||||||
|
groups.push(ColorGroup {
|
||||||
|
rgb: swatch.rgb,
|
||||||
|
count: swatch.count,
|
||||||
|
members: vec![swatch.rgb],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups.sort_by_key(|g| (std::cmp::Reverse(g.count), g.rgb));
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorGroup {
|
||||||
|
pub fn highlight_rgba(&self, rgba: &[u8]) -> Vec<u8> {
|
||||||
|
let members: std::collections::HashSet<_> = self.members.iter().copied().collect();
|
||||||
|
let mut output = rgba.to_vec();
|
||||||
|
for pixel in output.as_chunks_mut::<4>().0 {
|
||||||
|
if pixel[3] == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if members.contains(&[pixel[0], pixel[1], pixel[2]]) {
|
||||||
|
pixel[..3].copy_from_slice(&[255, 0, 255]);
|
||||||
|
} else {
|
||||||
|
for channel in &mut pixel[..3] {
|
||||||
|
*channel /= 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ColorMapping {
|
||||||
|
pub source: [u8; 3],
|
||||||
|
pub target: [u8; 3],
|
||||||
|
pub members: Vec<[u8; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ColorMappings(pub Vec<ColorMapping>);
|
||||||
|
|
||||||
|
impl ColorMappings {
|
||||||
|
pub fn insert_group(&mut self, group: &ColorGroup) {
|
||||||
|
let members: std::collections::HashSet<_> = group.members.iter().copied().collect();
|
||||||
|
// The latest explicit mapping owns overlapping colors. Keep unaffected
|
||||||
|
// members of earlier mappings, and remove entries that become empty.
|
||||||
|
for entry in &mut self.0 {
|
||||||
|
entry.members.retain(|rgb| !members.contains(rgb));
|
||||||
|
}
|
||||||
|
self.0.retain(|entry| !entry.members.is_empty());
|
||||||
|
self.0.push(ColorMapping {
|
||||||
|
source: group.rgb,
|
||||||
|
target: group.rgb,
|
||||||
|
members: group.members.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn insert(&mut self, source: [u8; 3], target: [u8; 3]) {
|
||||||
|
self.insert_group(&ColorGroup {
|
||||||
|
rgb: source,
|
||||||
|
count: 1,
|
||||||
|
members: vec![source],
|
||||||
|
});
|
||||||
|
self.0.last_mut().unwrap().target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_rgba(&self, rgba: &[u8]) -> Vec<u8> {
|
||||||
|
let lookup: HashMap<_, _> = self
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.flat_map(|entry| entry.members.iter().map(move |&rgb| (rgb, entry.target)))
|
||||||
|
.collect();
|
||||||
|
let mut output = rgba.to_vec();
|
||||||
|
for pixel in output.as_chunks_mut::<4>().0 {
|
||||||
|
if pixel[3] != 0
|
||||||
|
&& let Some(target) = lookup.get(&[pixel[0], pixel[1], pixel[2]])
|
||||||
|
{
|
||||||
|
pixel[..3].copy_from_slice(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn swatch(rgb: [u8; 3], count: u64) -> Swatch {
|
||||||
|
Swatch {
|
||||||
|
rgb,
|
||||||
|
count,
|
||||||
|
opaque_count: count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grouping_combines_counts_without_chaining_and_keeps_exact_mode() {
|
||||||
|
let palette = vec![
|
||||||
|
swatch([100, 0, 0], 100),
|
||||||
|
swatch([105, 0, 0], 3),
|
||||||
|
swatch([110, 0, 0], 2),
|
||||||
|
swatch([0, 255, 0], 1),
|
||||||
|
];
|
||||||
|
let groups = group_palette(&palette, 5);
|
||||||
|
assert_eq!(groups.len(), 3);
|
||||||
|
assert_eq!(groups[0].members, [[100, 0, 0], [105, 0, 0]]);
|
||||||
|
assert_eq!(groups[0].count, 103);
|
||||||
|
assert_eq!(groups.iter().map(|g| g.count).sum::<u64>(), 106);
|
||||||
|
assert_eq!(group_palette(&palette, 0).len(), 4);
|
||||||
|
let mut reversed = palette;
|
||||||
|
reversed.reverse();
|
||||||
|
assert_eq!(group_palette(&reversed, 5)[0].members, groups[0].members);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_mapping_uses_fixed_members_and_latest_mapping_owns_overlap() {
|
||||||
|
let groups = group_palette(&[swatch([255, 0, 0], 10), swatch([252, 2, 1], 1)], 4);
|
||||||
|
let mut mappings = ColorMappings::default();
|
||||||
|
mappings.insert_group(&groups[0]);
|
||||||
|
mappings.0[0].target = [0, 0, 255];
|
||||||
|
assert_eq!(mappings.apply_rgba(&[252, 2, 1, 128]), [0, 0, 255, 128]);
|
||||||
|
mappings.insert([252, 2, 1], [0, 255, 0]);
|
||||||
|
assert_eq!(mappings.0[0].members, [[255, 0, 0]]);
|
||||||
|
assert_eq!(
|
||||||
|
mappings.apply_rgba(&[252, 2, 1, 255, 255, 0, 0, 255]),
|
||||||
|
[0, 255, 0, 255, 0, 0, 255, 255]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_matches_group_members_and_preserves_transparency() {
|
||||||
|
let group = ColorGroup {
|
||||||
|
rgb: [250, 0, 0],
|
||||||
|
count: 2,
|
||||||
|
members: vec![[250, 0, 0], [253, 2, 0]],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
group.highlight_rgba(&[253, 2, 0, 128, 0, 0, 252, 255, 250, 0, 0, 0]),
|
||||||
|
[255, 0, 255, 128, 0, 0, 63, 255, 250, 0, 0, 0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_palette_preserves_every_color_and_respects_tolerance() {
|
||||||
|
let palette: Vec<_> = (0..=255)
|
||||||
|
.flat_map(|r| (0..=255).step_by(8).map(move |g| swatch([r, g, 128], 1)))
|
||||||
|
.collect();
|
||||||
|
let groups = group_palette(&palette, 8);
|
||||||
|
let members: std::collections::HashSet<_> = groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|g| g.members.iter().copied())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(members.len(), palette.len());
|
||||||
|
assert_eq!(
|
||||||
|
groups.iter().map(|g| g.count).sum::<u64>(),
|
||||||
|
palette.len() as u64
|
||||||
|
);
|
||||||
|
for group in groups {
|
||||||
|
assert!(
|
||||||
|
group
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.all(|rgb| (0..3).all(|i| rgb[i].abs_diff(group.rgb[i]) <= 8))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacements_are_exact_simultaneous_and_preserve_alpha() {
|
||||||
|
let mut mappings = ColorMappings::default();
|
||||||
|
mappings.insert([255, 0, 0], [0, 255, 0]);
|
||||||
|
mappings.insert([0, 255, 0], [255, 0, 0]);
|
||||||
|
let source = [255, 0, 0, 255, 0, 255, 0, 128, 254, 0, 0, 255, 255, 0, 0, 0];
|
||||||
|
assert_eq!(
|
||||||
|
mappings.apply_rgba(&source),
|
||||||
|
[0, 255, 0, 255, 255, 0, 0, 128, 254, 0, 0, 255, 255, 0, 0, 0]
|
||||||
|
);
|
||||||
|
assert_eq!(ColorMappings::default().apply_rgba(&source), source);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! 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 adjustments;
|
||||||
|
pub mod mapping;
|
||||||
pub mod vision;
|
pub mod vision;
|
||||||
pub const LEVEL_BINS: usize = 20;
|
pub const LEVEL_BINS: usize = 20;
|
||||||
|
|
||||||
|
|||||||
+299
-13
@@ -3,6 +3,7 @@ use crate::{
|
|||||||
analysis::{
|
analysis::{
|
||||||
self, Analysis, Region,
|
self, Analysis, Region,
|
||||||
adjustments::Adjustments,
|
adjustments::Adjustments,
|
||||||
|
mapping::{ColorGroup, ColorMappings, group_palette},
|
||||||
vision::{self, Vision},
|
vision::{self, Vision},
|
||||||
},
|
},
|
||||||
capture::{self, Frame, SharedCapture, Status},
|
capture::{self, Frame, SharedCapture, Status},
|
||||||
@@ -22,6 +23,13 @@ pub struct WhosHueApp {
|
|||||||
simulated_texture: Option<egui::TextureHandle>,
|
simulated_texture: Option<egui::TextureHandle>,
|
||||||
vision_mode: Vision,
|
vision_mode: Vision,
|
||||||
adjustments: Adjustments,
|
adjustments: Adjustments,
|
||||||
|
mappings: ColorMappings,
|
||||||
|
group_colors: bool,
|
||||||
|
group_tolerance: u8,
|
||||||
|
palette_groups: Vec<ColorGroup>,
|
||||||
|
hovered_group: Option<usize>,
|
||||||
|
highlight_texture: Option<egui::TextureHandle>,
|
||||||
|
mappings_enabled: bool,
|
||||||
vision_panel: bool,
|
vision_panel: bool,
|
||||||
channel: ui::Channel,
|
channel: ui::Channel,
|
||||||
region: Option<Region>,
|
region: Option<Region>,
|
||||||
@@ -55,6 +63,13 @@ impl WhosHueApp {
|
|||||||
simulated_texture: None,
|
simulated_texture: None,
|
||||||
vision_mode: Vision::Original,
|
vision_mode: Vision::Original,
|
||||||
adjustments: Adjustments::default(),
|
adjustments: Adjustments::default(),
|
||||||
|
mappings: ColorMappings::default(),
|
||||||
|
group_colors: true,
|
||||||
|
group_tolerance: 8,
|
||||||
|
palette_groups: Vec::new(),
|
||||||
|
hovered_group: None,
|
||||||
|
highlight_texture: None,
|
||||||
|
mappings_enabled: true,
|
||||||
vision_panel: false,
|
vision_panel: false,
|
||||||
channel: ui::Channel::Hue,
|
channel: ui::Channel::Hue,
|
||||||
region: None,
|
region: None,
|
||||||
@@ -79,12 +94,15 @@ impl WhosHueApp {
|
|||||||
self.stop();
|
self.stop();
|
||||||
// A new selection always opens KDE's picker; never silently restore it.
|
// A new selection always opens KDE's picker; never silently restore it.
|
||||||
self.capture = Some(capture::spawn(None));
|
self.capture = Some(capture::spawn(None));
|
||||||
|
self.mappings = ColorMappings::default();
|
||||||
|
self.mappings_enabled = true;
|
||||||
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.simulated_texture = None;
|
||||||
self.region = None;
|
self.region = None;
|
||||||
self.analysis = Analysis::default();
|
self.analysis = Analysis::default();
|
||||||
|
self.rebuild_groups();
|
||||||
self.frozen = false;
|
self.frozen = false;
|
||||||
self.drag_origin = None;
|
self.drag_origin = None;
|
||||||
self.message.clear();
|
self.message.clear();
|
||||||
@@ -133,6 +151,7 @@ impl WhosHueApp {
|
|||||||
let region = self.region.unwrap_or_else(|| Region::full(frame));
|
let region = self.region.unwrap_or_else(|| Region::full(frame));
|
||||||
let pixels = region.crop(frame);
|
let pixels = region.crop(frame);
|
||||||
self.analysis = analysis::analyze(&pixels);
|
self.analysis = analysis::analyze(&pixels);
|
||||||
|
self.rebuild_groups();
|
||||||
Self::texture(
|
Self::texture(
|
||||||
ctx,
|
ctx,
|
||||||
&mut self.crop_texture,
|
&mut self.crop_texture,
|
||||||
@@ -145,14 +164,69 @@ impl WhosHueApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rebuild_groups(&mut self) {
|
||||||
|
self.palette_groups = group_palette(
|
||||||
|
&self.analysis.palette,
|
||||||
|
if self.group_colors {
|
||||||
|
self.group_tolerance
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.hovered_group = None;
|
||||||
|
self.highlight_texture = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_group(&mut self, ctx: &egui::Context, index: Option<usize>) {
|
||||||
|
if self.hovered_group == index {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.hovered_group = index;
|
||||||
|
self.highlight_texture = None;
|
||||||
|
if let Some(index) = index
|
||||||
|
&& let Some(frame) = &self.frame
|
||||||
|
{
|
||||||
|
let region = self.region.unwrap_or_else(|| Region::full(frame));
|
||||||
|
let pixels = self.palette_groups[index].highlight_rgba(®ion.crop(frame));
|
||||||
|
Self::texture(
|
||||||
|
ctx,
|
||||||
|
&mut self.highlight_texture,
|
||||||
|
"group-highlight",
|
||||||
|
region.width,
|
||||||
|
region.height,
|
||||||
|
&pixels,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_map(&self) -> bool {
|
||||||
|
self.frame.is_some() && self.frozen && (self.drag_origin.is_none() || self.was_frozen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mappings_active(&self) -> bool {
|
||||||
|
self.can_map() && self.mappings_enabled && !self.mappings.0.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mapped_crop(&self, region: Region, frame: &Frame) -> Vec<u8> {
|
||||||
|
let crop = region.crop(frame);
|
||||||
|
if self.mappings_active() {
|
||||||
|
self.mappings.apply_rgba(&crop)
|
||||||
|
} else {
|
||||||
|
crop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn refresh_simulation(&mut self, ctx: &egui::Context) {
|
fn refresh_simulation(&mut self, ctx: &egui::Context) {
|
||||||
if self.vision_mode == Vision::Original && self.adjustments == Adjustments::default() {
|
if self.vision_mode == Vision::Original
|
||||||
|
&& self.adjustments == Adjustments::default()
|
||||||
|
&& !self.mappings_active()
|
||||||
|
{
|
||||||
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 crop = region.crop(frame);
|
let crop = self.mapped_crop(region, frame);
|
||||||
let pixels = if self.vision_mode == Vision::Original {
|
let pixels = if self.vision_mode == Vision::Original {
|
||||||
self.adjustments.apply_rgba(&crop)
|
self.adjustments.apply_rgba(&crop)
|
||||||
} else {
|
} else {
|
||||||
@@ -179,6 +253,10 @@ impl WhosHueApp {
|
|||||||
}
|
}
|
||||||
self.stop();
|
self.stop();
|
||||||
self.region = None;
|
self.region = None;
|
||||||
|
self.drag_origin = None;
|
||||||
|
self.frozen = true;
|
||||||
|
self.mappings = ColorMappings::default();
|
||||||
|
self.mappings_enabled = true;
|
||||||
self.set_frame(
|
self.set_frame(
|
||||||
ctx,
|
ctx,
|
||||||
Frame {
|
Frame {
|
||||||
@@ -208,7 +286,7 @@ impl WhosHueApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn export(&mut self) {
|
fn export(&mut self, mapped: bool) {
|
||||||
if let Some(frame) = &self.frame {
|
if let Some(frame) = &self.frame {
|
||||||
let r = self.region.unwrap_or_else(|| Region::full(frame));
|
let r = self.region.unwrap_or_else(|| Region::full(frame));
|
||||||
// Unique output preserves earlier captures.
|
// Unique output preserves earlier captures.
|
||||||
@@ -219,7 +297,11 @@ impl WhosHueApp {
|
|||||||
let path = std::env::temp_dir().join(format!("whoshue-{stamp}.png"));
|
let path = std::env::temp_dir().join(format!("whoshue-{stamp}.png"));
|
||||||
self.message = match image::save_buffer(
|
self.message = match image::save_buffer(
|
||||||
&path,
|
&path,
|
||||||
&r.crop(frame),
|
&if mapped {
|
||||||
|
self.mapped_crop(r, frame)
|
||||||
|
} else {
|
||||||
|
r.crop(frame)
|
||||||
|
},
|
||||||
r.width,
|
r.width,
|
||||||
r.height,
|
r.height,
|
||||||
image::ColorType::Rgba8,
|
image::ColorType::Rgba8,
|
||||||
@@ -406,15 +488,22 @@ impl WhosHueApp {
|
|||||||
self.vision_mode.label()
|
self.vision_mode.label()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ui.small("Pixel values and PNG use original colors");
|
ui.small(if self.highlight_texture.is_some() {
|
||||||
|
"Highlighted group · matching pixels shown in magenta"
|
||||||
|
} else if self.mappings_active() {
|
||||||
|
"Mapped preview · pixel inspection and analysis use original colors"
|
||||||
|
} else {
|
||||||
|
"Pixel values and Save crop PNG use original colors"
|
||||||
|
});
|
||||||
self.crop_inspector(ui, region);
|
self.crop_inspector(ui, region);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn crop_inspector(&self, ui: &mut egui::Ui, region: Region) {
|
fn crop_inspector(&self, ui: &mut egui::Ui, region: Region) {
|
||||||
let Some(texture) = self
|
let Some(texture) = self
|
||||||
.simulated_texture
|
.highlight_texture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
.or(self.simulated_texture.as_ref())
|
||||||
.or(self.crop_texture.as_ref())
|
.or(self.crop_texture.as_ref())
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
@@ -448,7 +537,82 @@ impl WhosHueApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mapping_controls(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.heading("Color mapping");
|
||||||
|
let can_map = self.can_map();
|
||||||
|
if !can_map {
|
||||||
|
ui.small("Freeze the capture or open an image to map colors.");
|
||||||
|
}
|
||||||
|
let mut changed = false;
|
||||||
|
ui.add_enabled_ui(can_map, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
changed |= ui
|
||||||
|
.checkbox(&mut self.mappings_enabled, "Preview mappings")
|
||||||
|
.changed();
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
!self.mappings.0.is_empty(),
|
||||||
|
egui::Button::new("Reset mappings"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.mappings.0.clear();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.small("Choose Map beside a palette color or group.");
|
||||||
|
let mut remove = None;
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("mappings")
|
||||||
|
.max_height(150.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for (index, entry) in self.mappings.0.iter_mut().enumerate() {
|
||||||
|
let source = entry.source;
|
||||||
|
let target = &mut entry.target;
|
||||||
|
ui.push_id(index, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let [r, g, b] = source;
|
||||||
|
ui.colored_label(Color32::from_rgb(r, g, b), "■");
|
||||||
|
ui.monospace(format!("#{r:02X}{g:02X}{b:02X} →"));
|
||||||
|
changed |= ui.color_edit_button_srgb(target).on_hover_text(format!("Replaces {} original shades. Group membership stays fixed when tolerance changes.", entry.members.len())).changed();
|
||||||
|
let [r, g, b] = *target;
|
||||||
|
ui.monospace(format!("#{r:02X}{g:02X}{b:02X}"));
|
||||||
|
if ui
|
||||||
|
.small_button("×")
|
||||||
|
.on_hover_text("Remove mapping")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
remove = Some(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(source) = remove {
|
||||||
|
self.mappings.0.remove(source);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
self.mappings_active(),
|
||||||
|
egui::Button::new("Save mapped crop PNG"),
|
||||||
|
)
|
||||||
|
.on_hover_text(
|
||||||
|
"Save exact color replacements, without preview filters or adjustments.",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.export(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if changed {
|
||||||
|
self.refresh_simulation(ui.ctx());
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
|
||||||
fn sidebar(&mut self, ui: &mut egui::Ui) {
|
fn sidebar(&mut self, ui: &mut egui::Ui) {
|
||||||
|
self.mapping_controls(ui);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.selectable_value(&mut self.vision_panel, false, "Color analysis");
|
ui.selectable_value(&mut self.vision_panel, false, "Color analysis");
|
||||||
let count: usize = self
|
let count: usize = self
|
||||||
@@ -466,6 +630,7 @@ impl WhosHueApp {
|
|||||||
});
|
});
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if self.vision_panel {
|
if self.vision_panel {
|
||||||
|
self.highlight_group(ui.ctx(), None);
|
||||||
let old_mode = self.vision_mode;
|
let old_mode = self.vision_mode;
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt("vision_warnings")
|
.id_salt("vision_warnings")
|
||||||
@@ -496,22 +661,37 @@ impl WhosHueApp {
|
|||||||
}
|
}
|
||||||
ui.small(self.channel.description());
|
ui.small(self.channel.description());
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.heading("Exact palette");
|
ui.heading("Palette");
|
||||||
|
let mut regroup = ui
|
||||||
|
.checkbox(&mut self.group_colors, "Group similar colors")
|
||||||
|
.changed();
|
||||||
|
if self.group_colors {
|
||||||
|
regroup |= ui.add(egui::Slider::new(&mut self.group_tolerance, 0..=32).text("Tolerance"))
|
||||||
|
.on_hover_text("Maximum difference in each RGB channel from the group's most frequent color. Zero keeps every color separate.").changed();
|
||||||
|
}
|
||||||
|
if regroup {
|
||||||
|
self.rebuild_groups();
|
||||||
|
}
|
||||||
|
ui.small("Hover a row to highlight matching pixels. Existing mappings keep their original shades.");
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
"{} colors · {} nontransparent pixels",
|
"{} groups / {} colors · {} pixels",
|
||||||
|
self.palette_groups.len(),
|
||||||
self.analysis.palette.len(),
|
self.analysis.palette.len(),
|
||||||
self.analysis.pixels
|
self.analysis.pixels
|
||||||
));
|
));
|
||||||
ui.small("Most frequent first · click a swatch to copy hex");
|
ui.small("Most frequent first · click a swatch to copy hex");
|
||||||
|
let can_map = self.can_map();
|
||||||
|
let mut map_color = None;
|
||||||
|
let mut hovered = None;
|
||||||
// Virtual rows keep large true-color captures responsive.
|
// Virtual rows keep large true-color captures responsive.
|
||||||
egui::ScrollArea::vertical().id_salt("palette").show_rows(
|
egui::ScrollArea::vertical().id_salt("palette").show_rows(
|
||||||
ui,
|
ui,
|
||||||
24.0,
|
24.0,
|
||||||
self.analysis.palette.len(),
|
self.palette_groups.len(),
|
||||||
|ui, rows| {
|
|ui, rows| {
|
||||||
for index in rows {
|
for index in rows {
|
||||||
let swatch = &self.analysis.palette[index];
|
let swatch = &self.palette_groups[index];
|
||||||
ui.horizontal(|ui| {
|
let row = ui.horizontal(|ui| {
|
||||||
let [r, g, b] = swatch.rgb;
|
let [r, g, b] = swatch.rgb;
|
||||||
let hex = format!("#{r:02X}{g:02X}{b:02X}");
|
let hex = format!("#{r:02X}{g:02X}{b:02X}");
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
@@ -522,10 +702,16 @@ impl WhosHueApp {
|
|||||||
ui.ctx().copy_text(hex.clone());
|
ui.ctx().copy_text(hex.clone());
|
||||||
}
|
}
|
||||||
ui.monospace(&hex);
|
ui.monospace(&hex);
|
||||||
|
let already_mapped = self.mappings.0.iter().any(|entry| entry.members == swatch.members);
|
||||||
|
if ui.add_enabled(can_map && !already_mapped, egui::Button::new(if already_mapped { "Mapped" } else { "Map" }))
|
||||||
|
.on_hover_text("Map every shade in this group. Overlapping colors move to this mapping.").clicked() {
|
||||||
|
map_color = Some(index);
|
||||||
|
}
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
"{:.1}%",
|
"{:.2}%",
|
||||||
100.0 * swatch.count as f64 / self.analysis.pixels.max(1) as f64
|
100.0 * swatch.count as f64 / self.analysis.pixels.max(1) as f64
|
||||||
));
|
));
|
||||||
|
ui.small(format!("{} shades", swatch.members.len()));
|
||||||
let [h, s, v] = analysis::hsv(swatch.rgb);
|
let [h, s, v] = analysis::hsv(swatch.rgb);
|
||||||
response.on_hover_text(format!(
|
response.on_hover_text(format!(
|
||||||
"RGB {r}, {g}, {b}\nHSV {h:.1}°, {:.1}%, {:.1}%\n{} pixels",
|
"RGB {r}, {g}, {b}\nHSV {h:.1}°, {:.1}%, {:.1}%\n{} pixels",
|
||||||
@@ -534,9 +720,18 @@ impl WhosHueApp {
|
|||||||
swatch.count
|
swatch.count
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
if row.response.contains_pointer() {
|
||||||
|
hovered = Some(index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
self.highlight_group(ui.ctx(), hovered);
|
||||||
|
if let Some(index) = map_color {
|
||||||
|
self.mappings.insert_group(&self.palette_groups[index]);
|
||||||
|
self.mappings_enabled = true;
|
||||||
|
self.refresh_simulation(ui.ctx());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,16 +785,18 @@ impl eframe::App for WhosHueApp {
|
|||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
self.frozen = !self.frozen;
|
self.frozen = !self.frozen;
|
||||||
|
self.refresh_simulation(&ctx);
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
.add_enabled(has_frame, egui::Button::new("Save crop PNG"))
|
.add_enabled(has_frame, egui::Button::new("Save crop PNG"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
self.export();
|
self.export(false);
|
||||||
}
|
}
|
||||||
if self.capture.is_some() && ui.button("Stop sharing").clicked() {
|
if self.capture.is_some() && ui.button("Stop sharing").clicked() {
|
||||||
self.stop();
|
self.stop();
|
||||||
self.frozen = true;
|
self.frozen = true;
|
||||||
|
self.refresh_simulation(&ctx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Some(capture) = &self.capture {
|
if let Some(capture) = &self.capture {
|
||||||
@@ -649,6 +846,91 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grouped_preview_and_highlight_reset_when_recropping() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = WhosHueApp::empty(Config::default());
|
||||||
|
app.frozen = true;
|
||||||
|
app.set_frame(
|
||||||
|
&ctx,
|
||||||
|
Frame {
|
||||||
|
seq: 1,
|
||||||
|
width: 3,
|
||||||
|
height: 1,
|
||||||
|
rgba: vec![250, 0, 0, 255, 253, 2, 0, 128, 0, 0, 255, 255],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let index = app
|
||||||
|
.palette_groups
|
||||||
|
.iter()
|
||||||
|
.position(|g| g.members.len() == 2)
|
||||||
|
.unwrap();
|
||||||
|
app.mappings.insert_group(&app.palette_groups[index]);
|
||||||
|
app.mappings.0[0].target = [0, 255, 0];
|
||||||
|
app.highlight_group(&ctx, Some(index));
|
||||||
|
assert_eq!(app.highlight_texture.as_ref().unwrap().size(), [3, 1]);
|
||||||
|
app.group_tolerance = 0;
|
||||||
|
app.rebuild_groups();
|
||||||
|
assert!(app.highlight_texture.is_none());
|
||||||
|
assert_eq!(app.palette_groups.len(), 3);
|
||||||
|
app.region = Some(Region {
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
});
|
||||||
|
app.analyze(&ctx);
|
||||||
|
assert_eq!(
|
||||||
|
app.mapped_crop(app.region.unwrap(), app.frame.as_ref().unwrap()),
|
||||||
|
[0, 255, 0, 128]
|
||||||
|
);
|
||||||
|
assert_eq!(app.analysis.palette[0].rgb, [253, 2, 0]);
|
||||||
|
app.highlight_group(&ctx, Some(0));
|
||||||
|
app.highlight_group(&ctx, None);
|
||||||
|
assert!(app.highlight_texture.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mappings_follow_crop_and_pause_when_live_without_changing_source() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = WhosHueApp::empty(Config::default());
|
||||||
|
app.set_frame(&ctx, sample(1, 4));
|
||||||
|
app.frozen = true;
|
||||||
|
app.mappings.insert([255, 0, 0], [0, 0, 255]);
|
||||||
|
app.region = Some(Region {
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
});
|
||||||
|
app.analyze(&ctx);
|
||||||
|
let region = app.region.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
app.mapped_crop(region, app.frame.as_ref().unwrap()),
|
||||||
|
[0, 0, 255, 255]
|
||||||
|
);
|
||||||
|
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.mappings_enabled = false;
|
||||||
|
app.refresh_simulation(&ctx);
|
||||||
|
assert!(app.simulated_texture.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
app.mapped_crop(region, app.frame.as_ref().unwrap()),
|
||||||
|
[255, 0, 0, 255]
|
||||||
|
);
|
||||||
|
app.mappings_enabled = true;
|
||||||
|
app.frozen = false;
|
||||||
|
app.refresh_simulation(&ctx);
|
||||||
|
assert!(app.simulated_texture.is_none());
|
||||||
|
app.frozen = true;
|
||||||
|
app.refresh_simulation(&ctx);
|
||||||
|
assert!(app.simulated_texture.is_some());
|
||||||
|
app.mappings.0.clear();
|
||||||
|
app.refresh_simulation(&ctx);
|
||||||
|
assert!(app.simulated_texture.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn frozen_frame_survives_updates_and_resize_resets_crop() {
|
fn frozen_frame_survives_updates_and_resize_resets_crop() {
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
@@ -715,6 +997,7 @@ mod tests {
|
|||||||
.as_nanos();
|
.as_nanos();
|
||||||
let path = std::env::temp_dir().join(format!("whoshue picker test ü {stamp}.png"));
|
let path = std::env::temp_dir().join(format!("whoshue picker test ü {stamp}.png"));
|
||||||
image::save_buffer(&path, &[0, 255, 0, 255], 1, 1, image::ColorType::Rgba8).unwrap();
|
image::save_buffer(&path, &[0, 255, 0, 255], 1, 1, image::ColorType::Rgba8).unwrap();
|
||||||
|
app.mappings.insert([255, 0, 0], [0, 0, 255]);
|
||||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
app.file_picker = Some(receiver);
|
app.file_picker = Some(receiver);
|
||||||
sender.send(Ok(Some(path.clone()))).unwrap();
|
sender.send(Ok(Some(path.clone()))).unwrap();
|
||||||
@@ -724,6 +1007,7 @@ mod tests {
|
|||||||
assert!(shared.lock().unwrap().stop);
|
assert!(shared.lock().unwrap().stop);
|
||||||
assert!(app.capture.is_none());
|
assert!(app.capture.is_none());
|
||||||
assert!(app.frozen);
|
assert!(app.frozen);
|
||||||
|
assert!(app.mappings.0.is_empty());
|
||||||
assert_eq!(app.frame.as_ref().unwrap().rgba, [0, 255, 0, 255]);
|
assert_eq!(app.frame.as_ref().unwrap().rgba, [0, 255, 0, 255]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,6 +1077,8 @@ mod tests {
|
|||||||
rgba: vec![255, 0, 0, 255, 0, 173, 0, 255],
|
rgba: vec![255, 0, 0, 255, 0, 173, 0, 255],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
app.frozen = true;
|
||||||
|
app.mappings.insert([255, 0, 0], [0, 0, 255]);
|
||||||
app.adjustments.hue = 45.0;
|
app.adjustments.hue = 45.0;
|
||||||
for mode in [Vision::Original, Vision::Invert]
|
for mode in [Vision::Original, Vision::Invert]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
Reference in New Issue
Block a user