Merge pull request #38 from alexwlchan/best-against-background
- ID
30cdfd7- date
2024-05-12 13:02:25+00:00- author
Alex Chan <alex@alexwlchan.net>- parents
40ef7df,45268c2- message
Merge pull request #38 from alexwlchan/best-against-background Add a flag for finding the best colour to go with a given background colour- changed files
5 files, 143 additions, 26 deletions
Changed files
src/cli.rs (1115) → src/cli.rs (1384)
diff --git a/src/cli.rs b/src/cli.rs
index b51a0ad..d1c0b87 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,4 +1,5 @@
use clap::{Arg, ArgAction, Command};
+use palette::Srgb;
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -14,13 +15,19 @@ pub fn app() -> clap::Command {
.index(1),
)
.arg(
- Arg::new("MAX-COLOURS")
+ Arg::new("MAX_COLOURS")
.long("max-colours")
.help("how many colours to find")
.value_parser(value_parser!(usize))
.default_value("5"),
)
.arg(
+ Arg::new("BACKGROUND_HEX")
+ .long("best-against-bg")
+ .help("find a single colour that will look best against this background")
+ .value_parser(value_parser!(Srgb<u8>)),
+ )
+ .arg(
Arg::new("no-palette")
.long("no-palette")
.help("Just print the hex values, not colour previews")
src/find_dominant_colors.rs (734) → src/find_dominant_colors.rs (2737)
diff --git a/src/find_dominant_colors.rs b/src/find_dominant_colors.rs
index 88ed12f..2e03315 100644
--- a/src/find_dominant_colors.rs
+++ b/src/find_dominant_colors.rs
@@ -1,7 +1,7 @@
use kmeans_colors::get_kmeans_hamerly;
-use palette::{FromColor, Lab, Srgb};
+use palette::{color_difference::Wcag21RelativeContrast, FromColor, Lab, Srgb};
-pub fn find_dominant_colors(lab: &Vec<Lab>, max_colors: usize) -> Vec<Srgb<u8>> {
+pub fn find_dominant_colors(lab: &Vec<Lab>, max_colors: usize) -> Vec<Lab> {
// This is based on code from the kmeans-colors binary, but with a bunch of
// the options stripped out.
// See https://github.com/okaneco/kmeans-colors/blob/0.5.0/src/bin/kmeans_colors/app.rs
@@ -12,11 +12,70 @@ pub fn find_dominant_colors(lab: &Vec<Lab>, max_colors: usize) -> Vec<Srgb<u8>>
let result = get_kmeans_hamerly(max_colors, max_iterations, converge, verbose, lab, seed);
- let rgb: Vec<Srgb<u8>> = result
- .centroids
- .iter()
- .map(|x| Srgb::from_color(*x).into_format())
- .collect::<Vec<Srgb<u8>>>();
+ result.centroids
+}
+
+pub fn choose_best_color_for_bg(colors: Vec<Lab>, background: &Srgb<u8>) -> Vec<Lab> {
+ // Start by adding black and white to the list of candidate colors.
+ //
+ // They're boring, but any background colour will always have sufficient
+ // contrast with at least one of them.
+ let black = Srgb::new(0.0, 0.0, 0.0);
+ let white = Srgb::new(1.0, 1.0, 1.0);
+
+ // I suspect this is not the most "technically correct" way to convert
+ // an Srgb<u8> to a Srgb<f32>, but it's good enough for my purposes.
+ let mut extended_colors: Vec<Srgb<f32>> =
+ colors.iter().map(|c| Srgb::<f32>::from_color(*c)).collect();
+
+ extended_colors.push(black);
+ extended_colors.push(white);
+
+ let background: Srgb<f32> = Srgb::new(
+ background.red as f32 / 255.0,
+ background.green as f32 / 255.0,
+ background.blue as f32 / 255.0,
+ );
+
+ // Filter for colors which meet the min contrast ratio
+ let allowed_colors: Vec<Srgb<f32>> = extended_colors
+ .into_iter()
+ .filter(|c| background.has_min_contrast_graphics(*c))
+ .collect();
+
+ // Now pick the color with the highest saturation among the remaining.
+ let best_color: Srgb<f32> = allowed_colors
+ .into_iter()
+ .max_by(|color_a, color_b| {
+ saturation(color_a)
+ .partial_cmp(&saturation(color_b))
+ .unwrap()
+ })
+ .unwrap();
+
+ vec![Lab::from_color(best_color)]
+}
+
+// Based on https://filmentor.academy/en/blogs/news/die-wunderbare-welt-der-mathematik-fur-farben
+fn saturation(c: &Srgb<f32>) -> f32 {
+ let min_rgb: f32 = vec![c.red, c.green, c.blue]
+ .into_iter()
+ .min_by(|a, b| a.partial_cmp(b).unwrap())
+ .unwrap();
+ let max_rgb: f32 = vec![c.red, c.green, c.blue]
+ .into_iter()
+ .max_by(|a, b| a.partial_cmp(b).unwrap())
+ .unwrap();
+
+ if min_rgb == max_rgb {
+ return 0.0;
+ }
+
+ let luminosity: f32 = 0.5 * (min_rgb + max_rgb);
- rgb
+ if luminosity == 1.0 {
+ 0.0
+ } else {
+ (max_rgb - min_rgb) / (1.0 - (2.0 * luminosity - 1.0).abs())
+ }
}
src/main.rs (7440) → src/main.rs (8026)
diff --git a/src/main.rs b/src/main.rs
index ff24d85..705a627 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,11 +3,12 @@
#[macro_use]
extern crate clap;
-use palette::Lab;
+use palette::{FromColor, Lab, Srgb};
mod cli;
mod find_dominant_colors;
mod get_image_colors;
+mod printing;
fn main() {
let matches = cli::app().get_matches();
@@ -17,27 +18,27 @@ fn main() {
.expect("`path` is required");
let max_colours: usize = *matches
- .get_one::<usize>("MAX-COLOURS")
+ .get_one::<usize>("MAX_COLOURS")
.expect("`max-colours` is required");
let lab: Vec<Lab> = get_image_colors::get_image_colors(&path);
let dominant_colors = find_dominant_colors::find_dominant_colors(&lab, max_colours);
- // This uses ANSI escape sequences and Unicode block elements to print
- // a palette of hex strings which are coloured to match.
- // See https://alexwlchan.net/2021/04/coloured-squares/
- for c in dominant_colors {
- let display_value = format!("#{:02x}{:02x}{:02x}", c.red, c.green, c.blue);
-
- if matches.get_flag("no-palette") {
- println!("{}", display_value);
- } else {
- println!(
- "\x1B[38;2;{};{};{}m▇ {}\x1B[0m",
- c.red, c.green, c.blue, display_value
- );
- }
+ let background = matches.get_one::<Srgb<u8>>("BACKGROUND_HEX");
+
+ let selected_colors = match background {
+ Some(bg) => find_dominant_colors::choose_best_color_for_bg(dominant_colors.clone(), bg),
+ None => dominant_colors,
+ };
+
+ let rgb_colors = selected_colors
+ .iter()
+ .map(|c| Srgb::from_color(*c).into_format())
+ .collect::<Vec<Srgb<u8>>>();
+
+ for c in rgb_colors {
+ printing::print_color(c, &background, matches.get_flag("no-palette"));
}
}
@@ -168,7 +169,7 @@ mod tests {
assert_eq!(output.stdout, "");
assert_eq!(
output.stderr,
- "error: invalid value 'NaN' for '--max-colours <MAX-COLOURS>': invalid digit found in string\n\nFor more information, try '--help'.\n"
+ "error: invalid value 'NaN' for '--max-colours <MAX_COLOURS>': invalid digit found in string\n\nFor more information, try '--help'.\n"
);
}
@@ -220,6 +221,30 @@ mod tests {
);
}
+ #[test]
+ fn it_chooses_the_right_color_for_a_dark_background() {
+ let output = get_success(&[
+ "src/tests/stripes.png",
+ "--max-colours=5",
+ "--best-against-bg=#222",
+ "--no-palette",
+ ]);
+
+ assert_eq!(output.stdout, "#d4fb79\n");
+ }
+
+ #[test]
+ fn it_chooses_the_right_color_for_a_light_background() {
+ let output = get_success(&[
+ "src/tests/stripes.png",
+ "--max-colours=5",
+ "--best-against-bg=#fff",
+ "--no-palette",
+ ]);
+
+ assert_eq!(output.stdout, "#693900\n");
+ }
+
struct DcOutput {
exit_code: i32,
stdout: String,
src/printing.rs (0) → src/printing.rs (881)
diff --git a/src/printing.rs b/src/printing.rs
new file mode 100644
index 0000000..8cc5441
--- /dev/null
+++ b/src/printing.rs
@@ -0,0 +1,26 @@
+use palette::Srgb;
+
+// Print the colours to the terminal, using ANSI escape codes to
+// apply formatting if desired.
+//
+// See https://alexwlchan.net/2021/04/coloured-squares/
+// See: https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797?permalink_comment_id=3857871
+pub fn print_color(c: Srgb<u8>, background: &Option<&Srgb<u8>>, no_palette: bool) {
+ let display_value = format!("#{:02x}{:02x}{:02x}", c.red, c.green, c.blue);
+
+ if no_palette {
+ println!("{}", display_value);
+ } else {
+ // If a background colour is specified, print it behind the
+ // hex strings.
+ match background {
+ Some(bg) => print!("\x1B[48;2;{};{};{}m", bg.red, bg.green, bg.blue),
+ _ => (),
+ };
+
+ println!(
+ "\x1B[38;2;{};{};{}m▇ {}\x1B[0m",
+ c.red, c.green, c.blue, display_value
+ );
+ }
+}
src/tests/stripes.png (0) → src/tests/stripes.png (1778)
diff --git a/src/tests/stripes.png b/src/tests/stripes.png
new file mode 100644
index 0000000..51b3759
Binary files /dev/null and b/src/tests/stripes.png differ