diff --git a/Cargo.lock b/Cargo.lock index 827513b..c3fb0dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,13 +157,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3" [[package]] -name = "gif" -version = "0.10.3" +name = "float-ord" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471d90201b3b223f3451cd4ad53e34295f16a1df17b1edf3736d47761c3981af" +checksum = "7bad48618fdb549078c333a7a8528acb57af271d0433bdecd523eb620628364e" + +[[package]] +name = "gif" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02efba560f227847cb41463a7395c514d127d4f74fff12ef0137fff1b84b96c4" dependencies = [ "color_quant", - "lzw", + "weezl", ] [[package]] @@ -186,7 +192,7 @@ dependencies = [ [[package]] name = "image" -version = "0.23.6" +version = "0.23.10" dependencies = [ "bytemuck", "byteorder", @@ -317,6 +323,7 @@ name = "perler" version = "0.1.0" dependencies = [ "anyhow", + "float-ord", "image", "lazy_static", "log", @@ -547,6 +554,12 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" +[[package]] +name = "weezl" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2bb9fc8309084dd7cd651336673844c1d47f8ef6d2091ec160b27f5c4aa277" + [[package]] name = "winapi" version = "0.3.8" diff --git a/Cargo.toml b/Cargo.toml index 5adff5d..fe2cd9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ thiserror = "1.0.20" anyhow = "1.0.31" #x11colors = {git="https://git.z.xinu.tv/wathiede/x11colors"} x11colors = {path="../../xinu.tv/x11colors"} +float-ord = "0.2.0" [dev-dependencies] lazy_static = "1.4.0" diff --git a/src/bin/debugger.rs b/src/bin/debugger.rs index 127c932..9ed477c 100644 --- a/src/bin/debugger.rs +++ b/src/bin/debugger.rs @@ -4,16 +4,29 @@ use std::io::Write; use std::path::PathBuf; use anyhow::Result; +use float_ord::FloatOrd; use image::imageops::colorops::{index_colors, ColorMap}; use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer, Rgba}; use structopt::StructOpt; use x11colors::COLORS; -use perler::NearestColor; +use perler::{rgba_to_hsv, NearestColorCIELAB, NearestColorHSV, NearestColorRGB}; #[derive(Debug, StructOpt)] enum Command { - Nearest { + NearestRGB { + /// Input image file. + input: PathBuf, + /// Output of processed image. + output: PathBuf, + }, + NearestHSV { + /// Input image file. + input: PathBuf, + /// Output of processed image. + output: PathBuf, + }, + NearestCIELAB { /// Input image file. input: PathBuf, /// Output of processed image. @@ -23,6 +36,10 @@ enum Command { /// Output of processed image. output: PathBuf, }, + GeneratePalette { + /// Output of processed image. + output: PathBuf, + }, } /// Convert image to SVG in the style of perler instructions. @@ -76,6 +93,27 @@ fn print_stats(img: &DynamicImage) { } } +fn generate_palette(output: PathBuf) -> Result<()> { + let w = 256; + let stripe_h = 8; + let h = COLORS.len() as u32 * stripe_h; + let mut img = DynamicImage::new_rgba8(w, h); + let mut colors: Vec<_> = COLORS.iter().collect(); + colors.sort_by_key(|[r, g, b]| { + let [h, s, v] = rgba_to_hsv(&[*r, *g, *b, 255].into()); + [FloatOrd(s), FloatOrd(h), FloatOrd(v)] + }); + for (y, c) in colors.iter().enumerate() { + for x in 0..w { + for s in 0..stripe_h { + img.put_pixel(x, stripe_h * y as u32 + s, [c[0], c[1], c[2], 255].into()); + } + } + } + img.save(output)?; + Ok(()) +} + fn generate(output: PathBuf) -> Result<()> { let w = 256; let h = 300; @@ -94,7 +132,13 @@ fn generate(output: PathBuf) -> Result<()> { Ok(()) } -fn nearest(input: PathBuf, output: PathBuf) -> Result<()> { +enum NearestType { + RGB, + HSV, + CIELAB, +} + +fn nearest(input: PathBuf, output: PathBuf, n_type: NearestType) -> Result<()> { let img = image::open(input)?; let (w, h) = &img.dimensions(); println!("Before:"); @@ -106,9 +150,16 @@ fn nearest(input: PathBuf, output: PathBuf) -> Result<()> { .cloned() .map(|[r, g, b]| [r, g, b, 255].into()) .collect(); - palette.sort_by_key(|Rgba([r, g, b, a])| r << 24 | g << 16 | b << 8 | a); - let cmap = NearestColor::new(palette); - let pal = index_colors(&mut img2, &cmap); + palette.sort_by_key(|Rgba([r, g, b, a])| { + (*r as u32) << 24 | (*g as u32) << 16 | (*b as u32) << 8 | *a as u32 + }); + let cmap: Box>> = match n_type { + NearestType::RGB => Box::new(NearestColorRGB::new(palette)), + NearestType::HSV => Box::new(NearestColorHSV::new(palette)), + NearestType::CIELAB => Box::new(NearestColorCIELAB::new(palette)), + }; + + let pal = index_colors(&mut img2, cmap.as_ref()); let out_buf = ImageBuffer::from_fn(*w, *h, |x, y| -> Rgba { let p = pal.get_pixel(x, y); cmap.lookup(p.0[0] as usize) @@ -128,7 +179,10 @@ fn main() -> Result<()> { let opt = Opt::from_args(); match opt.cmd { - Command::Nearest { input, output } => nearest(input, output), + Command::NearestRGB { input, output } => nearest(input, output, NearestType::RGB), + Command::NearestHSV { input, output } => nearest(input, output, NearestType::HSV), + Command::NearestCIELAB { input, output } => nearest(input, output, NearestType::CIELAB), Command::Generate { output } => generate(output), + Command::GeneratePalette { output } => generate_palette(output), } } diff --git a/src/lib.rs b/src/lib.rs index 4fe177e..aa5bcc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,7 @@ -use std::collections::HashMap; - +use float_ord::FloatOrd; use image::imageops::colorops::ColorMap; -use image::Rgba; use image::{DynamicImage, GenericImage, GenericImageView}; +use image::{Rgb, Rgba}; use log::debug; use svg::node::element::Group; use svg::node::element::{Circle, Rectangle}; @@ -21,22 +20,187 @@ pub enum PerlerError { }, } -#[derive(Default, Debug)] -pub struct NearestColor { - palette: Vec>, - color_cache: HashMap, usize>, +/// Convert tristimulus values in the XYZ color space (as defined by CIE) matching the human eye's +/// response to RGB values in the sRGB color space. +/// Values are nominally in the range [0..1]. +pub fn xyz_to_rgb(xyz: [f32; 3]) -> [f32; 3] { + [ + 3.240479 * xyz[0] - 1.537150 * xyz[1] - 0.498535 * xyz[2], + -0.969256 * xyz[0] + 1.875991 * xyz[1] + 0.041556 * xyz[2], + 0.055648 * xyz[0] - 0.204043 * xyz[1] + 1.057311 * xyz[2], + ] } -impl NearestColor { - pub fn new(palette: Vec>) -> NearestColor { - NearestColor { - palette, - color_cache: HashMap::new(), - } +/// From https://en.wikipedia.org/wiki/CIELAB_color_space#CIELAB%E2%80%93CIEXYZ_conversions +pub fn xyz_to_cielab(xyz: [f32; 3]) -> [f32; 3] { + todo!(); +} + +/// From https://en.wikipedia.org/wiki/CIELAB_color_space#CIELAB%E2%80%93CIEXYZ_conversions +pub fn cielab_to_xyz(cielab: [f32; 3]) -> [f32; 3] { + todo!(); +} + +/// Convert tristimulus values in the sRGB color space values to the XYZ color space (as defined by +/// CIE) matching the human eye's response. +/// Values are nominally in the range [0..1]. +pub fn rgb_to_xyz(rgb: [f32; 3]) -> [f32; 3] { + [ + 0.412453 * rgb[0] + 0.357580 * rgb[1] + 0.180423 * rgb[2], + 0.212671 * rgb[0] + 0.715160 * rgb[1] + 0.072169 * rgb[2], + 0.019334 * rgb[0] + 0.119193 * rgb[1] + 0.950227 * rgb[2], + ] +} + +// From https://www.rapidtables.com/convert/color/rgb-to-hsv.html +pub fn rgba_to_hsv(rgb: &Rgba) -> [f32; 3] { + let [r, g, b, _] = rgb.0; + let [f_r, f_g, f_b] = [r as f32 / 255., g as f32 / 255., b as f32 / 255.]; + + let c_max = *[r, g, b].iter().max().unwrap(); + let f_c_max = c_max as f32 / 255.; + let c_min = *[r, g, b].iter().min().unwrap(); + let f_c_min = c_min as f32 / 255.; + let delta = c_max - c_min; + let f_delta = f_c_max - f_c_min; + //dbg!((r, g, b, c_max, c_min, delta)); + //dbg!((f_r, f_g, f_b, f_c_max, f_c_min, f_delta)); + let mut h = if delta == 0 { + 0. + } else if c_max == r { + 60. * (((f_g - f_b) / f_delta) % 6.) + } else if c_max == g { + 60. * ((f_b - f_r) / f_delta + 2.) + } else if c_max == b { + 60. * ((f_r - f_g) / f_delta + 4.) + } else { + unreachable!("c_max value not equal to r, g, or b"); + }; + if h < 0. { + h += 360.; + }; + + let s = match c_max { + 0 => 0., + _ => f_delta / f_c_max, + }; + let v = f_c_max; + //dbg!(h, s, v); + [h / 360., s, v] +} + +// HSV values in [0..1] +// returns [r, g, b] values from 0 to 255 +//From https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/ +pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Rgb { + let h_i = (h * 6.) as i32; + let f = h * 6. - h_i as f32; + let p = v * (1. - s); + let q = v * (1. - f * s); + let t = v * (1. - (1. - f) * s); + let [r, g, b] = match h_i { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + 5 => [v, p, q], + _ => panic!(format!("Unknown H value {}", h_i)), + }; + [(r * 255.) as u8, (g * 255.) as u8, (b * 255.) as u8].into() +} + +#[derive(Default, Debug)] +pub struct NearestColorCIELAB { + palette: Vec>, + cielab: Vec<[f32; 3]>, +} + +impl NearestColorCIELAB { + pub fn new(palette: Vec>) -> NearestColorCIELAB { + let cielab = palette + .iter() + .map(|rgba| { + let [r, g, b, _] = rgba.0; + xyz_to_cielab(rgb_to_xyz([ + r as f32 / 255., + g as f32 / 255., + b as f32 / 255., + ])) + }) + .collect(); + NearestColorCIELAB { palette, cielab } } } -impl ColorMap for NearestColor { +impl ColorMap for NearestColorCIELAB { + type Color = Rgba; + fn index_of(&self, color: &Self::Color) -> usize { + todo!(); + } + fn map_color(&self, color: &mut Self::Color) { + *color = self.palette[self.index_of(color)]; + } + + fn lookup(&self, idx: usize) -> Option { + self.palette.get(idx).cloned() + } +} + +#[derive(Default, Debug)] +pub struct NearestColorHSV { + palette: Vec>, + hsv: Vec<[f32; 3]>, +} + +impl NearestColorHSV { + pub fn new(palette: Vec>) -> NearestColorHSV { + let hsv = palette.iter().map(|rgba| rgba_to_hsv(rgba)).collect(); + NearestColorHSV { palette, hsv } + } +} + +impl ColorMap for NearestColorHSV { + type Color = Rgba; + fn index_of(&self, color: &Self::Color) -> usize { + let c = rgba_to_hsv(color); + let mut min_idx = usize::MAX; + let mut min_dist = f32::MAX; + for (idx, hsv) in self.hsv.iter().enumerate() { + let [h1, s1, v1] = hsv; + let [h2, s2, v2] = c; + //dbg!((h1, s1, v1), (h2, s2, v2)); + let dist = (h1 - h2).abs().min(1. - (h1 - h2).abs()); + + //dbg!((dist, min_dist, idx, min_idx)); + if dist < min_dist { + min_dist = dist; + min_idx = idx; + } + } + min_idx + } + fn map_color(&self, color: &mut Self::Color) { + *color = self.palette[self.index_of(color)]; + } + + fn lookup(&self, idx: usize) -> Option { + self.palette.get(idx).cloned() + } +} + +#[derive(Default, Debug)] +pub struct NearestColorRGB { + palette: Vec>, +} + +impl NearestColorRGB { + pub fn new(palette: Vec>) -> NearestColorRGB { + NearestColorRGB { palette } + } +} + +impl ColorMap for NearestColorRGB { type Color = Rgba; fn index_of(&self, color: &Self::Color) -> usize { @@ -334,11 +498,52 @@ mod tests { .into_iter() .map(|rgb| rgb.into()) .collect(); - let cmap = NearestColor::new(palette); + let cmap = NearestColorRGB::new(palette); let got = cmap.index_of(&[255, 10, 10, 255].into()); assert_eq!(got, 2); let got = cmap.lookup(2); assert_eq!(got, Some([255, 0, 0, 255].into())); } + + const COLOR_DATA: [(&str, [u8; 3], [f32; 3]); 16] = [ + // Name, [R,G,B], [H,S,V] + ("Black", [0, 0, 0], [0., 0., 0.]), + ("White", [255, 255, 255], [0., 0., 1.]), + ("Red", [255, 0, 0], [0., 1., 1.]), + ("Lime", [0, 255, 0], [120., 1., 1.]), + ("Blue", [0, 0, 255], [240., 1., 1.]), + ("Yellow", [255, 255, 0], [60., 1., 1.]), + ("Cyan", [0, 255, 255], [180., 1., 1.]), + ("Magenta", [255, 0, 255], [300., 1., 1.]), + ("Silver", [191, 191, 191], [0., 0., 0.7490196]), + ("Gray", [128, 128, 128], [0., 0., 0.5019608]), + ("Maroon", [128, 0, 0], [0., 1., 0.5019608]), + ("Olive", [128, 128, 0], [60., 1., 0.5019608]), + ("Green", [0, 128, 0], [120., 1., 0.5019608]), + ("Purple", [128, 0, 128], [300., 1., 0.5019608]), + ("Teal", [0, 128, 128], [180., 1., 0.5019608]), + ("Navy", [0, 0, 128], [240., 1., 0.5019608]), + ]; + + #[test] + fn color_roundtrip() { + for (name, rgb, hsv) in COLOR_DATA.iter() { + let rgba: Rgba = [rgb[0], rgb[1], rgb[2], 255].into(); + let got = rgba_to_hsv(&rgba); + let [h, s, v] = *hsv; + assert_eq!( + got, + [h / 360., s, v], + "rgba_to_hsv failed for {} ({:?})", + name, + rgb + ); + + let rgb: Rgb = Rgb(*rgb); + let [h, s, v] = *hsv; + let got = hsv_to_rgb(h / 360., s, v); + assert_eq!(got, rgb, "hsv_to_rgb failed for {} ({:?})", name, hsv); + } + } }