dominant_colours, a CLI tool for finding dominant colours in an image

At the weekend, I published dominant_colours, a command-line tool for finding the dominant colours of an image. It prints their hex codes to the terminal, along with a preview of the colour (in terminals that support ANSI escape codes). For example:

A photo of a red and white lighthouse set against a blue sky.
$ dominant_colours lighthouse.jpg
█ #e9e4d7
█ #858b88
█ #4576bb
█ #2c231b
█ #c53b4e

You can read the README on GitHub to learn how to install it and how to use it. In this post, I’ll explain how and why I wrote it.

Why did I write this?

I started thinking about dominant colours several years ago, when I wrote Getting a tint colour from an image with Python and k‑means.

It works pretty well, and I started using it in a bunch of projects – and for each new project, I’d copy and paste the code. Different copies started to diverge as I tweaked and optimised them, and I no longer had a canonical implementation of this idea.

I wanted to get back to a single implementation, so that I could put all my ideas in one place – rather than having them spread over multiple projects.

I also wanted it to be faster. I use this code in a bunch of interactive scripts, and the old implementation took a second or so to run. That may not seem like much, but it gets noticeable if you have to wait for it regularly.

How does it work?

dominant_colours is written in Rust.

The heavy lifting is done by Collyn O’Kane’s kmeans-colors project, which includes a generic k-means library. I did consider using the CLI tool that’s part of the project, but it has lots of features I wouldn’t use. I wrote dominant_colours with the “do one thing and do it well” mantra in mind.

I downsize all the images to be within 400×400 pixels before passing them to the k-means process. This makes the whole process much faster, because there are less pixels to deal with – and it doesn’t have much effect on the result. If a colour was only visible in a fine detail that got lost in scaling, it probably wasn’t a dominant colour.

Similarly, if I’ve got an animated GIF, I only take a sample of the frames, which are each in turn downsized to 100×100. This dramatically reduces the number of pixels I have to deal with. (The biggest GIF I have saved locally is 720×1019 and has 650 frames – nearly half a billion pixels. Sampling and resizing reduces that to a much more managable 350k pixels.)

I’ve wrapped the k-means process in a command-line interface created with clap. There’s one argument and one flag which get used to configure the k-means process.

To draw arbitrary colours in the terminal, I’m using ANSI escape codes, adapting some Python from another blog post I’ve written.

Why did I pick Rust?

I’ve been increasingly picking Rust for small, standalone, interactive tools – command-line applications that do all their work locally. Rust binaries can be much faster than Python (the language I’d otherwise use), and for interactive stuff I really notice the difference.

I did some informal benchmarks of dominant_colours – for even moderately sized images, it only takes a fraction of a second. By comparison, Python takes at least a second for even the smallest image. This difference is really noticeable – a process that completes in a tenth of a second feels instant; a process that takes a second or more is a perceptible delay.

I’m not yet using Rust for anything that involves the Internet, because the network latency negates a lot of that speed benefit. If I’m waiting multiple seconds for a remote server to give a response, I won’t notice if I shave off half a second on my end.

As I was writing Rust, I was struck by the quality of the compiler errors. The Rust compiler is very picky and I regularly had compiler errors, but they were super helpful in explaining what I should do next. It feels like these errors get better each time I go back to Rust. (For more on this, I recommend Esteban Küber’s talk at RustConf 2020.)

This approach to error checking permeates other parts of the Rust ecosystem. This is the error you get from Clap if you misspell an argument:

$ dominant_colours lighthouse.jpg --max-colors=3
error: Found argument '--max-colors' which wasn't expected, or isn't valid in this context
      Did you mean --max-colours?

USAGE:
    dominant_colours <PATH> --max-colours <MAX-COLOURS>

For more information try --help

That’s more helpful than any other tool I’ve seen, and it’s the default behaviour in Clap. I didn’t have to opt-in or do anything special; I didn’t realise it was there until I made a genuine typo. This focus on UX and error handling means I’m more likely to use Rust and Clap in my next project.

What’s next?

I’m replacing all my Python implementations of k-means for dominant colours with this tool.

After that, this tool is probably done. I don’t want any more features, and I’m not aware of any bugs, so don’t expect to see a lot of work on it in the future. It’s one of the nice things about writing small, single-purpose tools: you can finish them.