refactor: remove unused dependencies and update color space calculations

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-24 11:02:53 +02:00
parent f330d02944
commit da23592896
4 changed files with 94 additions and 80 deletions

1
Cargo.lock generated
View File

@@ -878,7 +878,6 @@ dependencies = [
"palette",
"rayon",
"tempfile",
"thiserror",
"tracing",
"tracing-subscriber",
"walkdir",

View File

@@ -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"

View File

@@ -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::<f32>().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::<f32>()
.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::<f32>().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::<f32>()
.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::<f32>().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::<f32>()
.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<dyn ColorSpace> {
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);
}
}
}

View File

@@ -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();
}