version 0.6: implement image file output

This commit is contained in:
mxhagen 2025-09-14 20:46:06 +02:00
parent 6933239b10
commit 0fb6e337c2
7 changed files with 110 additions and 43 deletions

3
.gitignore vendored
View File

@ -1,4 +1 @@
/target
# test images
*.png

2
Cargo.lock generated
View File

@ -765,7 +765,7 @@ dependencies = [
[[package]]
name = "qtizer"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"clap",
"image",

View File

@ -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"

View File

@ -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 <number> Optional RNG seed for reproducible results
-o, --output <output> Output file path
- If not provided, outputs to stdout
-f, --format <fmt> Palette output format [default: hex] [possible values: hex, rgb]
- With image file extensions, outputs an image file
-f, --format <fmt> 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

View File

@ -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<String>,
/// 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<ColorFormat>,
// 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<P>(file: P) -> bool
where
P: AsRef<std::path::Path>,
{
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

View File

@ -1,6 +1,7 @@
use crate::kmeans::Kmeansable;
use image::Rgba;
use crate::kmeans::Kmeansable;
// TODO: implement k-means for `Rgb<u8>` pixel format
// for slightly less mem-usage and faster calculation

View File

@ -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::<Vec<_>>();
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<W>(
clusters: &[image::Rgba<u8>],
writer: &mut W,
@ -82,3 +86,50 @@ fn palette_handler<W>(
writeln!(writer).expect("failed to write color to output");
}
}
/// handle image output to file
fn image_file_handler(
img: &ImageBuffer<Rgba<u8>, Vec<u8>>,
clusters: &[image::Rgba<u8>],
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::<Vec<_>>();
let status = match alpha {
true => {
let img = ImageBuffer::from_vec(width, height, quantized);
let img: ImageBuffer<Rgba<u8>, _> = img.expect("failed to create quantized image");
img.save(&output_file)
}
false => {
let img = ImageBuffer::from_vec(width, height, quantized);
let img: ImageBuffer<Rgb<u8>, _> = 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})"),
);
}
}
}