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