Compare commits

..

No commits in common. "master" and "v0.6.0" have entirely different histories.

6 changed files with 115 additions and 181 deletions

2
Cargo.lock generated
View File

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

View File

@ -1,6 +1,6 @@
[package] [package]
name = "qtizer" name = "qtizer"
version = "0.7.0" version = "0.6.0"
edition = "2024" edition = "2024"
description = "Quantization/palette-generation tool using k-means clustering on pixel data" description = "Quantization/palette-generation tool using k-means clustering on pixel data"
readme = "README.md" readme = "README.md"

View File

@ -1,7 +1,7 @@
use clap::*; use clap::*;
use image::*; use image::*;
use crate::colors::ColorCodeFormat; use crate::colors::ColorFormat;
/// Quantization/palette-generation tool using k-means clustering on pixel dataI /// Quantization/palette-generation tool using k-means clustering on pixel dataI
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -45,7 +45,7 @@ pub struct Args {
/// Palette output format /// Palette output format
#[arg(short = 'f', long = "format", value_name = "fmt")] #[arg(short = 'f', long = "format", value_name = "fmt")]
pub format: Option<ColorCodeFormat>, pub format: Option<ColorFormat>,
// TODO: add input alpha policy for opaque output images // TODO: add input alpha policy for opaque output images
// /// Transparency policy when input has alpha but output does not // /// Transparency policy when input has alpha but output does not

View File

@ -1,92 +1,94 @@
use image::*; use image::Rgba;
use crate::kmeans::Kmeansable; use crate::kmeans::Kmeansable;
/// marker trait for usable color types // TODO: implement k-means for `Rgb<u8>` pixel format
#[derive(Clone, Debug, PartialEq, Eq)] // for slightly less mem-usage and faster calculation
pub struct Color {
pub color_type: ColorType,
pub data: Vec<u8>,
}
impl Kmeansable for Color { impl Kmeansable for Rgba<u8> {
type Sum = Vec<u32>; type Sum = Rgba<u32>;
/// black, transparent
fn zero() -> Self::Sum { fn zero() -> Self::Sum {
vec![0; 4] Rgba([0, 0, 0, 0])
} }
/// distance function, according to which clustering is performed /// euclidean distance between two rgba colors
/// (impl avoids sqrt for performance -- uses squared distance)
fn distance(&self, other: &Self) -> f64 { fn distance(&self, other: &Self) -> f64 {
self.data let dr = (self[0] as f64) - (other[0] as f64);
.iter() let dg = (self[1] as f64) - (other[1] as f64);
.zip(other.data.iter()) let db = (self[2] as f64) - (other[2] as f64);
.map(|(a, b)| ((*a as f64) - (*b as f64)).powi(2)) let da = (self[3] as f64) - (other[3] as f64);
.sum::<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 { fn add(sum: &Self::Sum, other: &Self) -> Self::Sum {
sum.iter() Rgba([
.zip(&other.data) sum[0] + other[0] as u32,
.map(|(a, b)| a + *b as u32) sum[1] + other[1] as u32,
.collect() 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 { fn div(sum: &Self::Sum, count: usize) -> Self {
let data = sum if count == 0 {
.iter() // TODO: better error handling via static logger
.map(|v| (v / count as u32) as u8) panic!("tried to calculate mean of 0 colors (division by zero)");
.collect::<Vec<u8>>();
Color {
color_type: match data.len() {
3 => ColorType::Rgb8,
4 => ColorType::Rgba8,
_ => unreachable!(
"invalid color length. only rgb or rgba colors should ever be used here."
),
},
data,
} }
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) /// calculate the rgba brightness (luminance)
pub fn brightness(color: &Color) -> u32 { pub fn brightness(&Rgba([r, g, b, _]): &Rgba<u8>) -> u32 {
let &[r, g, b, ..] = &color.data[..] else {
unreachable!("invalid color type. only rgb or rgba colors should ever be used here.");
};
((0.299 * r as f32) + (0.587 * g as f32) + (0.114 * b as f32)) as u32 ((0.299 * r as f32) + (0.587 * g as f32) + (0.114 * b as f32)) as u32
} }
/// color code output format /// color code output format
#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] #[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
pub enum ColorCodeFormat { pub enum ColorFormat {
/// `#rrggbb` or `#rrggbbaa` /// `#rrggbb` or `#rrggbbaa`
#[default] #[default]
Hex, Hex,
/// `rgb(r, g, b)` or `rgba(r, g, b, a)` /// `rgb(r, g, b)` or `rgba(r, g, b, a)`
Rgb, Rgb,
} }
use ColorFormat::*;
impl ColorCodeFormat { impl ColorFormat {
/// pretty print a color code in the format /// pretty print a color code in the format
/// when writing to terminals, uses ansi escape codes for color preview /// when writing to terminals, uses ansi escape codes for color preview
pub fn pretty_print_color_code<W>(format: &ColorCodeFormat, writer: &mut W, color: &Color) pub fn pretty_print_color_code<W>(
where format: &ColorFormat,
writer: &mut W,
color: &image::Rgba<u8>,
with_alpha: bool,
) where
W: std::io::Write, W: std::io::Write,
{ {
match format { match format {
ColorCodeFormat::Hex => Self::colored_with_format(writer, color, Self::hex_color_code), Hex => Self::colored_with_format(writer, color, with_alpha, Self::hex_color_code),
ColorCodeFormat::Rgb => Self::colored_with_format(writer, color, Self::rgb_color_code), Rgb => Self::colored_with_format(writer, color, with_alpha, Self::rgb_color_code),
} }
} }
/// pretty print wrapper that colors output /// pretty print wrapper that colors output
/// given a callback providing the actual color formatting /// given a callback providing the actual color formatting
fn colored_with_format<W>(writer: &mut W, color: &Color, callback: fn(&mut W, &Color)) fn colored_with_format<W>(
where writer: &mut W,
color: &image::Rgba<u8>,
with_alpha: bool,
callback: fn(&mut W, &image::Rgba<u8>, bool),
) where
W: std::io::Write, W: std::io::Write,
{ {
use std::io::IsTerminal; use std::io::IsTerminal;
@ -94,55 +96,51 @@ impl ColorCodeFormat {
if !colorize { if !colorize {
// just print formatted color, no ansi codes // just print formatted color, no ansi codes
return callback(writer, color); return callback(writer, color, with_alpha);
} }
// ensure text has enough contrast to colored background // ensure text has enough contrast to colored background
match brightness(color) { match brightness(&color) {
..128 => write!(writer, "\x1b[38;2;255;255;255m"), // dark => white text ..128 => write!(writer, "\x1b[38;2;255;255;255m"), // dark => white text
_ => write!(writer, "\x1b[38;2;0;0;0m"), // light => black text _ => write!(writer, "\x1b[38;2;0;0;0m"), // light => black text
} }
.expect("failed to write output"); .expect("failed to write output");
let &[r, g, b, ..] = &color.data[..] else {
unreachable!("invalid color type. only rgb or rgba colors should ever be used here.");
};
// print ansi codes for colored background // print ansi codes for colored background
write!(writer, "\x1b[48;2;{r};{g};{b}m").expect("failed to write output"); write!(writer, "\x1b[48;2;{};{};{}m", color[0], color[1], color[2],)
.expect("failed to write output");
// call the actual color printing function // call the actual color printing function
callback(writer, color); callback(writer, color, with_alpha);
// reset colors // reset colors
write!(writer, "\x1b[0m").expect("failed to write output"); write!(writer, "\x1b[0m").expect("failed to write output");
} }
/// print uncolored hex color code, with optional alpha /// print uncolored hex color code, with optional alpha
fn hex_color_code<W>(writer: &mut W, color: &Color) fn hex_color_code<W>(writer: &mut W, color: &image::Rgba<u8>, with_alpha: bool)
where where
W: std::io::Write, W: std::io::Write,
{ {
let c = &color.data; let Rgba([r, g, b, a]) = color;
write!(writer, "#{:02x}{:02x}{:02x}", c[0], c[1], c[2]).expect("failed to write output");
if color.color_type == ColorType::Rgba8 { write!(writer, "#{:02x}{:02x}{:02x}", r, g, b).expect("failed to write output");
write!(writer, "{:02x}", c[3]).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 /// print uncolored rgb color code, with optional alpha
fn rgb_color_code<W>(writer: &mut W, color: &Color) fn rgb_color_code<W>(writer: &mut W, color: &image::Rgba<u8>, with_alpha: bool)
where where
W: std::io::Write, W: std::io::Write,
{ {
let c = &color.data; let Rgba([r, g, b, a]) = color;
match color.color_type {
ColorType::Rgba8 => write!(writer, "rgba({}, {}, {}, {})", c[0], c[1], c[2], c[3]), match with_alpha {
ColorType::Rgb8 => write!(writer, "rgb({}, {}, {})", c[0], c[1], c[2]), true => write!(writer, "rgba({}, {}, {}, {})", r, g, b, a),
_ => unreachable!( false => write!(writer, "rgb({}, {}, {})", r, g, b),
"invalid color type. only rgb or rgba colors should ever be used here."
),
} }
.expect("failed to write output") .expect("failed to write output")
} }

View File

@ -44,69 +44,21 @@ impl Context<SmallRng> {
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// make cursor invisible
eprint!("\x1b[?25l");
for i in 0..iterations { for i in 0..iterations {
// TODO: implement static logger functionality for progress // TODO: implement static logger functionality for progress
// once implemented, replace other eprint(ln)! calls too eprintln!("processing k-means iteration {}/{}...", i + 1, iterations);
eprintln!(
"processing k-means iteration: [ {:>9} / {:>9} ]...",
i + 1,
iterations
);
// precompute cluster distances to skip some distance calculations later
// only set for i < j -- note: dist[i][j] == dist[j][i]
let mut cluster_distances = vec![vec![0.0; k]; k];
for i in 0..k {
for j in (i + 1)..k {
let dist = clusters[i].distance(&clusters[j]);
cluster_distances[i][j] = dist; // i < j case only
}
}
// assign each point to the nearest cluster // assign each point to the nearest cluster
for (i, point) in data.iter().enumerate() { for (i, point) in data.iter().enumerate() {
let mut closest_idx = 0; let min_idx = clusters
let mut closest_dist = clusters[0].distance(point); .iter()
.enumerate()
.min_by(|(_, a), (_, b)| f64::total_cmp(&a.distance(point), &b.distance(point)))
.unwrap()
.0;
// print progress every 500 points assignments[i] = min_idx;
if i % 500 == 0 {
if i > 0 {
// restore cursor position (write over previous status)
eprint!("\x1b[1F");
} }
let label_len = "processing k-means iteration".len();
eprintln!(
"{:>label_len$}: [ {:>9} / {:>9} ]...",
"assigning point",
i,
data.len()
);
}
for j in 1..k {
// skip distance calculation if the cluster is too far away
let (a, b) = (closest_idx.min(j), closest_idx.max(j));
if cluster_distances[a][b] >= 2.0 * closest_dist {
// d(c_j, c_min) >= 2 * d(p, c_min)
// d(p, c_j ) >= d(p, c_min)
continue;
}
let dist = clusters[j].distance(point);
if dist < closest_dist {
closest_dist = dist;
closest_idx = j;
}
}
assignments[i] = closest_idx;
}
// restore cursor position (write over previous status)
eprint!("\x1b[2F");
// move cluster to mean of its assigned points // move cluster to mean of its assigned points
let mut counts: Vec<usize> = vec![0; k]; let mut counts: Vec<usize> = vec![0; k];
@ -125,9 +77,6 @@ impl Context<SmallRng> {
} }
} }
// make cursor visible again
eprint!("\x1b[?25h");
(clusters, assignments) (clusters, assignments)
} }

View File

@ -6,7 +6,7 @@ mod cli;
mod colors; mod colors;
mod kmeans; mod kmeans;
use crate::colors::*; use crate::colors::ColorFormat;
fn main() { fn main() {
let args = cli::Args::parse(); let args = cli::Args::parse();
@ -24,29 +24,17 @@ fn main() {
let mut context = kmeans::Context::new(seed); let mut context = kmeans::Context::new(seed);
// open file and parse image // TODO: use rgb only, when use-alpha is false
let img = image::open(args.file_path).expect("failed to open image"); // with this refactor, also look at storing results & necessary config in
// `Context` in order to pass it uniformly to the various handlers
let pixels = match args.alpha { // open file and parse image
true => img let img = image::open(args.file_path)
.to_rgba8() .expect("failed to open image")
.pixels() .to_rgba8();
.map(|p| Color {
data: p.0.to_vec(),
color_type: ColorType::Rgba8,
})
.collect::<Vec<_>>(),
false => img
.to_rgb8()
.pixels()
.map(|p| Color {
data: p.0.to_vec(),
color_type: ColorType::Rgb8,
})
.collect::<Vec<_>>(),
};
// run kmeans // 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 // handle output
@ -54,86 +42,85 @@ fn main() {
None => palette_handler( None => palette_handler(
&clusters, &clusters,
&mut std::io::stdout(), &mut std::io::stdout(),
args.alpha,
&args.format.unwrap_or_default(), &args.format.unwrap_or_default(),
), ),
Some(output_file) if ImageFormat::from_path(&output_file).is_ok() => { Some(output_file) if ImageFormat::from_path(&output_file).is_ok() => {
let (width, height) = img.dimensions(); image_file_handler(&img, &clusters, &assignments, args.alpha, output_file)
image_file_handler(width, height, &clusters, &assignments, output_file);
} }
Some(output_file) => { Some(output_file) => {
let mut file = let mut file =
std::fs::File::create(output_file).expect("failed to create output file"); std::fs::File::create(output_file).expect("failed to create output file");
palette_handler(&clusters, &mut file, &args.format.unwrap_or_default()); palette_handler(
&clusters,
&mut file,
args.alpha,
&args.format.unwrap_or_default(),
);
} }
} }
} }
/// handle palette output to terminal or file /// handle palette output to terminal or file
fn palette_handler<W>(clusters: &[Color], writer: &mut W, format: &ColorCodeFormat) fn palette_handler<W>(
where clusters: &[image::Rgba<u8>],
writer: &mut W,
alpha: bool,
format: &ColorFormat,
) where
W: std::io::Write, W: std::io::Write,
{ {
// sort colors by alpha and brightness // sort colors by alpha and brightness
let mut clusters = clusters.to_vec(); let mut clusters = clusters.to_vec();
clusters.sort_by(|x, y| { clusters.sort_by(|a, b| {
let (&[r_x, g_x, b_x], &[r_y, g_y, b_y]) = (&x.data[..3], &y.data[..3]) else { a[3].cmp(&b[3]) // alpha first
unreachable!("invalid color type. only rgb or rgba colors should ever be used here."); .then_with(|| u32::cmp(&colors::brightness(b), &colors::brightness(a))) // then brightness
};
u32::cmp(&colors::brightness(y), &colors::brightness(x)) // descending brightness
.then_with(|| {
u32::from_be_bytes([r_x, g_x, b_x, 0]).cmp(&u32::from_be_bytes([r_y, g_y, b_y, 0]))
})
}); });
// output palette as hex #rrggbbaa // output palette as hex #rrggbbaa
// output with ansi escape codes for color preview in terminal // output with ansi escape codes for color preview in terminal
for color in &clusters { for color in &clusters {
ColorCodeFormat::pretty_print_color_code(format, writer, color); ColorFormat::pretty_print_color_code(format, writer, color, alpha);
writeln!(writer).expect("failed to write color to output"); writeln!(writer).expect("failed to write color to output");
} }
} }
/// handle image output to file /// handle image output to file
fn image_file_handler( fn image_file_handler(
width: u32, img: &ImageBuffer<Rgba<u8>, Vec<u8>>,
height: u32, clusters: &[image::Rgba<u8>],
clusters: &[Color],
assignments: &[usize], assignments: &[usize],
alpha: bool,
output_file: String, output_file: String,
) { ) {
let (width, height) = img.dimensions();
// create new image by replacing each pixel with its cluster center // create new image by replacing each pixel with its cluster center
let quantized = assignments let quantized = assignments
.iter() .iter()
.flat_map(|&i| &clusters[i].data) .flat_map(|&i| &clusters[i].0[..if alpha { 4 } else { 3 }])
.copied() .copied()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let status = match clusters.first() { let status = match alpha {
Some(c) if c.color_type == ColorType::Rgba8 => { true => {
let img = ImageBuffer::from_vec(width, height, quantized); let img = ImageBuffer::from_vec(width, height, quantized);
let img: ImageBuffer<Rgba<u8>, _> = img.expect("failed to create quantized image"); let img: ImageBuffer<Rgba<u8>, _> = img.expect("failed to create quantized image");
img.save(&output_file) img.save(&output_file)
} }
Some(c) if c.color_type == ColorType::Rgb8 => { false => {
let img = ImageBuffer::from_vec(width, height, quantized); let img = ImageBuffer::from_vec(width, height, quantized);
let img: ImageBuffer<Rgb<u8>, _> = img.expect("failed to create quantized image"); let img: ImageBuffer<Rgb<u8>, _> = img.expect("failed to create quantized image");
img.save(&output_file) img.save(&output_file)
} }
_ => {
cli::err_exit(
clap::error::ErrorKind::InvalidValue,
"unexpected error - this is a bug".to_string(),
);
unreachable!("should have exited");
}
}; };
// TODO: better errors handling logger // TODO: better errors handling logger
// save image with inferred format // save image with inferred format
match status { match status {
Ok(_) => println!("saved quantized image to {output_file}"), Ok(_) => println!("saved quantized image to {}", output_file),
Err(err) => { Err(err) => {
// errors here are unexpected, since extension alpha-capability // errors here are unexpected, since extension alpha-capability
// is validated in `cli::semantically_validate` // is validated in `cli::semantically_validate`