In search of categorical color scheme families: dark, same perceptual brightness

I frequently want to plot categorical data on a white background (eg a scatterplot, with various colors for each kind). My ideal color scheme would be

  1. reasonably dark, as I am plotting on a white background,
  2. have more or less the same perceived brightness
  3. support at least 3–5 colors, 6–7 would be nice if possible, but more become confusing
  4. bonus: have 2–3 non-overlapping palettes

Dark2 from ColorBrewer comes close, but the ochre gets lost on white and the two greens are easy to confuse.

I will explain (4) above: suppose in a paper I want to use one color palette for some categorical variable in all plots, and another for another palette for another categorical variable for all relevant plots. The two palettes would not show up in the same plot, but I want to avoid confusion.
families

I guess I could try a rotation around the white point in CIELUV or similar, since Colors.jl supports it, but I am hoping that someone has done this work and I could just use the results.

A naive experiment:

using Colors

"`n` colors with evenly distributed hues, luminance `l`"
function my_palette(n, l)
    hues = range(0, 360; length = n + 1)[begin:(end-1)]
    function _f(h)
        c = MSC(h, l)
        LCHuv(l, c, h)
    end
    _f.(hues)
end

But my_palette(5, 50) gives

x

which has greenish colors that are again too close.

Maybe take a bunch of initial colors and maximize pairwise CIE \Delta E in some space? Colors.jl supports those calculations, but maybe maxing saturation out is not such a good idea.

1 Like

When in doubt, compute stuff.

const WHITE = convert(LCHuv, colorant"white")
const BLACK = convert(LCHuv, colorant"black")

function pairwise_ΔE(colors; black_correction = 1.0)
    _lch = convert.(LCHuv, colors)
    min_ΔE = Inf
    function _update!(c1, c2, correction = 1.0)
        min_ΔE = min(min_ΔE, colordiff(c1, c2))
    end
    for (i, c1) in enumerate(_lch)
        _update!(WHITE, c1)
        _update!(BLACK, c1, black_correction) # OK to get closer to black
        for c2 in @view(_lch[(i+1):end])
            _update!(c1, c2)
        end
    end
    min_ΔE
end

random_colors(n) = [RGB(rand(), rand(), rand()) for _ in 1:5]

r = sort!([(p = random_colors(5); p => pairwise_ΔE(p)) for _ in 1:100000],
          by = last, rev = true)
permutedims(mapreduce(first, hcat, r[1:10]))

I guess I could pick manually, some of them are not bad at all. But notice that the first one is like the first attempt, calculated from hues.

2 Likes

Take a look here for more color inspiration.

1 Like

Thanks, but those are not either too dark or not dark enough.

Second attempt, using the DE_CMC() metric (because it looks best to my eye), forcing a kind of dark yellow to be there (otherwise yellowish colors are pretty rare, why?), and results sorted by hue:

The first one looks kind of OK, so if anyone wants it, here are the values:

LCHuv{Float64}(100.0,0.0,0.0)
LCHuv{Float64}(0.0,0.0,0.0)
LCHuv{Float64}(51.863153619748246,93.77645820051907,23.238375666328537)
LCHuv{Float64}(70.5,84.0,70.5)
LCHuv{Float64}(43.99159466563735,61.591721169405666,131.19968337189033)
LCHuv{Float64}(49.53323427719795,62.386038925970134,250.83883848314323)
LCHuv{Float64}(37.2494937150584,131.4158387710182,270.28598024082146)
LCHuv{Float64}(29.91785388310216,25.124316742516655,310.2475589723785)
LCHuv{Float64}(10.898674004674053,21.026130868830556,335.6789198210797)
LCHuv{Float64}(52.5930045850733,129.7328214196894,352.8318079349615)
Code
using Colors

function pairwise_ΔE(colors,
                     adjusted_constants = [WHITE => 0.5, BLACK => 2.0];
                     metric = DE_2000())
    _lch = convert.(LCHuv, colors)
    min_ΔE = Inf
    function _update!(c1, c2, correction = 1.0)
        min_ΔE = min(min_ΔE, colordiff(c1, c2; metric) * correction)
    end
    for (i, c1) in enumerate(_lch)
        for (c, correction) in adjusted_constants
            _update!(c, c1, correction)
        end
        for c2 in @view(_lch[(i+1):end])
            _update!(c1, c2)
        end
    end
    sort!(vcat(_lch, first.(adjusted_constants)), by = hue) => min_ΔE
end

pre_list = [LCHuv(100.0, 0.0, 0.0) => 0.7, # white plot background, want extra distance
            LCHuv(0.0, 0.0, 0.0) => 1.2,   # black lines, no direct overlap, OK to be closer
            LCHuv(70.5, 84, 70.5) => 1.0]  # want to include manually

random_colors(n) = [RGB(rand(), rand(), rand()) for _ in 1:n]

r = sort!([pairwise_ΔE(random_colors(7), pre_list; metric = DE_CMC()) for _ in 1:100000],
          by = last, rev = true)
permutedims(mapreduce(first, hcat, r[1:10]))
3 Likes

I’d say this is a feature, since yellow on white is a terrible look in my opinion.

1 Like

Depends on brightness/saturation I guess. I think that a darker yellow is as good as any other color.

I think the real reason is that what we call “yellow” is a rather narrow slice on the xy color diagram, compared to eg green and red. So it has a lower chance of showing up.

I’ve pursued distinct colors for this purpose several times. I especially appreciate you posting your work here because I have tritanopia. It’s genetic–my mother had it, most of my siblings and children also have it, and we’ve participated as test subjects in a published study. It manifests itself as inability to distinguish colors that differ in a small degree of yellow content. For example, I can’t distinguish blue and green when they get relatively close, such as in post #2. My wife has to put white-out on green board game pieces so that I can distinguish them from the blue pieces. I also have difficulty with brown vs. purple, orange vs. pink, and especially pale yellow on white. I’ve bookmarked your posts and will return to them the next time I need to generate distinct colors.

PS I can’t see the boundary between the yellow and white in my avatar.

1 Like

Running a similar script using okhsl I get a better green/blue contrast IMO.

okhsl

Oklab has a better \Delta E too so your other scripts could also give better results.

(Note that the PR for oklab in Colors.jl has been stalled for a while, I used my own implementation here.)

4 Likes

Yes, unfortunately Colors.jl has been without maintainers for a couple of years; kimikage stepped away, and timholy and johnnychen are too busy elsewhere… It needs someone with the time,authority, and expertise to take up the challenge. But hopefully one day your PRs will be merged!

Can you post the RGB for these colors? They look great and I want to try them out. Thanks!

Yeah, I don’t blame them. Colors requires a lot of technical knowledge, but compared to other projects in the ecosystem it seems inconsequential.

The PRs for oklab are not mine, altho hopefully I’ll have time in the future to clean up what I’ve written and make a separate PR for okhsv and okhsl.

1 Like

The colors are:

#d70071
#a26900
#00911c
#00859d
#7947ff

And a similar 8 color palette:

#d70071
#c14f00
#927300
#478b00
#008a79
#0083a7
#465fff
#b101e4
3 Likes

Is this code public anywhere?

Yes, this is unfortunate, but since ColorTypes.jl exposes core types, one can implement a lot of stuff outside Colors.jl and still hook into the ecosystem.

OKLCH in CSS: why we moved from RGB and HSL—Martian Chronicles, Evil Martians’ team blog” argues for OKLCH over HSL for generating palettes. You can try making some colors in https://oklch.com and palettes at https://huetone.ardov.me/. OKLCH is now available for CSS in browsers.

3 Likes

This thread was mentioned in my (now superceded) PR for implementing OKLab and OKLch. I’m actually still using Colors.jl with a simple (90 lines), rudimentarily grafted on implementation of those for my personal color science projects with great success.

Regarding your original question, I’ve actually probably solved exactly what you are looking for in the past (with even identical chroma, not just luminance), though in the context of syntax highlighting. Check out the non-background colors (six, seven or eight) as well as their construction here: Penumbra

The hexagonal (similarly pentagonal, heptagonal etc.) vertices of equal luminance and maximum chroma in sRGB (which I would still target, P3 is not common enough by far) are actually around a luminance of about 0.75, so your request for relatively dark colors is tricky since their chroma will decrease with the luminance. A luminance of 0.65 just barely meets the least stringent W3C contrast criteria, which is what I chose for the basic version of the color scheme above.

Huge caveat on all of this though! I would strongly advise against using such a color scheme for data visualisation for any purpose that will include sharing with others as it is not accessible for people with color vision deficiency, it’s actually especially bad as luminance helps this group with differentiation.

I’ve just last week developed the (to my knowledge) first CVD-accessible discrete palette that is truly optimised from first principles, but haven’t gotten around to re-verify my assumptions (especially the implementation of color blindness simulation in Colors.jl), write everything up and publish it yet. There will definitely also be a repository, so by following my GitHub it should show up once I make it public. Might be a couple of weeks (or even months) since I might turn it into an actual paper, but definitely sometime this year.
To allow for differentiability for the color blind, there is relatively large variance in luminance, going against your original request, but I think in this case accessibility should come first.
In the meantime, the Wong palette available in Makie.jl (actually Okabe-Ito) and used as the default in AlgebraOfGraphics.jl is somewhat serviceable (I suspect it has only been verified against anomalous trichromacy though, not full dichromacy, or might have used outdated math) and there’s also the “Bright” Palette by Paul Tol, which uses a less precise color blindness simulation and disregards the rarest form entirely, but is somewhat more principled for the other two.

7 Likes

Is this code public anywhere?

Yep. My draft is here. It’s a translation of Ottosson’s original C++ code, and the js version used in his color picker. The original code is written in a very… game-dev way. so there’s a lot that could be improved there.

1 Like

Thanks for your detailed reply. I now think that my problem is near impossible: it is very challenging to find even 5 colors that

  1. work when viewed on a screen by a color-blind person,
  2. survive printing on an unsophisticated garden variety color laser printer,
  3. are distinguishable when I present on a projector that has some problem (the lamp burning out etc)

Maybe the “high contrast” scheme of Paul Tol works for 3 colors, but that is pretty much the limit I guess. Perhaps I should look for other ways to present the data that has categories, eg subplots.

For comparison, from the site I linked above, the following 5 colors should be 99.99% accessible:

3 Likes