Skip to main content

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