Add color mapping

This commit is contained in:
Schluffe
2026-09-30 23:13:20 +02:00
parent 67fb971175
commit 3b72cd5723
4 changed files with 568 additions and 13 deletions
+29
View File
@@ -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:
+239
View File
@@ -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
View File
@@ -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
View File
@@ -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(&region.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()