From 28dac1b7dc40405348995fe646bbb4f5e9ca2ec4 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sun, 23 Aug 2026 19:09:00 +0200 Subject: [PATCH] feat: wire up image conversion pipeline and CLI Add `convert.rs`: a SIMD nearest-color search and a rayon-parallel `nordify_pixels` function. Add `error.rs` with the `NordifyError` type. Rewrite `lib.rs` to expose `NordifyOptions`, `nordify_image`, and `nordify_file` as the public API. Rewrite `main.rs` to parse CLI arguments with `argh` and call into `lib.rs`. Add `argh` and `thiserror` as dependencies. --- Cargo.toml | 2 + src/convert.rs | 375 +++++++++++++++++++++++++++++++++++++++++++++++++ src/error.rs | 9 ++ src/lib.rs | 69 ++++++++- src/main.rs | 46 +++++- 5 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 src/convert.rs create mode 100644 src/error.rs diff --git a/Cargo.toml b/Cargo.toml index 4f871f9..0e1aacb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,11 @@ path = "src/main.rs" name = "nordify" [dependencies] +argh = "0.1.19" image = "0.25.10" ndarray = { version = "0.17.2", features = ["rayon"] } rayon = "1.12.0" +thiserror = "2.0.20" wide = "1.6.1" [profile.release] diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..e98b59d --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,375 @@ +use crate::color::{Lab, LabPlanar, PlanarColor, Rgb, RgbPlanar}; +use crate::palette::{Expansion, Palette}; +use ndarray::parallel::prelude::*; +use ndarray::{Array3, Axis}; +use wide::f64x4; + +/// Finds the index of the palette entry closest to `pixel` in Lab space. +/// +/// This method compares squared distances, not distances. Squaring skips +/// a `sqrt` call per comparison. It never changes which entry is +/// closest, since `sqrt` is monotonic. The scan compares 4 palette +/// entries at a time, then falls back to one at a time for the +/// remainder. On a tie, the method returns the lower index. +fn closest_index_lab_simd(pixel: Lab, palette: &LabPlanar) -> usize { + let (pl, pa, pb) = ( + f64x4::splat(pixel.l), + f64x4::splat(pixel.a), + f64x4::splat(pixel.b), + ); + let mut best_dist = f64::MAX; + let mut best_idx = 0; + let n = palette.len(); + let chunks = n / 4; + + for chunk_index in 0..chunks { + let i = chunk_index * 4; + let dl = f64x4::from(&palette.l()[i..i + 4]) - pl; + let da = f64x4::from(&palette.a()[i..i + 4]) - pa; + let db = f64x4::from(&palette.b()[i..i + 4]) - pb; + let dist_sq = (dl * dl + da * da + db * db).to_array(); + for (lane, &d) in dist_sq.iter().enumerate() { + if d < best_dist { + best_dist = d; + best_idx = i + lane; + } + } + } + for i in (chunks * 4)..n { + let d = (palette.l()[i] - pixel.l).powi(2) + + (palette.a()[i] - pixel.a).powi(2) + + (palette.b()[i] - pixel.b).powi(2); + if d < best_dist { + best_dist = d; + best_idx = i; + } + } + best_idx +} + +/// Finds the index of the palette entry closest to `pixel` in RGB space. +/// +/// This method works the same way as `closest_index_lab_simd`, but +/// compares raw `0..=255` RGB channels instead of Lab components. +fn closest_index_rgb_simd(pixel: Rgb, palette: &RgbPlanar) -> usize { + let (pr, pg, pb) = ( + f64x4::splat(f64::from(pixel.r)), + f64x4::splat(f64::from(pixel.g)), + f64x4::splat(f64::from(pixel.b)), + ); + let mut best_dist = f64::MAX; + let mut best_idx = 0; + let n = palette.len(); + let chunks = n / 4; + + for chunk_index in 0..chunks { + let i = chunk_index * 4; + let dr = f64x4::from(&palette.r()[i..i + 4]) - pr; + let dg = f64x4::from(&palette.g()[i..i + 4]) - pg; + let db = f64x4::from(&palette.b()[i..i + 4]) - pb; + let dist_sq = (dr * dr + dg * dg + db * db).to_array(); + for (lane, &d) in dist_sq.iter().enumerate() { + if d < best_dist { + best_dist = d; + best_idx = i + lane; + } + } + } + for i in (chunks * 4)..n { + let d = (palette.r()[i] - f64::from(pixel.r)).powi(2) + + (palette.g()[i] - f64::from(pixel.g)).powi(2) + + (palette.b()[i] - f64::from(pixel.b)).powi(2); + if d < best_dist { + best_dist = d; + best_idx = i; + } + } + best_idx +} + +/// Finds the palette color closest to `pixel`. +/// +/// This method returns `None` if `palette` has no colors. Otherwise, it +/// returns `Some` of the closest color. When `use_lab` is `true`, the +/// method compares colors in Lab space, which better matches human color +/// perception. Otherwise, it compares raw RGB channels. +pub fn find_closest_color( + pixel: Rgb, + palette: &Palette, + use_lab: bool, +) -> Option { + if palette.is_empty() { + return None; + } + let idx = if use_lab { + closest_index_lab_simd(Lab::from(pixel), palette.lab_planar()) + } else { + closest_index_rgb_simd(pixel, palette.rgb_planar()) + }; + Some(palette.colors()[idx]) +} + +/// Scratch buffers for one image row, reused across rows on the same +/// rayon worker instead of allocated fresh every row. +struct RowScratch { + row_rgb: RgbPlanar, + row_lab: LabPlanar, +} + +impl RowScratch { + fn with_capacity(width: usize) -> Self { + Self { + row_rgb: RgbPlanar::with_capacity(width), + row_lab: LabPlanar::with_capacity(width), + } + } +} + +/// Converts every pixel in `pixels` to its closest color in `palette`. +/// +/// This method processes image rows in parallel. Within each row, it +/// compares each pixel against `palette` 4 entries at a time. Set +/// `use_lab` to `true` for perceptually accurate matching, or `false` +/// for plain RGB distance. +pub fn nordify_pixels( + pixels: &Array3, + palette: &Palette, + use_lab: bool, +) -> Array3 { + let (height, width, _channels) = pixels.dim(); + let mut output = Array3::::zeros((height, width, 3)); + + pixels + .axis_iter(Axis(0)) + .into_par_iter() + .zip(output.axis_iter_mut(Axis(0)).into_par_iter()) + .for_each_init( + || RowScratch::with_capacity(width), + |scratch, (in_row, mut out_row)| { + scratch.row_rgb.clear(); + for x in 0..width { + scratch.row_rgb.push(Rgb { + r: in_row[[x, 0]], + g: in_row[[x, 1]], + b: in_row[[x, 2]], + }); + } + + if use_lab { + scratch.row_lab.convert_from(&scratch.row_rgb); + for x in 0..width { + let pixel = Lab { + l: scratch.row_lab.l()[x], + a: scratch.row_lab.a()[x], + b: scratch.row_lab.b()[x], + }; + let idx = closest_index_lab_simd(pixel, palette.lab_planar()); + let color = palette.colors()[idx]; + out_row[[x, 0]] = color.r; + out_row[[x, 1]] = color.g; + out_row[[x, 2]] = color.b; + } + } else { + for x in 0..width { + let pixel = Rgb { + r: in_row[[x, 0]], + g: in_row[[x, 1]], + b: in_row[[x, 2]], + }; + let idx = closest_index_rgb_simd(pixel, palette.rgb_planar()); + let color = palette.colors()[idx]; + out_row[[x, 0]] = color.r; + out_row[[x, 1]] = color.g; + out_row[[x, 2]] = color.b; + } + } + }, + ); + + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::palette::CanExpand; + + fn black_and_white_palette() -> Palette { + Palette::new(vec![ + Rgb { r: 0, g: 0, b: 0 }, + Rgb { + r: 255, + g: 255, + b: 255, + }, + ]) + } + + #[test] + fn test_find_closest_color_lab() { + let palette = black_and_white_palette(); + let pixel = Rgb { + r: 10, + g: 10, + b: 10, + }; + let closest = find_closest_color(pixel, &palette, true); + assert_eq!(closest, Some(Rgb { r: 0, g: 0, b: 0 })); + } + + #[test] + fn test_find_closest_color_rgb() { + let palette = black_and_white_palette(); + let pixel = Rgb { + r: 240, + g: 240, + b: 240, + }; + let closest = find_closest_color(pixel, &palette, false); + assert_eq!( + closest, + Some(Rgb { + r: 255, + g: 255, + b: 255 + }) + ); + } + + #[test] + fn test_find_closest_color_empty_palette() { + let palette: Palette = Palette::new(vec![]); + let pixel = Rgb { r: 1, g: 2, b: 3 }; + assert_eq!(find_closest_color(pixel, &palette, true), None); + } + + fn linear_scan_lab(pixel: Lab, palette: &LabPlanar) -> usize { + (0..palette.len()) + .min_by(|&a, &b| { + let dist = |i: usize| { + (palette.l()[i] - pixel.l).powi(2) + + (palette.a()[i] - pixel.a).powi(2) + + (palette.b()[i] - pixel.b).powi(2) + }; + dist(a).partial_cmp(&dist(b)).unwrap() + }) + .unwrap() + } + + fn linear_scan_rgb(pixel: Rgb, palette: &RgbPlanar) -> usize { + (0..palette.len()) + .min_by(|&a, &b| { + let dist = |i: usize| { + (palette.r()[i] - f64::from(pixel.r)).powi(2) + + (palette.g()[i] - f64::from(pixel.g)).powi(2) + + (palette.b()[i] - f64::from(pixel.b)).powi(2) + }; + dist(a).partial_cmp(&dist(b)).unwrap() + }) + .unwrap() + } + + fn sample_colors() -> Vec { + vec![ + Rgb { r: 0, g: 0, b: 0 }, + Rgb { + r: 255, + g: 255, + b: 255, + }, + Rgb { + r: 46, + g: 52, + b: 64, + }, + Rgb { + r: 216, + g: 222, + b: 233, + }, + Rgb { + r: 136, + g: 192, + b: 208, + }, + Rgb { + r: 191, + g: 97, + b: 106, + }, + Rgb { + r: 163, + g: 190, + b: 140, + }, + ] + } + + #[test] + fn closest_index_lab_simd_matches_linear_scan() { + let colors = sample_colors(); + let palette = Palette::::new(colors); + let test_pixels = [ + Rgb { r: 10, g: 10, b: 10 }, + Rgb { + r: 200, + g: 200, + b: 200, + }, + Rgb { + r: 140, + g: 190, + b: 210, + }, + ]; + for pixel in test_pixels { + let lab_pixel = Lab::from(pixel); + assert_eq!( + closest_index_lab_simd(lab_pixel, palette.lab_planar()), + linear_scan_lab(lab_pixel, palette.lab_planar()) + ); + } + } + + #[test] + fn closest_index_rgb_simd_matches_linear_scan() { + let colors = sample_colors(); + let palette = Palette::::new(colors); + let test_pixels = [ + Rgb { r: 10, g: 10, b: 10 }, + Rgb { + r: 200, + g: 200, + b: 200, + }, + Rgb { + r: 140, + g: 190, + b: 210, + }, + ]; + for pixel in test_pixels { + assert_eq!( + closest_index_rgb_simd(pixel, palette.rgb_planar()), + linear_scan_rgb(pixel, palette.rgb_planar()) + ); + } + } + + #[test] + fn closest_index_lab_simd_tie_break_matches_first_wins() { + // Indices 0 and 1 sit at the same distance (1.0) from the pixel, + // within the same 4-wide SIMD chunk. The lower index must win. + let palette = LabPlanar::new( + vec![50.0, 50.0, 50.0, 50.0], + vec![-1.0, 1.0, 10.0, 20.0], + vec![0.0, 0.0, 0.0, 0.0], + ); + let pixel = Lab { + l: 50.0, + a: 0.0, + b: 0.0, + }; + assert_eq!(closest_index_lab_simd(pixel, &palette), 0); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6255c1b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NordifyError { + #[error("failed to read or write image: {0}")] + Image(#[from] image::ImageError), +} + +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index 82d5d02..fe1b873 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,71 @@ mod color; +mod convert; +mod error; mod palette; -pub fn add(a: u32, b: u32) -> u32 { - a + b +pub use error::{NordifyError, Result}; + +use ndarray::Array3; +use palette::{CanExpand, Palette, NORD_PALETTE}; + +/// Options controlling how an image converts to the Nord color palette. +#[derive(Debug, Clone)] +pub struct NordifyOptions { + /// Expand the palette with colors interpolated between close pairs. + pub expand: bool, + /// Interpolated colors to add between each close pair, when + /// `expand` is `true`. + pub expansion_factor: usize, + /// Compare colors in perceptual Lab space instead of raw RGB. + pub use_lab: bool, +} + +impl Default for NordifyOptions { + fn default() -> Self { + Self { + expand: true, + expansion_factor: 3, + use_lab: true, + } + } +} + +/// Converts `image` to the Nord color palette. +/// +/// This method never fails. It replaces each pixel with its closest Nord +/// palette color, per `options`. +pub fn nordify_image(image: &image::RgbImage, options: &NordifyOptions) -> image::RgbImage { + let (width, height) = image.dimensions(); + let raw = image.as_raw().clone(); + let pixels = Array3::from_shape_vec((height as usize, width as usize, 3), raw) + .expect("RgbImage's raw buffer length always matches height * width * 3"); + + let base = Palette::::new(NORD_PALETTE.to_vec()); + let output: Array3 = if options.expand { + let expanded = base.expand(options.expansion_factor); + convert::nordify_pixels(&pixels, &expanded, options.use_lab) + } else { + convert::nordify_pixels(&pixels, &base, options.use_lab) + }; + + let (raw_out, _offset) = output.into_raw_vec_and_offset(); + image::RgbImage::from_raw(width, height, raw_out) + .expect("output buffer length always matches width * height * 3 by construction") +} + +/// Reads the image at `input`, converts it to the Nord color palette, +/// then writes the result to `output`. +/// +/// # Errors +/// +/// Returns an error if `input` cannot be read and decoded, or if +/// `output` cannot be written. +pub fn nordify_file( + input: impl AsRef, + output: impl AsRef, + options: &NordifyOptions, +) -> Result<()> { + let img = image::open(input)?.to_rgb8(); + nordify_image(&img, options).save(output)?; + Ok(()) } diff --git a/src/main.rs b/src/main.rs index e7a11a9..175244d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,45 @@ -fn main() { - println!("Hello, world!"); +use std::path::PathBuf; + +/// Convert an image to the Nord color palette without dithering artifacts. +#[derive(argh::FromArgs)] +struct Args { + /// input image path + #[argh(positional)] + input: PathBuf, + + /// output image path + #[argh(option, short = 'o')] + output: PathBuf, + + /// use only the original 16 Nord colors (disable palette expansion) + #[argh(switch)] + no_expand: bool, + + /// number of interpolated colors between close palette entries (default: 3) + #[argh(option, default = "3")] + expansion_factor: usize, + + /// use plain RGB distance instead of perceptual LAB distance + #[argh(switch)] + rgb_distance: bool, +} + +fn main() -> std::process::ExitCode { + let args: Args = argh::from_env(); + let options = nordify_rs::NordifyOptions { + expand: !args.no_expand, + expansion_factor: args.expansion_factor, + use_lab: !args.rgb_distance, + }; + + match nordify_rs::nordify_file(&args.input, &args.output, &options) { + Ok(()) => { + println!("Saved to {}", args.output.display()); + std::process::ExitCode::SUCCESS + } + Err(err) => { + eprintln!("Error: {err}"); + std::process::ExitCode::FAILURE + } + } }