Creating Perfect Grayscale-Gradient Colormaps

When reviewing the submission instruction for a conference, I was reminded that the figures in my paper need to be “accessible” when printed in grayscale.

Usually, this would be done by adding patterns like triangles and circles in the plot. However, we were plotting stacked areas like the left one below, which contains dense and tiny vertical bars, so a grayscale gradient seems to be our only option.

Original stacked area plot exampleSequential Matplotlib colormaps

I found nice grayscale-gradient “sequential” Matplotlib colormaps like the right one above, but they make it impossible to hand-pick a distinct and pretty color for each category.

“This is not a big deal,” I thought, “I’ll just use HSL (hue-saturation-lightness), set to , to a gradient, and hand-pick the .” Except I was so wrong—lightness is not grayscale, and figuring everything out took me the whole morning.

Result: hand-picked hues and perfect grayscale gradient

After wasting an hour failing to find any implementation on the Internet, I rolled my own scripts to calculate RGB color values from hue and grayscale, which allowed hand-picking hues while still having perfect grayscale gradient:

Grayscale-gradient stacked area plotGrayscale-gradient stacked area plot in grayscale

Below is the definition of these six colors. I hand-picked the hue values so each color looks natural, but the grayscale values are sampled linearly.

COLORS6: Final = tuple(
    hue_grayscale_to_srgb(hue, grayscale)
    for hue, grayscale in zip(
        [60, 180, 120, 240, 0, 300],
        np.linspace(0.96, 0.2, 6),
    )
)

As a trade-off, I spent some time doing math on a whiteboard.

Math: calculating linear RGB from hue and grayscale

Math for hue-grayscale to RGB conversion on a whiteboard

After some Wikipedia’ing, I extracted the formula for RGB-HSL and RGB-grayscale conversions, and did the math on the whiteboard above. For a color with linear red, green, blue values , hue , saturation , lightness , and grayscale :

Recall that we know and , and want to solve for , , and . The easiest bit is exploiting saturation and the formula for it:

The many conditions of the hue is a source of nightmare for simplification. Fortunately, I observed the formula for and found out only the order of the three RGB values matters if I introduce a new variable :

Since we can get the order of from , we can also know the grayscale coefficient for . For example, if , then we have:

That is, we need to solve this linear system:

This is then trivial to program. In the code, I first compute a potential answer using the first case, then fall through to the second case if the resulting , and then, boom! We have the RGB values from hue and grayscale.

…except we don’t.

Linear RGB vs standard RGB

RGB values computed from a linear grayscale gradient using the code above does not produce a grayscale gradient. In fact, I found some colors to have very similar grayscale.

This is because the RGB values we computed are linear RGB values, and monitors use standard RGB (sRGB) instead, with the conversion:

Why apply a weakly concave curve to RGB values? It turns out that human eyes are less sensitive to changes in bright colors, and so the curved sRGB values better reflect the perceived brightness.

Applying this knowledge to creating a grayscale gradient colormap, we need to first generate the gradient in sRGB, then convert each grayscale to linear RGB and apply the hue-grayscale to linear RGB conversion, and finally convert them back to sRGB. And, that is it! A perfect grayscale-gradient colormap with hand-picked hues.

Grayscale-gradient stacked area plotGrayscale-gradient stacked area plot in grayscale

Exercise for the reader

We fixed above, but what if we want to be able to change the saturation? Can you solve the system for given ?


2024-05-25

Steven Hé (Sīchàng)’s Blogs