Skip to content

Accessibility: a validated colourblind-safe palette

depictr defaults to the Okabe-Ito palette and then measures what that choice buys. A Machado-2009 simulator and a CIE-Lab distance test confirm the colours stay distinguishable under each colour-vision deficiency. A safe palette is not a safe figure, though, so the same machinery also audits a finished plot. That is the second half of this page.

The safety report

The report gives the worst-case minimum distance between any two palette colours, across normal vision and each deficiency at full severity. The distance is the CIEDE2000 colour difference (Sharma, Wu and Dalal, 2005).

import depictr as dp

report = dp.palette_safety()
print(pre(repr(report)))
{'min_delta_e': 6.43, 'by_condition': {'normal': 21.25, 'protan': 13.44, 'deutan': 6.43, 'tritan': 11.13}, 'worst_condition': 'deutan', 'worst_pair': ('#cc79a7', '#999999'), 'safe': True, 'threshold': 5.0}

The palette in use

A grouped distribution in the default palette, a histogram with its density curve. legend_inside tucks the legend into the empty top-right corner, so the figure needs no separate margin.

ld = dp.lexical_decision()
p = dp.explore_distribution(ld, "RT", group="condition", kind="both",
                            legend_inside=True)
print(show(p))
depictr plot

The palette as a deficiency sees it

The same simulator drives the safety report above and the web app's colour-vision toggle. Below it maps every palette colour through each deficiency in turn, so the rows can be read against one another. The tightest pair is the reddish purple and the grey under deuteranopia, which is where the 6.43 in the report comes from, and even that clears the threshold of 5.

import pandas as pd
from plotnine import (aes, element_blank, geom_tile, ggplot, labs,
                      scale_fill_identity, scale_y_discrete, theme)

palette = dp.depictr_palette()
conditions = ["normal", *dp.cvd.DEFICIENCIES]

rows = []
for condition in conditions:
    # simulate_cvd takes the three deficiencies only; normal vision is the
    # palette itself, which is the same split palette_safety makes internally.
    seen = palette if condition == "normal" else dp.simulate_cvd(
        palette, condition
    )
    rows += [{"condition": condition, "position": i, "colour": colour}
             for i, colour in enumerate(seen)]

swatches = pd.DataFrame(rows)
p = (ggplot(swatches, aes("factor(position)", "condition", fill="colour"))
     + geom_tile(width=0.92, height=0.82)
     + scale_fill_identity()
     # Discrete scales are drawn from the bottom up, so the order is reversed
     # to put normal vision at the top.
     + scale_y_discrete(limits=list(reversed(conditions)))
     + labs(x="Palette colour", y=None)
     + theme(axis_text_x=element_blank(), panel_grid=element_blank()))
print(show(p, width=8, height=2.6))
depictr plot

The hex codes behind those rows, for the deficiency the palette comes closest to failing.

simulated = dp.simulate_cvd(palette, "deutan")
lines = [f"{original}  ->  {seen}"
         for original, seen in zip(palette, simulated)]
print(pre("\n".join(lines)))
#005b96  ->  #275295
#e69f00  ->  #cab411
#009e73  ->  #8a8676
#d55e00  ->  #9e8c00
#cc79a7  ->  #9498a5
#56b4e9  ->  #87a4e8
#f0e442  ->  #fce34e
#999999  ->  #999999

A finished figure as a deficiency sees it

simulate_figure applies the same simulation to a whole figure. It draws the plot and passes every pixel through the transform, so the legend and the text change with the data. The result is an array of pixels that matplotlib's imshow or Streamlit's st.image can show, and the web app draws its colour-vision toggle this way. Below, the grouped distribution from the top of the page appears under normal vision, which is severity zero, and then under each deficiency at full severity.

import numpy as np

p = dp.explore_distribution(ld, "RT", group="condition", kind="both",
                            legend_inside=True)
views = [dp.simulate_figure(p, "deutan", severity=0, dpi=60)]
views += [dp.simulate_figure(p, deficiency, dpi=60)
          for deficiency in dp.cvd.DEFICIENCIES]
grid = np.concatenate([np.concatenate(views[:2], axis=1),
                       np.concatenate(views[2:], axis=1)])
print(image(grid, alt="The grouped distribution of reaction times four "
            "times over: under normal vision, then as readers with "
            "protanopia, deuteranopia and tritanopia see it."))
The grouped distribution of reaction times four times over: under normal vision, then as readers with protanopia, deuteranopia and tritanopia see it.

The R twin's simulate_figure() applies the same matrices to the figure's vector elements and returns a gtable. A solid region comes out in the same colour in both, to within the rounding of an eight-bit channel.

Where the guarantee stops

Eight colours is the guarantee, not a starting point. Asked for more, depictr_palette interpolates between the Okabe-Ito colours, and the in-between colours land close enough together that the same check fails them. The package warns when that happens, so no colours go out under a promise it cannot keep. The report below shows the check agreeing with the warning: eight colours pass, and nine already fail.

import warnings

with warnings.catch_warnings():
    warnings.simplefilter("ignore")  # the warning is the point; here we tabulate it
    sizes = {n: dp.palette_safety(dp.depictr_palette(n)) for n in (8, 9, 10, 12)}

lines = [f"n = {n:>2}   min Delta-E {r['min_delta_e']:>5}   safe: {r['safe']}"
         for n, r in sizes.items()]
print(pre("\n".join(lines)))
n =  8   min Delta-E  6.43   safe: True
n =  9   min Delta-E  4.96   safe: False
n = 10   min Delta-E  1.87   safe: False
n = 12   min Delta-E  2.15   safe: False

With more than eight groups, facet them, or map the variable to the sequential ramp (dp.depictr_palette(n, kind="sequential")), which stays ordered and legible at any length because lightness carries the ordering.

Auditing the figure you are about to submit

The palette report above describes eight colours in the abstract. It cannot know how many of them a given figure uses, what you replaced them with, how small the text becomes once the figure is squeezed into a journal column, or whether the only thing separating two groups is their colour. check_figure reads the built plot and reports what it measured beside the threshold it was measured against.

p = dp.explore_distribution(ld, "RT", group="condition", kind="both",
                            legend_inside=True)
print(table(dp.check_figure(p)))
check measured threshold verdict detail
colour_separability 46.75 5.0 pass Closest pair #669dc0 and #f0c566 of 2 encoding fill colours.
colour_separability_protan 43.83 5.0 pass Closest pair #669dc0 and #f0c566 of 2 encoding fill colours.
colour_separability_deutan 47.76 5.0 pass Closest pair #669dc0 and #f0c566 of 2 encoding fill colours.
colour_separability_tritan 45.17 5.0 pass Closest pair #669dc0 and #f0c566 of 2 encoding fill colours.
greyscale_separability 19.23 5.0 pass Closest pair #669dc0 and #f0c566 in CIE lightness.
text_size 8.80 6.0 pass Smallest text 8.80 pt, drawn at 17.78 cm and printed at 17.78 cm.
text_contrast 8.45 4.5 pass Lowest-contrast text #4d4d4d on #ffffff at 8.80 pt.
geometry_contrast 1.63 3.0 fail Lowest-contrast colour #e69f00 at alpha 0.60, drawn as #f0c566, on #ffffff.
redundant_encoding 0.00 1.0 fail Colour alone distinguishes the groups.
annotation_text_size NaN 6.0 not applicable The figure draws no text inside the panel.

Two rows are worth dwelling on. geometry_contrast measures each encoding colour, as it is drawn, against the panel background. The palette's orange falls below the 3:1 that WCAG asks of a graphical object even when opaque, at 2.25:1, and further when drawn translucent, as the histogram's bars are. redundant_encoding is zero because nothing but colour tells the two conditions apart. The audit also takes a stated output width, which is where most figure text quietly fails: text is drawn in points, so a figure saved seven inches wide and then printed in an 8.9 cm column arrives at half the size it looked on screen.

column = dp.check_figure(p, width_cm=8.9)
print(table(column[column["check"] == "text_size"]))
check measured threshold verdict detail
text_size 4.4 6.0 fail Smallest text 8.80 pt, drawn at 17.78 cm and printed at 8.90 cm.

A figure saved at the size it will be printed is drawn at that size, so give the saved width as both width_cm and render_width_cm, or leave both at their defaults. Text drawn inside the panel, such as an annotation, a label or a cell value, has a row of its own, annotation_text_size, held to the same floor. That floor defaults to 6 pt, which is lenient. As their guidelines stood on 30 September 2026, PLOS and APA Style asked for at least 8 pt, Elsevier for 7 pt and Nature for 5 to 7 pt.

Nature's range has a ceiling as well as a floor, since no text but the panel labels may exceed 7 pt. Passing max_text_pt=7 holds a figure to it in a row of its own, text_size_max. dp.save_plot(check=True) runs the audit as it saves, at the width it saves at, and warns about each check the figure fails.

A figure built with two well-separated colours drawn opaque, a redundant shape and no shrinking clears every row it can be held to, so the audit returns a pass as readily as a failure. It draws no text inside the panel, so that row is not applicable.

from plotnine import aes, geom_point, ggplot, scale_colour_manual

cy = dp.crop_yield()
mended = (ggplot(cy, aes("rainfall", "yield", colour="treatment",
                         shape="treatment"))
          + geom_point()
          + scale_colour_manual(values=["#005b96", "#d55e00"])
          + dp.theme_depictr())
print(table(dp.check_figure(mended)[["check", "measured", "threshold",
                                     "verdict"]]))
check measured threshold verdict
colour_separability 50.12 5.0 pass
colour_separability_protan 49.67 5.0 pass
colour_separability_deutan 58.30 5.0 pass
colour_separability_tritan 61.99 5.0 pass
greyscale_separability 16.92 5.0 pass
text_size 8.80 6.0 pass
text_contrast 8.45 4.5 pass
geometry_contrast 3.87 3.0 pass
redundant_encoding 1.00 1.0 pass
annotation_text_size NaN 6.0 not applicable

The vermillion need not be typed by hand. dp.scale_colour_depictr(order="contrast") draws the groups in the contrast-first order of dp.depictr_palette(). It leads with the brand blue, the vermillion and the reddish purple, the only three of the eight colours that clear every colour row together. The bluish green comes fourth, and with it four groups still keep 3:1 against white. The R twin can make that order the default through its options. Python has none, so add the scale, or dp.scale_fill_depictr(order="contrast"), to each plot that should use it. In that order, the second group takes the vermillion, which is also dp.depictr_accent().

The audit does not measure a continuous colour or fill scale, such as a heatmap's: its colour rows abstain and say why, while the cell values are measured as text inside the panel. A ramp is read by its lightness, so prefer one whose lightness runs evenly from end to end, such as viridis or depictr's sequential and diverging ramps, over a rainbow (Crameri, Shephard and Heron, 2020).

heatmap = dp.check_figure(dp.correlation_heatmap(dp.wellbeing_survey()))
print(table(heatmap[heatmap["check"].isin(
    ["colour_separability", "text_size", "annotation_text_size"])]))
check measured threshold verdict detail
colour_separability NaN 5.0 not applicable Colour is a continuous scale, which these checks do not audit.
text_size 8.8 6.0 pass Smallest text 8.80 pt, drawn at 17.78 cm and printed at 17.78 cm.
annotation_text_size 8.0 6.0 pass Smallest in-panel text 8.00 pt, drawn at 17.78 cm and printed at 17.78 cm.

Where the guarantee stops a second time: greyscale

One limitation belongs here, where the claim is made. The eight-colour palette clears every colour-vision check and fails the greyscale check: its orange and its sky blue differ by 0.79 in CIE lightness, so a black-and-white printer renders them as the same grey. The Okabe-Ito guarantee is about hue confusion and was never a claim about greyscale. Moving the threshold would let depictr's own defaults through, so it stays where it is.

eight = pd.DataFrame({"g": list("abcdefgh"), "x": range(8), "y": range(8)})
p8 = (ggplot(eight, aes("x", "y", colour="g")) + geom_point()
      + dp.scale_colour_depictr() + dp.theme_depictr())
report = dp.check_figure(p8)
print(table(report[report["check"].str.contains("separability")]))
check measured threshold verdict detail
colour_separability 21.25 5.0 pass Closest pair #56b4e9 and #999999 of 8 encoding colours.
colour_separability_protan 13.44 5.0 pass Closest pair #999999 and #cc79a7 of 8 encoding colours.
colour_separability_deutan 6.43 5.0 pass Closest pair #999999 and #cc79a7 of 8 encoding colours.
colour_separability_tritan 11.13 5.0 pass Closest pair #cc79a7 and #e69f00 of 8 encoding colours.
greyscale_separability 0.79 5.0 fail Closest pair #56b4e9 and #e69f00 in CIE lightness.

A figure that may be printed in black and white wants fewer groups, a sequential palette, or a redundant shape or line type. Four groups are not enough on their own either: the bluish green and the vermillion differ by 3.55.