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:
@@ -15,9 +15,11 @@ path = "src/main.rs"
|
|||||||
name = "nordify"
|
name = "nordify"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
argh = "0.1.19"
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
ndarray = { version = "0.17.2", features = ["rayon"] }
|
ndarray = { version = "0.17.2", features = ["rayon"] }
|
||||||
rayon = "1.12.0"
|
rayon = "1.12.0"
|
||||||
|
thiserror = "2.0.20"
|
||||||
wide = "1.6.1"
|
wide = "1.6.1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
+375
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,6 +1,71 @@
|
|||||||
mod color;
|
mod color;
|
||||||
|
mod convert;
|
||||||
|
mod error;
|
||||||
mod palette;
|
mod palette;
|
||||||
|
|
||||||
pub fn add(a: u32, b: u32) -> u32 {
|
pub use error::{NordifyError, Result};
|
||||||
a + b
|
|
||||||
|
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
@@ -1,3 +1,45 @@
|
|||||||
fn main() {
|
use std::path::PathBuf;
|
||||||
println!("Hello, world!");
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user