//! Analysis always uses source pixels, never the scaled preview. use std::collections::HashMap; pub mod vision; pub const LEVEL_BINS: usize = 20; #[derive(Default)] pub struct Distribution { pub pixels: [u64; LEVEL_BINS], pub colors: [u64; LEVEL_BINS], pub pixel_sum: f64, pub color_sum: f64, } impl Distribution { fn add(&mut self, value: f64, count: u64) { let bin = ((value.clamp(0.0, 1.0) * LEVEL_BINS as f64) as usize).min(LEVEL_BINS - 1); self.pixels[bin] += count; self.colors[bin] += 1; self.pixel_sum += value * count as f64; self.color_sum += value; } } 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 opaque_count: u64, } pub struct Analysis { pub saturation: Distribution, pub brightness: Distribution, pub luminance: Distribution, pub vision: vision::Report, 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 { saturation: Distribution::default(), brightness: Distribution::default(), luminance: Distribution::default(), vision: vision::Report::default(), 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, u64)>::new(); for pixel in rgba.as_chunks::<4>().0 { // Ignore fully transparent pixels; count other captured RGB values exactly. if pixel[3] != 0 { let entry = counts.entry([pixel[0], pixel[1], pixel[2]]).or_default(); entry.0 += 1; entry.1 += u64::from(pixel[3] == 255); } } let mut result = Analysis::default(); for (rgb, (count, opaque_count)) in counts { result.pixels += count; let [h, s, v] = hsv(rgb); result.saturation.add(s as f64, count); result.brightness.add(v as f64, count); result.luminance.add(vision::luminance(rgb), count); 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, opaque_count, }); } result .palette .sort_unstable_by(|a, b| b.count.cmp(&a.count).then(a.rgb.cmp(&b.rgb))); result.vision = vision::check(&result.palette); 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 saturation_value_and_luminance_include_endpoints_with_both_weights() { let a = analyze(&[ 0, 0, 0, 255, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 0, 255, 128, 128, 128, 255, 0, 255, 0, 0, ]); for d in [&a.saturation, &a.brightness, &a.luminance] { assert_eq!(d.pixels.iter().sum::(), 5); assert_eq!(d.colors.iter().sum::(), 4); } assert_eq!(a.saturation.pixels[0], 3); assert_eq!(a.saturation.pixels[19], 2); assert_eq!(a.saturation.colors[19], 1); assert_eq!(a.brightness.pixels[0], 1); assert_eq!(a.brightness.pixels[19], 3); assert_eq!(a.luminance.pixels[19], 1); assert!((a.saturation.pixel_sum / 5.0 - 0.4).abs() < 1e-9); assert!((a.saturation.color_sum / 4.0 - 0.25).abs() < 1e-9); let empty = analyze(&[]); assert_eq!(empty.brightness.pixel_sum, 0.0); assert_eq!(empty.vision.colors_checked, 0); } #[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]); } }