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.
This commit is contained in:
2026-08-28 13:41:18 +02:00
parent 4cbc25f2d6
commit 28dac1b7dc
5 changed files with 497 additions and 4 deletions
+2
View File
@@ -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]
+375
View File
@@ -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<T: Expansion>(
pixel: Rgb,
palette: &Palette<T>,
use_lab: bool,
) -> Option<Rgb> {
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<T: Expansion + Sync>(
pixels: &Array3<u8>,
palette: &Palette<T>,
use_lab: bool,
) -> Array3<u8> {
let (height, width, _channels) = pixels.dim();
let mut output = Array3::<u8>::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<CanExpand> {
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<CanExpand> = 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<Rgb> {
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::<CanExpand>::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::<CanExpand>::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);
}
}
+9
View File
@@ -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<T> = std::result::Result<T, NordifyError>;
+67 -2
View File
@@ -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::<CanExpand>::new(NORD_PALETTE.to_vec());
let output: Array3<u8> = 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<std::path::Path>,
output: impl AsRef<std::path::Path>,
options: &NordifyOptions,
) -> Result<()> {
let img = image::open(input)?.to_rgb8();
nordify_image(&img, options).save(output)?;
Ok(())
}
+44 -2
View File
@@ -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
}
}
}