From 0fb6e337c255596d74c56f2c1364009b853ed852 Mon Sep 17 00:00:00 2001 From: mxhagen Date: Sun, 14 Sep 2025 20:46:06 +0200 Subject: [PATCH] version 0.6: implement image file output --- .gitignore | 3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 12 +++++++-- src/cli.rs | 68 +++++++++++++++++++++++++++++---------------------- src/colors.rs | 3 ++- src/main.rs | 63 ++++++++++++++++++++++++++++++++++++++++++----- 7 files changed, 110 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 217fa30..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ /target - -# test images -*.png diff --git a/Cargo.lock b/Cargo.lock index 402e76f..801477e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -765,7 +765,7 @@ dependencies = [ [[package]] name = "qtizer" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "image", diff --git a/Cargo.toml b/Cargo.toml index 2f40a13..1a7f144 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qtizer" -version = "0.5.0" +version = "0.6.0" edition = "2024" description = "Quantization/palette-generation tool using k-means clustering on pixel data" readme = "README.md" diff --git a/README.md b/README.md index c6b0c53..6c94021 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ command line quantization/palette-generation tool using k-means clustering on pi - [features](#features) - [usage](#usage) + - [example: palette creation in rgb format with alpha](#example-palette-creation-in-rgb-format-with-alpha-output-is-colored-accordingly-in-terminals) + - [example: image quantization to reduced palette](#example-image-quantization-to-reduced-palette-file-formats-inferred-based-on-extension) - [installation](#installation) @@ -31,12 +33,13 @@ Options: -s, --seed Optional RNG seed for reproducible results -o, --output Output file path - If not provided, outputs to stdout - -f, --format Palette output format [default: hex] [possible values: hex, rgb] + - With image file extensions, outputs an image file + -f, --format Palette output format [possible values: hex, rgb] -h, --help Print help (see more with '--help') -V, --version Print version ``` -Example (output is colored accordingly in terminals): +#### Example: palette creation in rgb format with alpha (output is colored accordingly in terminals): ```sh $ qtizer wallpaper.png -k 3 -af rgb @@ -45,6 +48,11 @@ rgba(191, 150, 132, 254) rgba(48, 45, 51, 254) ``` +#### Example: image quantization to reduced palette (file formats inferred based on extension): +```sh +$ qtizer wallpaper.png -k 8 quantized.png +``` + ## installation diff --git a/src/cli.rs b/src/cli.rs index 32a0055..f2dc7eb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,7 @@ -use crate::colors::ColorFormat; use clap::*; +use image::*; + +use crate::colors::ColorFormat; /// Quantization/palette-generation tool using k-means clustering on pixel dataI #[derive(Parser, Debug)] @@ -27,8 +29,7 @@ pub struct Args { /// Output file path /// - If not provided, outputs to stdout - // TODO: implement image output - // /// - With image file extensions, outputs an image file + /// - With image file extensions, outputs an image file #[arg( short = 'o', long = "output", @@ -43,13 +44,14 @@ pub struct Args { pub output_positional: Option, /// Palette output format - #[arg( - short = 'f', - long = "format", - default_value = "hex", - value_name = "fmt" - )] + #[arg(short = 'f', long = "format", value_name = "fmt")] pub format: Option, + + // TODO: add input alpha policy for opaque output images + // /// Transparency policy when input has alpha but output does not + // #[arg(short = 'p', long = "alpha-policy", value_name = "policy",)] + // pub alpha_policy: AlphaPolicy, + // TODO: implement multi-threading // /// Number of workers to use [default: core count] // #[arg(short = 'j', long = "jobs", @@ -59,35 +61,43 @@ pub struct Args { } /// semantic validation of arguments +/// - `--format` cannot be specified when outputting an image file +/// - some image formats do not support alpha (eg. jpg) pub fn semantically_validate(args: &Args) { + // check if `--format` is specified AND output has image file extension if args.format.is_some() && (args.output.clone()) .or(args.output_positional.clone()) - .is_some_and(is_image_file_path) + .is_some_and(|p| ImageFormat::from_path(p).is_ok()) { - Args::command() - .error( + err_exit( + clap::error::ErrorKind::ArgumentConflict, + "cannot specify color-code format when outputting an image file.", + ); + } + + // check if output image format supports alpha channel + let output_opt = args.output.clone().or(args.output_positional.clone()); + if args.alpha && output_opt.is_some() { + let output_file = output_opt.unwrap(); + let filetype = ImageFormat::from_path(&output_file); + + use ImageFormat::*; + if matches!(filetype, Ok(Jpeg | Bmp | Pnm | Tiff)) { + err_exit( clap::error::ErrorKind::ArgumentConflict, - "cannot specify color format when outputting an image file.", - ) - .exit(); + format!( + "the `{:?}` image format does not support alpha.", + filetype.unwrap(), + ), + ); + } } } -// TODO: implement image output -pub fn is_image_file_path

(file: P) -> bool -where - P: AsRef, -{ - file.as_ref() - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| { - matches!( - e.to_lowercase().as_str(), - "png" | "jpg" | "jpeg" | "bmp" | "tiff" | "ppm" - ) - }) +/// shorthand for `Args::command().error(...).exit()` +pub fn err_exit(kind: clap::error::ErrorKind, message: impl std::fmt::Display) { + Args::command().error(kind, message).exit() } // TODO: implement static logger functionality for parsed arguments diff --git a/src/colors.rs b/src/colors.rs index ee03a23..a21e79b 100644 --- a/src/colors.rs +++ b/src/colors.rs @@ -1,6 +1,7 @@ -use crate::kmeans::Kmeansable; use image::Rgba; +use crate::kmeans::Kmeansable; + // TODO: implement k-means for `Rgb` pixel format // for slightly less mem-usage and faster calculation diff --git a/src/main.rs b/src/main.rs index ada2948..d5569cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,13 @@ -use clap::{CommandFactory, Parser}; +use clap::*; +use image::*; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::colors::ColorFormat; - mod cli; mod colors; mod kmeans; +use crate::colors::ColorFormat; + fn main() { let args = cli::Args::parse(); cli::semantically_validate(&args); @@ -34,7 +35,7 @@ fn main() { // run kmeans let pixels = img.pixels().cloned().collect::>(); - let (clusters, _assignments) = context.k_means(&pixels, args.number, args.iterations); + let (clusters, assignments) = context.k_means(&pixels, args.number, args.iterations); // handle output match args.output.or(args.output_positional) { @@ -45,8 +46,10 @@ fn main() { &args.format.unwrap_or_default(), ), - // TODO: implement image output - // Some(output_file) if is_image_file_path(&output_file) => image_file_handler(&clusters, &assignments, output_file), + Some(output_file) if ImageFormat::from_path(&output_file).is_ok() => { + image_file_handler(&img, &clusters, &assignments, args.alpha, output_file) + } + Some(output_file) => { let mut file = std::fs::File::create(output_file).expect("failed to create output file"); @@ -60,6 +63,7 @@ fn main() { } } +/// handle palette output to terminal or file fn palette_handler( clusters: &[image::Rgba], writer: &mut W, @@ -82,3 +86,50 @@ fn palette_handler( writeln!(writer).expect("failed to write color to output"); } } + +/// handle image output to file +fn image_file_handler( + img: &ImageBuffer, Vec>, + clusters: &[image::Rgba], + assignments: &[usize], + alpha: bool, + output_file: String, +) { + let (width, height) = img.dimensions(); + + // create new image by replacing each pixel with its cluster center + let quantized = assignments + .iter() + .flat_map(|&i| &clusters[i].0[..if alpha { 4 } else { 3 }]) + .copied() + .collect::>(); + + let status = match alpha { + true => { + let img = ImageBuffer::from_vec(width, height, quantized); + let img: ImageBuffer, _> = img.expect("failed to create quantized image"); + img.save(&output_file) + } + false => { + let img = ImageBuffer::from_vec(width, height, quantized); + let img: ImageBuffer, _> = img.expect("failed to create quantized image"); + img.save(&output_file) + } + }; + + // TODO: better errors handling logger + // save image with inferred format + match status { + Ok(_) => println!("saved quantized image to {}", output_file), + Err(err) => { + // errors here are unexpected, since extension alpha-capability + // is validated in `cli::semantically_validate` + cli::err_exit( + clap::error::ErrorKind::InvalidValue, + "unexpectedly failed to save quantized image.\n".to_string() + + "try checking the output file format. (does it support alpha?)\n" + + &format!(" ({err})"), + ); + } + } +}