Color palettes

I love the Libby app on my phone, which allows me to listen to audiobooks from the library. Not only does it work really well, it's beautiful to look at. And it changes color to match the book cover!

./libby.png

The background is sort of a blurry version of the book cover, but then the whole area at the top as well as the main control buttons at the bottom are a color taken from the book cover. A different book changes the whole look of the app! This one is sort of bluish, but The Secrets of Hartwood Hall is red. I started thinking about how they did that.

Rust has a pretty nice imaging library, so I searched for color palette and quickly found palette_extract. I used it to write a little program to dump the top nine colors.

/// modified median cut quantization
use image::Rgba;
use palette_extract::{MaxColors, PixelEncoding, PixelFilter, Quality};

pub fn extract(pixels: &[u8], max_colors: u8) -> Vec<Rgba<u8>> {
    // extract the color palette
    // Max colors defaults to 10, which is apparently 9.

    let palette = palette_extract::get_palette_with_options(
        pixels,
        PixelEncoding::Rgb,
        Quality::new(5),
        MaxColors::new(max_colors),
        PixelFilter::White,
    );

    // output the extracted color palette
    palette.iter().for_each(|x| println!("{:?}", x));

    // return a vector of Rgba, not a vector of Color
    palette.iter().map(|c| Rgba([c.r, c.g, c.b, 255])).collect()
}

Calling that gives

./color-palette-emacs.png

That's easy, but how to see the colors? It's nice that the default output includes those hex codes because those are standard. If we toggle rainbow-mode in Emacs, we see

./color-palette-rainbow.png

Neat! What if I just write colored squares to the terminal? The amazing colored crate makes this super-easy!

/// Save the given color palette in the terminal as a strip of squares of color.
fn show_palette(colors: &[Rgba<u8>]) {
    for c in colors {
        let s = "■".truecolor(c.0[0], c.0[1], c.0[2]);
        print!("{s}");
    }
    println!();
}

Nice!

./color-palette-terminal.png

Too small? Maybe write it to an image file instead.

/// Save the given color palette in an image with a strip of squares of color.
fn save_palette(colors: &[Rgba<u8>], filename: &str) -> Result<(), Box<dyn Error>> {
    let h = 100;
    let w = 100;
    let x = w * (colors.len() as u32);
    let y = h;
    let v = vec![0; (x * y * 4) as usize];
    let mut palette = RgbaImage::from_raw(x, y, v).ok_or("cannot construct image")?;

    for i in 0..colors.len() {
        let x = (w * i as u32) as i32;
        draw_filled_rect_mut(&mut palette, Rect::at(x, 0).of_size(w, h), colors[i]);
    }

    Ok(palette.save(filename)?)
}

I went back to crates.io for imageproc to do that.

./palette.png

Yeah, that works!

The palette_extract README also contains a couple of great links.

That second one talks about doing this three different ways.

  1. Simple Histogram Approach

  2. Median Cut Approach

  3. k-means Approach

And k-means is just one way to do clustering, so number 3 is really a whole nother rabbit hole. There is clearly more room for exploring here!

This is all great fun, but I don't think any of this is what Libby is doing. Using the GIMP color picker to grab that color from the phone screen, we see it's not any of the colors we've found so far.

./color-picker.png

I'm guessing they just choose a color by hand. (Boring.)