init
This commit is contained in:
294
src/cli.rs
Normal file
294
src/cli.rs
Normal 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
145
src/color_space.rs
Normal 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
6
src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod cli;
|
||||
mod color_space;
|
||||
mod palette;
|
||||
mod processor;
|
||||
|
||||
pub use cli::run;
|
||||
7
src/main.rs
Normal file
7
src/main.rs
Normal 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
175
src/palette.rs
Normal 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
130
src/processor.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user