refactor: remove unused dependencies and update color space calculations
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -878,7 +878,6 @@ dependencies = [
|
||||
"palette",
|
||||
"rayon",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"walkdir",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user