Building the most visually distinct set of colors

What is a set of eight colors that look as different from each other as possible? Or ten colors, fifteen? Is there a generalizable approach for creating such a set?

The answers I found weren't too satisfying; either producing sets that weren't quite right or methods that were much too complex. So I thought I'd play around with some of the approaches and gain an intuition about some of the ideas behind colorspaces, perceptual uniformity, and generative methods for creating colorsets.

Existing option: hand picked sets

There’s plenty of sets that can be found online that’ve been curated to contain colors that are visually distinct. Many are based on the colors of lines on transit maps from around the world, a clever way to get started. A few examples include Kelley's 22 colors of maximum contrast (a set developed in 1965), Paul Tol's sets developed from transit maps, and Sasha Trubetskoy's 20 color set, developed from both Kelley's set and transit maps.

Excerpt of Kelley's 22 Color Set

Excerpt of Tol's light color set

Excerpt of Trubetskoy's 20 color set

More hand-picked sets and resources

But curated sets only go so far; what if you need more colors, or if the colors supplied are too light or dark? And how do we know the colors are really that distinct? For this we need some kind of a generative approach.

Generative approaches for more flexibility

Probably the most well known generative approach is the Glasbey algorithm, which builds a set by finding colors that are maximally perceptually distinct from all other colors in the set. It results in sets of colors like this:

Excerpt of a glasbey generated set

More Glasbey resources

The Glasbey algorithm chooses colors that are varied on three dimensions: chroma (the colorfulness of the color), lightness, and hue.

Varying chroma

But in information visualization, varying the chroma can come at a cost: it can change the interpretation of the category. A category that’s more colorful might pop out, while a greyer category may get lost in comparison. The same is true of lightness: very light or dark colors can get lost in the background and may be interpreted differently. It’s been suggested that the safest way to generate a set of colors for visualizing categorical variables is to keep chroma and lightness fixed.

The Glasbey algorithm is also quite demanding to run; it requires building a color lookup table and doing some amount of random sampling.

More generative approaches and resources

Is there a simpler approach? If we don’t vary chroma at all, only vary lightness within a certain range, and vary hue, is there an easier path to generating palettes of colors?

Choosing a color space

The first choice we need to make is which color space we'll use to generate the set. This it turns out is key to answering the question of how different two colors appear.

Avoiding visual lightness discrepancies

The problem with the basic RGB or HSL color spaces that are often used in color pickers is they aren’t perceptually uniform. Meaning that changing just hue also changes how light the color appears. Here’s an example from HSL, just changing the hue and keeping everything else fixed:

Changing hues in HSL

This visual discrepancy ocurrs beacuse our perception of color isn’t uniform; yellow appears lighter and blue appears darker despite having the exact same lightness values in the HSL colorspace.

This interactive color picker makes it clear how much lightness variation there is in the HSL color space.

Perceptually uniform color spaces

In order to avoid this, our color palette needs to be generated in a perceptually uniform color space: a space that has been designed to more closely mimic the way color is perceived by the rods and cones in our eyes.

In a perceptually uniform color space, changing one dimension doesn’t affect the perceptual qualities of the other dimensions. So our example above would look more uniform; changing only the hue would keep the relative lightness of the color the same:

Changing hues in OKLAB

There are lots of these perceptually uniform colorspaces, including CIELAB, CIELUV, CIECAM02, JzAzBz, and OKLAB. Each is an imperfect approximation and each have areas where they under and outperform their peers. I went with the OKLAB space as it seemed to perform best all around.

Visualizing the color space

OKLAB (and any other *LAB-type color space) has three dimensions:

  • L. Lightness: how light or dark the color appears.
  • a. The green-red component of our vision.
  • b. The blue-yellow component of our vision.

However, these dimensions aren’t the most intuitive to work with. An alternative mapping is the OKLCh space, which has these dimensions:

  • L. Lightness: how light or dark the color appears.
  • C. Chroma: the colorfulness, from fully colored to grey.
  • h. The hue, from 0-360°.

The CKLCh colorspace is a cylindrical colorspace, and can be visualized in 3D space as a cylinder. Every color exists somewhere in this shape:

Color

Lightness is the y-axis of the space, from totally black at the bottom to totally white at the top. Chroma is the radius of the cylinder, with a lower chroma corresponding to a smaller and less colorful section of the space closer to the center of the cylinder. Hue is the rotation around the center axis and is measured from 0 to 360°.

Generating the set of colors

Measuring perceptual distance

One useful characteristic of a colorspace like OKLCh is that measuring how different two colors appear is quite easy. Since the colorspace has already been adjusted to be perceptually uniform, the distance between two colors in space can be interpreted as the perceptual distance between the colors.

So in order to get the most perceptually most distinct color from another, we rotate the hue 180° around to the opposite side of the colorspace, maximizing the distance between it and the existing color.

Rotating hue

With this property in mind, to generate a palette of colors of a particular size all we need to do is divide the number of hues (360) by the desired number of colors we want and rotate by that amount at each step.

Palette

Adding a lightness range

The next dimension to consider is lightness.

One of the original requirements was to bind lightness to a certain range, so we don’t have any colors in the set that are too light or dark but can still take advantage of the additional perceptual variation we get with different lightnesses.

So, we'll adjust the color selection at each hue step. Rather than just picking the color with the same lightness, we’ll find the next color by looping through the possible lightness values at this hue value and recording minimum distance between the test color and the existing colors in the set. Whichever test color has the furthest minimum distance we’ll add to the set.

Palette

For most lightness ranges and chromas, this method has the effect of pushing the color at each hue step to the opposite lightness bound as the previous color in the set. This seems like a good property to take advantage of to simplifying the process.

A simple generation method

To implement we'll use culori, a fantastic color library for javascript.

Given the exploration above, it seems like the simplest way to generate the palette is to start by creating two colormaps: one at the max lightness value and one at the minimum. Then, we'll create a palette by dividing up the colormap into evenly sized chunks and pick each hue alternating from the light to the dark colormaps.

To begin, we'll define a way to create a colormap in OKLCh space, converted to hex:

import { clampChroma, formatHex } from "culori"

const generateColormap = (chroma = 0.3, lightness = 0.8) => {
    const colormap = []
    // create a color for every hue with this chroma and lightness
    for (let h = 1; h <= 360; h++) {
        const color = {
            mode: "oklch",
            l: lightness,
            c: chroma,
            h
        }
        // convert the OKLCh color to hex
        colormap.push(formatHex(clampChroma(color)))
    }

    return colormap
}

Then, we can create the light and dark colormaps:

const lightColormap = generateColormap(0.3, 0.65)
const darkColormap = generateColormap(0.3, 0.75)

These colormaps could be pregenerated and saved to speed up the palette creation process even more.

Finally, we can choose a palette by selecting colors from each of the two colormaps. We'll divide each colormap into chunks sized based on the number of colors in the palette, then alternate between the light and dark maps to create the set:

const numColors = 12;
const stepSize = Math.round(360 / numColors)

const palette = []

for (let i = 0; i < numColors; i += stepSize) {
    if (i % 2 === 0) palette.push(lightColormap[i * stepSize])
    else palette.push(darkColormap[i * stepSize])
}

Generate color sets

Now we have a quick and simple way of creating maximally distinct sets of colors with a few useful parameters.

Palette
Palette Hex
More color set generators and resources

Next

One obvious limitation to this method is it assumes perfect color vision. An important addition would be to adjust for deuteranopia, protanopia, and tritanopia. Naturally the number of colors in the palette will be much lower, but it should be possible to adjust the colorspace to account for these types of vision.

Published May 2022

Thanks to Sydney Zheng for discussions and feedback throughout this experiment.

Font is Inter, previews are Hyperfov link previews, colors generated with culori, interactive examples built with threejs, components built with sveltekit.