initial commit: version 0.5
This commit is contained in:
commit
6933239b10
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/target
|
||||
|
||||
# test images
|
||||
*.png
|
||||
1352
Cargo.lock
generated
Normal file
1352
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal 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
19
LICENSE
Normal 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
60
README.md
Normal 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
93
src/cli.rs
Normal 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
146
src/colors.rs
Normal 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
89
src/kmeans.rs
Normal 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
84
src/main.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user