diff --git a/Cargo.lock b/Cargo.lock index 8c6f178..0a6bf38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,6 @@ dependencies = [ "palette", "rayon", "tempfile", - "thiserror", "tracing", "tracing-subscriber", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index 2deb827..31e2fc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ image = "0.25.10" indicatif = "0.18.4" palette = "0.7" rayon = "1.12.0" -thiserror = "2.0.18" tracing = "0.1.44" tracing-subscriber = "0.3.23" walkdir = "2" diff --git a/src/color_space.rs b/src/color_space.rs index 5144efa..30af9b3 100644 --- a/src/color_space.rs +++ b/src/color_space.rs @@ -1,65 +1,46 @@ use palette::{Hsl, IntoColor, Lab, Oklab, Srgb}; pub trait ColorSpace: Send + Sync { - fn distance(&self, p1: &[u8; 4], p2: &[u8; 4]) -> f64; + fn to_cartesian(&self, p: &[u8; 4]) -> [f64; 3]; } pub struct RgbSpace; impl ColorSpace for RgbSpace { - fn distance(&self, p1: &[u8; 4], p2: &[u8; 4]) -> f64 { - let dr = (p1[0] as f64) - (p2[0] as f64); - let dg = (p1[1] as f64) - (p2[1] as f64); - let db = (p1[2] as f64) - (p2[2] as f64); - (dr * dr + dg * dg + db * db).sqrt() + fn to_cartesian(&self, p: &[u8; 4]) -> [f64; 3] { + [p[0] as f64, p[1] as f64, p[2] as f64] } } pub struct LabSpace; impl ColorSpace for LabSpace { - fn distance(&self, p1: &[u8; 4], p2: &[u8; 4]) -> f64 { - let to_lab = |p: &[u8; 4]| -> Lab { - Srgb::new(p[0], p[1], p[2]).into_format::().into_color() - }; - let l1 = to_lab(p1); - let l2 = to_lab(p2); - let dl = (l1.l - l2.l) as f64; - let da = (l1.a - l2.a) as f64; - let db = (l1.b - l2.b) as f64; - (dl * dl + da * da + db * db).sqrt() + fn to_cartesian(&self, p: &[u8; 4]) -> [f64; 3] { + let lab: Lab = Srgb::new(p[0], p[1], p[2]) + .into_format::() + .into_color(); + [lab.l as f64, lab.a as f64, lab.b as f64] } } pub struct HslSpace; impl ColorSpace for HslSpace { - fn distance(&self, p1: &[u8; 4], p2: &[u8; 4]) -> f64 { - let to_cart = |p: &[u8; 4]| -> (f64, f64, f64) { - let hsl: Hsl = Srgb::new(p[0], p[1], p[2]).into_format::().into_color(); - let h = hsl.hue.into_radians() as f64; - let s = hsl.saturation as f64; - let l = hsl.lightness as f64; - (s * h.cos(), s * h.sin(), l) - }; - let (x1, y1, z1) = to_cart(p1); - let (x2, y2, z2) = to_cart(p2); - let dx = x1 - x2; - let dy = y1 - y2; - let dz = z1 - z2; - (dx * dx + dy * dy + dz * dz).sqrt() + fn to_cartesian(&self, p: &[u8; 4]) -> [f64; 3] { + let hsl: Hsl = Srgb::new(p[0], p[1], p[2]) + .into_format::() + .into_color(); + let h = hsl.hue.into_radians() as f64; + let s = hsl.saturation as f64; + let l = hsl.lightness as f64; + [s * h.cos(), s * h.sin(), l] } } pub struct OklabSpace; impl ColorSpace for OklabSpace { - fn distance(&self, p1: &[u8; 4], p2: &[u8; 4]) -> f64 { - let to_oklab = |p: &[u8; 4]| -> Oklab { - Srgb::new(p[0], p[1], p[2]).into_format::().into_color() - }; - let o1 = to_oklab(p1); - let o2 = to_oklab(p2); - let dl = (o1.l - o2.l) as f64; - let da = (o1.a - o2.a) as f64; - let db = (o1.b - o2.b) as f64; - (dl * dl + da * da + db * db).sqrt() + fn to_cartesian(&self, p: &[u8; 4]) -> [f64; 3] { + let oklab: Oklab = Srgb::new(p[0], p[1], p[2]) + .into_format::() + .into_color(); + [oklab.l as f64, oklab.a as f64, oklab.b as f64] } } @@ -74,37 +55,43 @@ pub enum ColorSpaceKind { impl ColorSpaceKind { pub fn into_space(self) -> Box { match self { - Self::Rgb => Box::new(RgbSpace), - Self::Hsl => Box::new(HslSpace), - Self::Lab => Box::new(LabSpace), + Self::Rgb => Box::new(RgbSpace), + Self::Hsl => Box::new(HslSpace), + Self::Lab => Box::new(LabSpace), Self::Oklab => Box::new(OklabSpace), } } } - #[cfg(test)] mod tests { use super::*; + fn euclidean(a: [f64; 3], b: [f64; 3]) -> f64 { + let dx = a[0] - b[0]; + let dy = a[1] - b[1]; + let dz = a[2] - b[2]; + (dx * dx + dy * dy + dz * dz).sqrt() + } + #[test] fn lab_distance_positive_for_different_colors() { let space = LabSpace; let white = [255u8, 255, 255, 255]; let black = [0u8, 0, 0, 255]; - assert!(space.distance(&white, &black) > 0.0); - assert!(space.distance(&white, &white) < 1e-10); + assert!(euclidean(space.to_cartesian(&white), space.to_cartesian(&black)) > 0.0); + assert!(euclidean(space.to_cartesian(&white), space.to_cartesian(&white)) < 1e-10); } #[test] fn hsl_cylindrical_distance_works() { let space = HslSpace; let red = [255u8, 0, 0, 255]; - assert!(space.distance(&red, &red) < 1e-10); + assert!(euclidean(space.to_cartesian(&red), space.to_cartesian(&red)) < 1e-10); let blue = [0u8, 0, 255, 255]; - assert!(space.distance(&red, &blue) > 0.0); + assert!(euclidean(space.to_cartesian(&red), space.to_cartesian(&blue)) > 0.0); let grey = [128u8, 128, 128, 255]; - assert!(space.distance(&grey, &grey) < 1e-10); + assert!(euclidean(space.to_cartesian(&grey), space.to_cartesian(&grey)) < 1e-10); } #[test] @@ -112,8 +99,8 @@ mod tests { let space = OklabSpace; let white = [255u8, 255, 255, 255]; let black = [0u8, 0, 0, 255]; - assert!(space.distance(&white, &black) > 0.0); - assert!(space.distance(&white, &white) < 1e-10); + assert!(euclidean(space.to_cartesian(&white), space.to_cartesian(&black)) > 0.0); + assert!(euclidean(space.to_cartesian(&white), space.to_cartesian(&white)) < 1e-10); } #[test] @@ -128,8 +115,8 @@ mod tests { let black = [0u8, 0, 0, 255]; for variant in variants { let space = variant.into_space(); - assert!(space.distance(&white, &black) > 0.0); - assert!(space.distance(&white, &white) < 1e-10); + assert!(euclidean(space.to_cartesian(&white), space.to_cartesian(&black)) > 0.0); + assert!(euclidean(space.to_cartesian(&white), space.to_cartesian(&white)) < 1e-10); } } } diff --git a/src/processor.rs b/src/processor.rs index 766c45f..daea7d2 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path}; use anyhow::Context; @@ -18,35 +18,61 @@ pub fn remap_pixels( palette: &[[u8; 4]], color_space: &dyn ColorSpace, ) -> RemapStats { - debug_assert!(!palette.is_empty(), "remap_pixels called with empty palette"); + debug_assert!( + !palette.is_empty(), + "remap_pixels called with empty palette" + ); + + let mapped_palette: Vec<([f64; 3], [u8; 4])> = palette + .iter() + .map(|&color| (color_space.to_cartesian(&color), color)) + .collect(); + + let mut cache: HashMap<[u8; 4], [u8; 4]> = HashMap::new(); let mut pixels_changed: u64 = 0; + for pixel in img.pixels_mut() { if pixel[3] == 0 { continue; } - // Premul is exact for RGB; non-linear spaces (Lab, Oklab, HSL) treat premul'd bytes as straight sRGB. - let alpha = pixel[3]; - let premul = [ - (pixel[0] as u16 * alpha as u16 / 255) as u8, - (pixel[1] as u16 * alpha as u16 / 255) as u8, - (pixel[2] as u16 * alpha as u16 / 255) as u8, - alpha, - ]; - let mut min_distance = f64::MAX; - let mut best_match: [u8; 4] = pixel.0; - for palette_color in palette { - let dist = color_space.distance(&premul, palette_color); - if dist < min_distance { - min_distance = dist; - best_match = *palette_color; + + let mut raw_color = pixel.0; + raw_color[3] = 255; + + let mut best_match = if let Some(cached) = cache.get(&raw_color) { + *cached + } else { + let mapped_pixel = color_space.to_cartesian(&raw_color); + + let mut min_distance_sq = f64::MAX; + let mut current_best = raw_color; + + for &(palette_point, orig_palette_color) in &mapped_palette { + let dx = mapped_pixel[0] - palette_point[0]; + let dy = mapped_pixel[1] - palette_point[1]; + let dz = mapped_pixel[2] - palette_point[2]; + + let dist_sq = dx * dx + dy * dy + dz * dz; + + if dist_sq < min_distance_sq { + min_distance_sq = dist_sq; + current_best = orig_palette_color; + } } - } + + cache.insert(raw_color, current_best); + current_best + }; + best_match[3] = pixel[3]; + if pixel.0 != best_match { pixels_changed += 1; } + pixel.0 = best_match; } + RemapStats { pixels_changed } } @@ -119,14 +145,14 @@ mod tests { } #[test] - fn semi_transparent_pixel_uses_premultiplied_distance() { - // [255, 0, 0, 128] premultiplied → [128, 0, 0] - // palette: bright-red [255,0,0] vs dark-red [128,0,0] - // premul distance to dark-red = 0, so dark-red wins; original alpha preserved + fn semi_transparent_pixel_matches_rgb_without_alpha_influence() { + // Matching uses full RGB (alpha forced to 255 before lookup), alpha preserved in output. + // [255,0,0,128] matches [255,0,0] exactly → stays [255,0,0], alpha 128 preserved. let mut img = image::RgbaImage::from_pixel(1, 1, image::Rgba([255u8, 0, 0, 128])); let palette = vec![[255u8, 0, 0, 255], [128u8, 0, 0, 255]]; - remap_pixels(&mut img, &palette, &RgbSpace); - assert_eq!(img.get_pixel(0, 0).0, [128, 0, 0, 128]); + let stats = remap_pixels(&mut img, &palette, &RgbSpace); + assert_eq!(img.get_pixel(0, 0).0, [255, 0, 0, 128]); + assert_eq!(stats.pixels_changed, 0); } #[test] @@ -142,7 +168,10 @@ mod tests { let result = process_image(&tmp, &out_dir, &palette, &RgbSpace, true).unwrap(); assert_eq!(result.pixels_changed, 1); - assert!(!out_dir.exists(), "dry_run must not create output directory"); + assert!( + !out_dir.exists(), + "dry_run must not create output directory" + ); std::fs::remove_file(&tmp).ok(); }