//! 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 { 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 = 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 { 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); 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 lookup(&self) -> HashMap<[u8; 3], [u8; 3]> { self.0 .iter() .flat_map(|entry| entry.members.iter().map(move |&rgb| (rgb, entry.target))) .collect() } pub fn apply_rgba(&self, rgba: &[u8]) -> Vec { let lookup = self.lookup(); 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::(), 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::(), 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); } }