//! Analysis always uses source pixels, never the scaled preview. use std::collections::HashMap; use crate::capture::Frame; pub const HUE_BINS: usize = 36; pub const NEUTRAL_SATURATION: f32 = 0.05; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Region { pub x: u32, pub y: u32, pub width: u32, pub height: u32, } impl Region { pub fn full(frame: &Frame) -> Self { Self { x: 0, y: 0, width: frame.width, height: frame.height, } } pub fn crop(self, frame: &Frame) -> Vec { let mut pixels = Vec::new(); for y in self.y..self.y.saturating_add(self.height).min(frame.height) { let start = ((y as usize * frame.width as usize) + self.x.min(frame.width) as usize) * 4; let end = ((y as usize * frame.width as usize) + self.x.saturating_add(self.width).min(frame.width) as usize) * 4; pixels.extend_from_slice(&frame.rgba[start..end]); } pixels } } #[derive(Clone, Debug)] pub struct Swatch { pub rgb: [u8; 3], pub count: u64, } pub struct Analysis { pub palette: Vec, pub pixels: u64, pub neutral_pixels: u64, pub neutral_colors: u64, pub hue_pixels: [u64; HUE_BINS], pub hue_colors: [u64; HUE_BINS], } impl Default for Analysis { fn default() -> Self { Self { palette: Vec::new(), pixels: 0, neutral_pixels: 0, neutral_colors: 0, hue_pixels: [0; HUE_BINS], hue_colors: [0; HUE_BINS], } } } /// HSV hue in degrees, saturation and value in 0..=1. pub fn hsv(rgb: [u8; 3]) -> [f32; 3] { let [r, g, b] = rgb.map(|v| v as f32 / 255.0); let max = r.max(g).max(b); let min = r.min(g).min(b); let delta = max - min; if delta == 0.0 { return [0.0, 0.0, max]; } let hue = if max == r { ((g - b) / delta).rem_euclid(6.0) } else if max == g { (b - r) / delta + 2.0 } else { (r - g) / delta + 4.0 }; [hue * 60.0, delta / max, max] } pub fn analyze(rgba: &[u8]) -> Analysis { let mut counts = HashMap::<[u8; 3], u64>::new(); for pixel in rgba.as_chunks::<4>().0 { // Ignore fully transparent pixels; count other captured RGB values exactly. if pixel[3] != 0 { *counts.entry([pixel[0], pixel[1], pixel[2]]).or_default() += 1; } } let mut result = Analysis::default(); for (rgb, count) in counts { result.pixels += count; let [h, s, _] = hsv(rgb); if s < NEUTRAL_SATURATION { result.neutral_pixels += count; result.neutral_colors += 1; } else { let bin = (h / 10.0).floor() as usize % HUE_BINS; result.hue_pixels[bin] += count; result.hue_colors[bin] += 1; } result.palette.push(Swatch { rgb, count }); } result .palette .sort_unstable_by(|a, b| b.count.cmp(&a.count).then(a.rgb.cmp(&b.rgb))); result } #[cfg(test)] mod tests { use super::*; #[test] fn primary_hues_and_wraparound() { assert_eq!(hsv([255, 0, 0]), [0.0, 1.0, 1.0]); assert_eq!(hsv([0, 255, 0])[0], 120.0); assert_eq!(hsv([0, 0, 255])[0], 240.0); assert!(hsv([255, 0, 1])[0] > 359.0); assert_eq!(hsv([0, 0, 0]), [0.0, 0.0, 0.0]); } #[test] fn frequency_distinct_weights_neutrals_and_transparency() { let data = [ 255, 0, 0, 255, 255, 0, 0, 255, 128, 0, 0, 255, 0, 255, 0, 255, 128, 128, 128, 255, 255, 0, 255, 0, ]; let a = analyze(&data); assert_eq!(a.pixels, 5); assert_eq!(a.palette.len(), 4); assert_eq!(a.palette[0].rgb, [255, 0, 0]); assert_eq!(a.hue_pixels[0], 3); assert_eq!(a.hue_colors[0], 2); assert_eq!(a.neutral_pixels, 1); assert_eq!( a.hue_pixels.iter().sum::() + a.neutral_pixels, a.pixels ); } #[test] fn crop_uses_source_coordinates_and_clamps() { let frame = Frame { seq: 0, width: 3, height: 2, rgba: (0..24).collect(), }; let r = Region { x: 1, y: 0, width: 1, height: 2, }; assert_eq!(r.crop(&frame), [4, 5, 6, 7, 16, 17, 18, 19]); let edge = Region { x: 2, y: 1, width: 10, height: 10, }; assert_eq!(edge.crop(&frame), [20, 21, 22, 23]); } }