initial commit: version 0.5

This commit is contained in:
mxhagen 2025-09-13 22:54:01 +02:00
commit 6933239b10
9 changed files with 1865 additions and 0 deletions

4
.gitignore vendored Normal file
View File

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

1352
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "qtizer"
version = "0.5.0"
edition = "2024"
description = "Quantization/palette-generation tool using k-means clustering on pixel data"
readme = "README.md"
license = "MIT"
[dependencies]
clap = { version = "4.5.47", features = ["derive"] }
image = "0.25.8"
rand = "0.9.2"
[profile.release]
codegen-units = 1
lto = true
panic = "abort"
strip = true

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

60
README.md Normal file
View File

@ -0,0 +1,60 @@
# 🎨 qtizer
command line quantization/palette-generation tool using k-means clustering on pixel data
- [features](#features)
- [usage](#usage)
- [installation](#installation)
## features
- hex and rgb formats
- output with color previews
- various supported file types
## usage
```
Usage: qtizer [OPTIONS] <input> [output]
Arguments:
<input> Input file path
[output] Output file path
Options:
-k <count> Number of colors to quantize to [default: 8]
-n <count> Number of k-means iterations to perform [default: 5]
-a, --with-alpha Include alpha channel
-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]
-h, --help Print help (see more with '--help')
-V, --version Print version
```
Example (output is colored accordingly in terminals):
```sh
$ qtizer wallpaper.png -k 3 -af rgb
rgba(254, 254, 254, 0)
rgba(191, 150, 132, 254)
rgba(48, 45, 51, 254)
```
## installation
```sh
git clone https://github.com/mxhagen/qtizer
```
```sh
cd qtizer; cargo build --release
```
```sh
sudo cp target/release/qtizer /usr/local/bin
```

93
src/cli.rs Normal file
View File

@ -0,0 +1,93 @@
use crate::colors::ColorFormat;
use clap::*;
/// Quantization/palette-generation tool using k-means clustering on pixel dataI
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Args {
/// Input file path
#[arg(index = 1, value_name = "input")]
pub file_path: String,
/// Number of colors to quantize to
#[arg(short = 'k', default_value_t = 8, value_name = "count")]
pub number: usize,
/// Number of k-means iterations to perform
#[arg(short = 'n', default_value_t = 5, value_name = "count")]
pub iterations: usize,
/// Include alpha channel
#[arg(short = 'a', long = "with-alpha", default_value_t = false)]
pub alpha: bool,
/// Optional RNG seed for reproducible results
#[arg(short = 's', long = "seed", value_name = "number")]
pub seed: Option<u64>,
/// Output file path
/// - If not provided, outputs to stdout
// TODO: implement image output
// /// - With image file extensions, outputs an image file
#[arg(
short = 'o',
long = "output",
conflicts_with = "output_positional",
value_name = "output",
verbatim_doc_comment
)]
pub output: Option<String>,
/// Output file path
#[arg(index = 2, conflicts_with = "output", value_name = "output")]
pub output_positional: Option<String>,
/// Palette output format
#[arg(
short = 'f',
long = "format",
default_value = "hex",
value_name = "fmt"
)]
pub format: Option<ColorFormat>,
// TODO: implement multi-threading
// /// Number of workers to use [default: core count]
// #[arg(short = 'j', long = "jobs",
// value_parser = clap::value_parser!(u32).range(1..),
// value_name = "count")]
// pub jobs: usize,
}
/// semantic validation of arguments
pub fn semantically_validate(args: &Args) {
if args.format.is_some()
&& (args.output.clone())
.or(args.output_positional.clone())
.is_some_and(is_image_file_path)
{
Args::command()
.error(
clap::error::ErrorKind::ArgumentConflict,
"cannot specify color format when outputting an image file.",
)
.exit();
}
}
// 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"
)
})
}
// TODO: implement static logger functionality for parsed arguments

146
src/colors.rs Normal file
View File

@ -0,0 +1,146 @@
use crate::kmeans::Kmeansable;
use image::Rgba;
// TODO: implement k-means for `Rgb<u8>` pixel format
// for slightly less mem-usage and faster calculation
impl Kmeansable for Rgba<u8> {
type Sum = Rgba<u32>;
/// black, transparent
fn zero() -> Self::Sum {
Rgba([0, 0, 0, 0])
}
/// euclidean distance between two rgba colors
fn distance(&self, other: &Self) -> f64 {
let dr = (self[0] as f64) - (other[0] as f64);
let dg = (self[1] as f64) - (other[1] as f64);
let db = (self[2] as f64) - (other[2] as f64);
let da = (self[3] as f64) - (other[3] as f64);
(dr * dr + dg * dg + db * db + da * da).sqrt()
}
/// add two rgba colors, returning a u32 sum to avoid overflow
fn add(sum: &Self::Sum, other: &Self) -> Self::Sum {
Rgba([
sum[0] + other[0] as u32,
sum[1] + other[1] as u32,
sum[2] + other[2] as u32,
sum[3] + other[3] as u32,
])
}
/// calculate the mean of a sum of rgba colors and a count
fn div(sum: &Self::Sum, count: usize) -> Self {
if count == 0 {
// TODO: better error handling via static logger
panic!("tried to calculate mean of 0 colors (division by zero)");
}
Rgba([
(sum[0] / count as u32) as u8,
(sum[1] / count as u32) as u8,
(sum[2] / count as u32) as u8,
(sum[3] / count as u32) as u8,
])
}
}
/// calculate the rgba brightness (luminance)
pub fn brightness(&Rgba([r, g, b, _]): &Rgba<u8>) -> u32 {
((0.299 * r as f32) + (0.587 * g as f32) + (0.114 * b as f32)) as u32
}
/// color code output format
#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
pub enum ColorFormat {
/// `#rrggbb` or `#rrggbbaa`
#[default]
Hex,
/// `rgb(r, g, b)` or `rgba(r, g, b, a)`
Rgb,
}
use ColorFormat::*;
impl ColorFormat {
/// pretty print a color code in the format
/// when writing to terminals, uses ansi escape codes for color preview
pub fn pretty_print_color_code<W>(
format: &ColorFormat,
writer: &mut W,
color: &image::Rgba<u8>,
with_alpha: bool,
) where
W: std::io::Write,
{
match format {
Hex => Self::colored_with_format(writer, color, with_alpha, Self::hex_color_code),
Rgb => Self::colored_with_format(writer, color, with_alpha, Self::rgb_color_code),
}
}
/// pretty print wrapper that colors output
/// given a callback providing the actual color formatting
fn colored_with_format<W>(
writer: &mut W,
color: &image::Rgba<u8>,
with_alpha: bool,
callback: fn(&mut W, &image::Rgba<u8>, bool),
) where
W: std::io::Write,
{
use std::io::IsTerminal;
let colorize = std::io::stdout().is_terminal();
if !colorize {
// just print formatted color, no ansi codes
return callback(writer, color, with_alpha);
}
// ensure text has enough contrast to colored background
match brightness(&color) {
..128 => write!(writer, "\x1b[38;2;255;255;255m"), // dark => white text
_ => write!(writer, "\x1b[38;2;0;0;0m"), // light => black text
}
.expect("failed to write output");
// print ansi codes for colored background
write!(writer, "\x1b[48;2;{};{};{}m", color[0], color[1], color[2],)
.expect("failed to write output");
// call the actual color printing function
callback(writer, color, with_alpha);
// reset colors
write!(writer, "\x1b[0m").expect("failed to write output");
}
/// print uncolored hex color code, with optional alpha
fn hex_color_code<W>(writer: &mut W, color: &image::Rgba<u8>, with_alpha: bool)
where
W: std::io::Write,
{
let Rgba([r, g, b, a]) = color;
write!(writer, "#{:02x}{:02x}{:02x}", r, g, b).expect("failed to write output");
if with_alpha {
write!(writer, "{:02x}", a).expect("failed to write output");
}
}
/// print uncolored rgb color code, with optional alpha
fn rgb_color_code<W>(writer: &mut W, color: &image::Rgba<u8>, with_alpha: bool)
where
W: std::io::Write,
{
let Rgba([r, g, b, a]) = color;
match with_alpha {
true => write!(writer, "rgba({}, {}, {}, {})", r, g, b, a),
false => write!(writer, "rgb({}, {}, {})", r, g, b),
}
.expect("failed to write output")
}
}

89
src/kmeans.rs Normal file
View File

@ -0,0 +1,89 @@
use rand::{SeedableRng, rngs::SmallRng, seq::IndexedRandom};
/// trait for types that can be clustered using k-means
pub trait Kmeansable {
/// output type for summation during mean calculation
type Sum: Clone + std::fmt::Debug;
/// initial value for sum
fn zero() -> Self::Sum;
/// distance function, according to which clustering is performed
fn distance(&self, other: &Self) -> f64;
/// summation for mean calculation
fn add(sum: &Self::Sum, other: &Self) -> Self::Sum;
/// division for mean calculation
fn div(sum: &Self::Sum, count: usize) -> Self;
}
// TODO: look for speedups before parallelizing
// - k-d tree for nearest neighbor search?
// - triangle inequality to skip distance calculations?
/// context for k-means clustering, containing an rng to initialize clusters
pub struct Context<R = SmallRng>
where
R: rand::Rng,
{
rng: R,
}
impl Context<SmallRng> {
/// k-means clustering for pixel data
///
/// returns (clusters, assignments), such that for any given `x = assignments[i]`, `data[i]` belongs to `clusters[x]`
pub fn k_means<T>(&mut self, data: &[T], k: usize, iterations: usize) -> (Vec<T>, Vec<usize>)
where
T: Kmeansable + Clone,
{
let mut assignments: Vec<usize> = vec![0; data.len()];
let mut clusters = data
.choose_multiple(&mut self.rng, k)
.cloned()
.collect::<Vec<_>>();
for i in 0..iterations {
// TODO: implement static logger functionality for progress
eprintln!("processing k-means iteration {}/{}...", i + 1, iterations);
// assign each point to the nearest cluster
for (i, point) in data.iter().enumerate() {
let min_idx = clusters
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| f64::total_cmp(&a.distance(point), &b.distance(point)))
.unwrap()
.0;
assignments[i] = min_idx;
}
// move cluster to mean of its assigned points
let mut counts: Vec<usize> = vec![0; k];
let mut sums = vec![T::zero(); k];
for (i, point) in data.iter().enumerate() {
let cluster_idx = assignments[i];
counts[cluster_idx] += 1;
sums[cluster_idx] = T::add(&sums[cluster_idx], point);
}
for i in 0..k {
if counts[i] != 0 {
clusters[i] = T::div(&sums[i].clone(), counts[i]);
}
}
}
(clusters, assignments)
}
/// create a new context with a seed
pub fn new(seed: u64) -> Self {
Self {
rng: SmallRng::seed_from_u64(seed),
}
}
}

84
src/main.rs Normal file
View File

@ -0,0 +1,84 @@
use clap::{CommandFactory, Parser};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::colors::ColorFormat;
mod cli;
mod colors;
mod kmeans;
fn main() {
let args = cli::Args::parse();
cli::semantically_validate(&args);
let seed = args.seed.unwrap_or_else(|| {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("you are a time traveler (system time < unix epoch)")
.as_millis();
// least significant 64 bits
(millis & u64::MAX as u128) as u64
});
let mut context = kmeans::Context::new(seed);
// TODO: use rgb only, when use-alpha is false
// with this refactor, also look at storing results & necessary config in
// `Context` in order to pass it uniformly to the various handlers
// open file and parse image
let img = image::open(args.file_path)
.expect("failed to open image")
.to_rgba8();
// run kmeans
let pixels = img.pixels().cloned().collect::<Vec<_>>();
let (clusters, _assignments) = context.k_means(&pixels, args.number, args.iterations);
// handle output
match args.output.or(args.output_positional) {
None => palette_handler(
&clusters,
&mut std::io::stdout(),
args.alpha,
&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) => {
let mut file =
std::fs::File::create(output_file).expect("failed to create output file");
palette_handler(
&clusters,
&mut file,
args.alpha,
&args.format.unwrap_or_default(),
);
}
}
}
fn palette_handler<W>(
clusters: &[image::Rgba<u8>],
writer: &mut W,
alpha: bool,
format: &ColorFormat,
) where
W: std::io::Write,
{
// sort colors by alpha and brightness
let mut clusters = clusters.to_vec();
clusters.sort_by(|a, b| {
a[3].cmp(&b[3]) // alpha first
.then_with(|| u32::cmp(&colors::brightness(b), &colors::brightness(a))) // then brightness
});
// output palette as hex #rrggbbaa
// output with ansi escape codes for color preview in terminal
for color in &clusters {
ColorFormat::pretty_print_color_code(format, writer, color, alpha);
writeln!(writer).expect("failed to write color to output");
}
}