This commit is contained in:
2026-04-24 04:54:33 +02:00
commit d980755621
11 changed files with 2313 additions and 0 deletions

294
src/cli.rs Normal file
View File

@@ -0,0 +1,294 @@
use std::path::PathBuf;
use clap::Parser;
use rayon::prelude::*;
use tracing::info;
use crate::{
color_space::ColorSpace,
palette::{FilePaletteSource, Palette},
processor::{process_image, ProcessResult},
};
#[derive(Parser, Debug)]
#[command(author, version, about = "A pixel palette colorizer tool.")]
struct Cli {
#[arg(required = true)]
inputs: Vec<PathBuf>,
#[arg(short, long)]
palette: PathBuf,
#[arg(short, long, default_value = "output")]
out_dir: PathBuf,
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(
short = 'e',
long,
default_value = "png,jpg",
value_delimiter = ','
)]
extensions: Vec<String>,
#[arg(
short,
long,
default_value = "rgb",
value_parser = clap::builder::PossibleValuesParser::new(crate::color_space::available_names())
)]
color_space: String,
}
pub fn expand_inputs(inputs: &[PathBuf], extensions: &[String]) -> Vec<PathBuf> {
let exts: Vec<String> = extensions.iter().map(|e| e.to_lowercase()).collect();
let mut result = Vec::new();
for path in inputs {
if path.is_dir() {
for entry in walkdir::WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_map(|e| match e {
Ok(entry) => Some(entry),
Err(err) => {
tracing::warn!("Skipping unreadable entry: {}", err);
None
}
})
{
let p = entry.path();
if p.is_file() {
let matches = p
.extension()
.and_then(|e| e.to_str())
.map(|e| exts.contains(&e.to_lowercase()))
.unwrap_or(false);
if matches {
result.push(p.to_path_buf());
}
}
}
} else {
result.push(path.clone());
}
}
result
}
pub trait Reporter: Send + Sync {
fn on_complete(&self, outcome: &FileOutcome);
fn summarize(&self, outcomes: &[FileOutcome]);
}
pub struct FileOutcome {
pub path: PathBuf,
pub result: anyhow::Result<ProcessResult>,
}
pub fn run_batch(
inputs: &[PathBuf],
out_dir: &std::path::Path,
palette: &[[u8; 4]],
space: &dyn ColorSpace,
dry_run: bool,
reporter: &dyn Reporter,
) -> Vec<FileOutcome> {
inputs
.par_iter()
.map(|path| {
let result = process_image(path, out_dir, palette, space, dry_run);
let outcome = FileOutcome { path: path.clone(), result };
reporter.on_complete(&outcome);
outcome
})
.collect()
}
pub struct DefaultReporter {
bar: indicatif::ProgressBar,
}
impl DefaultReporter {
pub fn new(total: u64) -> Self {
let bar = indicatif::ProgressBar::new(total);
bar.set_style(
indicatif::ProgressStyle::with_template("{pos}/{len} [{bar:40}] {msg}")
.unwrap()
.progress_chars("=> "),
);
Self { bar }
}
}
impl Reporter for DefaultReporter {
fn on_complete(&self, outcome: &FileOutcome) {
self.bar.inc(1);
match &outcome.result {
Ok(r) => info!(
"Processed {:?} ({} pixels changed)",
outcome.path.file_name().unwrap_or_default(),
r.pixels_changed
),
Err(e) => tracing::error!(
"Failed {:?}: {:#}",
outcome.path.file_name().unwrap_or_default(),
e
),
}
}
fn summarize(&self, outcomes: &[FileOutcome]) {
self.bar.finish_and_clear();
let succeeded = outcomes.iter().filter(|o| o.result.is_ok()).count();
let total_pixels: u64 = outcomes
.iter()
.filter_map(|o| o.result.as_ref().ok())
.map(|r| r.pixels_changed)
.sum();
info!(
"Done: {}/{} files succeeded, {} pixels remapped.",
succeeded,
outcomes.len(),
total_pixels
);
}
}
pub fn run() -> anyhow::Result<()> {
info!("Running pixel palette colorizer...");
let cli = Cli::parse();
let space = crate::color_space::from_name(&cli.color_space)?;
info!("Color space: {}", cli.color_space);
info!("Loading palette from {:?}", cli.palette);
let palette = Palette::load(&FilePaletteSource(cli.palette.clone()))?;
info!("Loaded {} colors.", palette.len());
let inputs = expand_inputs(&cli.inputs, &cli.extensions);
info!("Processing {} files...", inputs.len());
let reporter = DefaultReporter::new(inputs.len() as u64);
let outcomes = run_batch(&inputs, &cli.out_dir, palette.colors(), &*space, cli.dry_run, &reporter);
reporter.summarize(&outcomes);
let failed = outcomes.iter().filter(|o| o.result.is_err()).count();
if failed > 0 {
anyhow::bail!("{}/{} files failed", failed, outcomes.len());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_space;
struct NoopReporter;
impl Reporter for NoopReporter {
fn on_complete(&self, _: &FileOutcome) {}
fn summarize(&self, _: &[FileOutcome]) {}
}
#[test]
fn run_batch_collects_all_errors_without_aborting() {
let space = color_space::from_name("rgb").unwrap();
let palette = vec![[255u8, 0, 0, 255]];
let inputs = vec![
std::path::PathBuf::from("/nonexistent/a.png"),
std::path::PathBuf::from("/nonexistent/b.png"),
];
let outcomes = run_batch(
&inputs,
std::path::Path::new("/tmp"),
&palette,
&*space,
false,
&NoopReporter,
);
assert_eq!(outcomes.len(), 2);
assert!(outcomes.iter().all(|o| o.result.is_err()));
}
#[test]
fn run_batch_returns_one_outcome_per_input() {
let space = color_space::from_name("rgb").unwrap();
let palette = vec![[0u8, 0, 0, 255]];
let inputs: Vec<std::path::PathBuf> = (0..5)
.map(|i| std::path::PathBuf::from(format!("/nonexistent/{i}.png")))
.collect();
let outcomes = run_batch(
&inputs,
std::path::Path::new("/tmp"),
&palette,
&*space,
false,
&NoopReporter,
);
assert_eq!(outcomes.len(), 5);
}
#[test]
fn expand_inputs_passes_through_explicit_file() {
let path = PathBuf::from("/nonexistent/file.png");
let result = expand_inputs(&[path.clone()], &["png".to_string()]);
assert_eq!(result, vec![path]);
}
#[test]
fn expand_inputs_passes_through_nonexistent_path() {
let path = PathBuf::from("/nonexistent/missing.xyz");
let result = expand_inputs(&[path.clone()], &["png".to_string()]);
assert_eq!(result, vec![path]);
}
#[test]
fn expand_inputs_recurses_directory() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("nested.png"), b"").unwrap();
std::fs::write(dir.path().join("top.png"), b"").unwrap();
let result = expand_inputs(&[dir.path().to_path_buf()], &["png".to_string()]);
assert_eq!(result.len(), 2);
}
#[test]
fn expand_inputs_filters_by_extension() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("keep.png"), b"").unwrap();
std::fs::write(dir.path().join("skip.txt"), b"").unwrap();
let result = expand_inputs(&[dir.path().to_path_buf()], &["png".to_string()]);
assert_eq!(result.len(), 1);
assert_eq!(result[0].file_name().unwrap(), "keep.png");
}
#[test]
fn expand_inputs_extension_match_is_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("image.PNG"), b"").unwrap();
let result = expand_inputs(&[dir.path().to_path_buf()], &["png".to_string()]);
assert_eq!(result.len(), 1);
}
#[test]
fn expand_inputs_mixed_file_and_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.png"), b"").unwrap();
let explicit = PathBuf::from("/nonexistent/b.png");
let mut result = expand_inputs(
&[dir.path().to_path_buf(), explicit.clone()],
&["png".to_string()],
);
result.sort();
assert_eq!(result.len(), 2);
assert!(result.contains(&explicit));
}
}

145
src/color_space.rs Normal file
View File

@@ -0,0 +1,145 @@
use palette::{Hsl, IntoColor, Lab, Oklab, Srgb};
pub trait ColorSpace: Send + Sync {
fn distance(&self, p1: &[u8; 4], p2: &[u8; 4]) -> f64;
}
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()
}
}
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()
}
}
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()
}
}
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()
}
}
struct ColorSpaceEntry {
name: &'static str,
build: fn() -> Box<dyn ColorSpace>,
}
static REGISTRY: &[ColorSpaceEntry] = &[
ColorSpaceEntry { name: "rgb", build: || Box::new(RgbSpace) },
ColorSpaceEntry { name: "hsl", build: || Box::new(HslSpace) },
ColorSpaceEntry { name: "lab", build: || Box::new(LabSpace) },
ColorSpaceEntry { name: "oklab", build: || Box::new(OklabSpace) },
];
pub fn available_names() -> impl Iterator<Item = &'static str> {
REGISTRY.iter().map(|e| e.name)
}
pub fn from_name(name: &str) -> anyhow::Result<Box<dyn ColorSpace>> {
REGISTRY
.iter()
.find(|e| e.name == name)
.map(|e| (e.build)())
.ok_or_else(|| anyhow::anyhow!("Unknown color space: {name}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_name_rgb_returns_working_space() {
let space = from_name("rgb").unwrap();
let white = [255u8, 255, 255, 255];
let black = [0u8, 0, 0, 255];
assert!(space.distance(&white, &black) > 0.0);
assert_eq!(space.distance(&white, &white), 0.0);
}
#[test]
fn from_name_unknown_is_err() {
assert!(from_name("xyz").is_err());
}
#[test]
fn registry_is_coherent() {
let names: Vec<_> = available_names().collect();
assert_eq!(names.len(), 4, "expected 4 registered color spaces");
for name in &names {
assert!(from_name(name).is_ok(), "from_name failed for {name}");
}
}
#[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);
}
#[test]
fn hsl_cylindrical_distance_works() {
let space = HslSpace;
let red = [255u8, 0, 0, 255];
assert!(space.distance(&red, &red) < 1e-10);
let blue = [0u8, 0, 255, 255];
assert!(space.distance(&red, &blue) > 0.0);
let grey = [128u8, 128, 128, 255];
assert!(space.distance(&grey, &grey) < 1e-10);
}
#[test]
fn oklab_distance_positive_for_different_colors() {
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);
}
}

6
src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
mod cli;
mod color_space;
mod palette;
mod processor;
pub use cli::run;

7
src/main.rs Normal file
View File

@@ -0,0 +1,7 @@
use pixel_palette_colorizer::run;
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
run()
}

175
src/palette.rs Normal file
View File

@@ -0,0 +1,175 @@
use anyhow::Context;
pub trait PaletteSource {
fn extension(&self) -> &str;
fn read_bytes(&self) -> anyhow::Result<Vec<u8>>;
}
pub struct FilePaletteSource(pub std::path::PathBuf);
impl PaletteSource for FilePaletteSource {
fn extension(&self) -> &str {
self.0
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
}
fn read_bytes(&self) -> anyhow::Result<Vec<u8>> {
std::fs::read(&self.0).context("Failed to read palette file")
}
}
#[derive(Debug)]
pub struct Palette(Vec<[u8; 4]>);
fn parse_hex_text(bytes: &[u8]) -> anyhow::Result<Vec<[u8; 4]>> {
let content = std::str::from_utf8(bytes).context("Palette text is not valid UTF-8")?;
let mut colors = Vec::new();
for line in content.lines() {
let hex = line.trim().trim_start_matches('#');
if hex.is_empty() {
continue;
}
if hex.len() == 6 || hex.len() == 8 {
let r = u8::from_str_radix(&hex[0..2], 16).context("Invalid hex color")?;
let g = u8::from_str_radix(&hex[2..4], 16).context("Invalid hex color")?;
let b = u8::from_str_radix(&hex[4..6], 16).context("Invalid hex color")?;
let a = if hex.len() == 8 {
u8::from_str_radix(&hex[6..8], 16).context("Invalid hex color")?
} else {
255
};
colors.push([r, g, b, a]);
} else {
tracing::warn!("Skipping invalid hex color: {}", line.trim());
}
}
Ok(colors)
}
fn parse_image_bytes(bytes: &[u8]) -> anyhow::Result<Vec<[u8; 4]>> {
use image::GenericImageView;
let img = image::load_from_memory(bytes).context("Failed to decode palette image")?;
let mut seen = std::collections::HashSet::new();
let mut colors = Vec::new();
for (_, _, pixel) in img.pixels() {
let key = [pixel[0], pixel[1], pixel[2], pixel[3]];
if seen.insert(key) {
colors.push(key);
}
}
Ok(colors)
}
impl Palette {
pub fn load(source: &dyn PaletteSource) -> anyhow::Result<Self> {
let bytes = source.read_bytes()?;
let colors = match source.extension() {
"txt" | "hex" => parse_hex_text(&bytes)?,
"png" | "jpg" | "jpeg" | "bmp" | "gif" => parse_image_bytes(&bytes)?,
ext => anyhow::bail!("Unsupported palette file format: {ext}"),
};
anyhow::ensure!(!colors.is_empty(), "Palette contains no colors");
Ok(Palette(colors))
}
pub fn colors(&self) -> &[[u8; 4]] {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct InMemorySource {
ext: &'static str,
data: Vec<u8>,
}
impl PaletteSource for InMemorySource {
fn extension(&self) -> &str { self.ext }
fn read_bytes(&self) -> anyhow::Result<Vec<u8>> { Ok(self.data.clone()) }
}
fn src(ext: &'static str, data: &str) -> InMemorySource {
InMemorySource { ext, data: data.as_bytes().to_vec() }
}
#[test]
fn hex_parses_six_char_rgb() {
let p = Palette::load(&src("hex", "#ff0000\n")).unwrap();
assert_eq!(p.colors(), &[[255u8, 0, 0, 255]]);
}
#[test]
fn hex_parses_eight_char_rgba() {
let p = Palette::load(&src("hex", "ff000080\n")).unwrap();
assert_eq!(p.colors(), &[[255u8, 0, 0, 128]]);
}
#[test]
fn hex_skips_blank_lines() {
let p = Palette::load(&src("hex", "\n#ff0000\n\n#00ff00\n\n")).unwrap();
assert_eq!(p.colors().len(), 2);
}
#[test]
fn hex_parses_six_char_rgb_without_prefix() {
let p = Palette::load(&src("txt", "ff0000\n")).unwrap();
assert_eq!(p.colors(), &[[255u8, 0, 0, 255]]);
}
#[test]
fn hex_skips_wrong_length_lines() {
let p = Palette::load(&src("hex", "#fff\n#ff0000\n")).unwrap();
assert_eq!(p.colors().len(), 1);
}
#[test]
fn hex_rejects_invalid_hex_digits() {
let result = Palette::load(&src("hex", "gggggg\n"));
assert!(result.is_err());
}
fn make_png_bytes(colors: &[[u8; 4]]) -> Vec<u8> {
let mut img = image::RgbaImage::new(colors.len() as u32, 1);
for (i, &c) in colors.iter().enumerate() {
img.put_pixel(i as u32, 0, image::Rgba(c));
}
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
buf.into_inner()
}
#[test]
fn image_extracts_unique_colors() {
let bytes = make_png_bytes(&[[255u8, 0, 0, 255], [0, 255, 0, 255], [255, 0, 0, 255]]);
let source = InMemorySource { ext: "png", data: bytes };
let p = Palette::load(&source).unwrap();
assert_eq!(p.colors().len(), 2); // [255,0,0,255] deduped
}
#[test]
fn image_jpeg_extension_also_works() {
let bytes = make_png_bytes(&[[0u8, 0, 255, 255]]);
let source = InMemorySource { ext: "jpg", data: bytes };
assert!(Palette::load(&source).is_ok());
}
#[test]
fn empty_text_palette_is_rejected() {
assert!(Palette::load(&src("hex", "\n\n")).is_err());
}
#[test]
fn unknown_extension_is_rejected() {
let err = Palette::load(&src("csv", "#ff0000\n")).unwrap_err();
assert!(err.to_string().contains("Unsupported"), "{err}");
}
}

130
src/processor.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::path::Path;
use anyhow::Context;
use crate::color_space::ColorSpace;
pub struct ProcessResult {
pub pixels_changed: u64,
}
pub struct RemapStats {
pub pixels_changed: u64,
}
// Palette alpha is ignored; each pixel's original alpha is preserved.
pub fn remap_pixels(
img: &mut image::RgbaImage,
palette: &[[u8; 4]],
color_space: &dyn ColorSpace,
) -> RemapStats {
debug_assert!(!palette.is_empty(), "remap_pixels called with empty palette");
let mut pixels_changed: u64 = 0;
for pixel in img.pixels_mut() {
if pixel[3] == 0 {
continue;
}
let mut min_distance = f64::MAX;
let mut best_match: [u8; 4] = pixel.0;
for palette_color in palette {
let dist = color_space.distance(&pixel.0, palette_color);
if dist < min_distance {
min_distance = dist;
best_match = *palette_color;
}
}
best_match[3] = pixel[3];
if pixel.0 != best_match {
pixels_changed += 1;
}
pixel.0 = best_match;
}
RemapStats { pixels_changed }
}
pub fn process_image(
input_path: &Path,
out_dir: &Path,
palette: &[[u8; 4]],
color_space: &dyn ColorSpace,
dry_run: bool,
) -> anyhow::Result<ProcessResult> {
let img = image::open(input_path)
.with_context(|| format!("Failed to open input image {:?}", input_path))?;
let mut out_img = img.to_rgba8();
let stats = remap_pixels(&mut out_img, palette, color_space);
let file_name = input_path.file_name().context("Invalid input file name")?;
let out_path = out_dir.join(file_name);
if !dry_run {
std::fs::create_dir_all(out_dir)?;
out_img
.save(&out_path)
.with_context(|| format!("Failed to save output image to {:?}", out_path))?;
}
Ok(ProcessResult {
pixels_changed: stats.pixels_changed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_space::RgbSpace;
#[test]
fn transparent_pixels_are_not_remapped() {
let mut img = image::RgbaImage::from_pixel(1, 1, image::Rgba([100u8, 150, 200, 0]));
let palette = vec![[255u8, 0, 0, 255]];
let stats = remap_pixels(&mut img, &palette, &RgbSpace);
assert_eq!(stats.pixels_changed, 0);
assert_eq!(img.get_pixel(0, 0).0, [100, 150, 200, 0]);
}
#[test]
fn nearest_color_replaces_pixel() {
let mut img = image::RgbaImage::from_pixel(1, 1, image::Rgba([100u8, 150, 200, 255]));
let palette = vec![[255u8, 0, 0, 255], [0u8, 0, 255, 255]];
let stats = remap_pixels(&mut img, &palette, &RgbSpace);
assert_eq!(stats.pixels_changed, 1);
assert_eq!(img.get_pixel(0, 0).0, [0, 0, 255, 255]);
}
#[test]
fn alpha_is_preserved_after_remap() {
let mut img = image::RgbaImage::from_pixel(1, 1, image::Rgba([200u8, 200, 200, 128]));
let palette = vec![[0u8, 0, 0, 255]];
let stats = remap_pixels(&mut img, &palette, &RgbSpace);
assert_eq!(img.get_pixel(0, 0).0[3], 128);
assert_eq!(stats.pixels_changed, 1);
}
#[test]
fn identical_pixel_is_not_counted_as_changed() {
let mut img = image::RgbaImage::from_pixel(1, 1, image::Rgba([255u8, 0, 0, 255]));
let palette = vec![[255u8, 0, 0, 255]];
let stats = remap_pixels(&mut img, &palette, &RgbSpace);
assert_eq!(stats.pixels_changed, 0);
}
#[test]
fn process_image_dry_run_does_not_write() {
let tmp = std::env::temp_dir().join("ppc_proc_test_input.png");
let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([100u8, 150, 200, 255]));
img.save(&tmp).unwrap();
let out_dir = std::env::temp_dir().join("ppc_proc_test_dryrun_out");
let _ = std::fs::remove_dir_all(&out_dir);
let palette = vec![[255u8, 0, 0, 255]];
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");
std::fs::remove_file(&tmp).ok();
}
}