At the end of the rainbow

[This article was first published on Achim Zeileis, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Fully saturated RGB rainbow colors are still widely used in scientific visualizations despite their widely-recognized disadvantages. A recent wild-caught example is presented, showing its limitations along with a better HCL-based alternative palette.

#endrainbow

The go-to palette in many software packages is – or used to be until rather recently – the so-called rainbow: a palette created by changing the hue in highly-saturated RGB colors. This has been widely recognized as having a number of disadvantages including: abrupt shifts in brightness, misleading for viewers with color vision deficiencies, too flashy to look at for a longer time. As part of our R software project colorspace we therefore started collecting typical (ab-)uses of the RGB rainbow palette on our web site http://colorspace.R-Forge.R-project.org/articles/endrainbow.html and suggest better HCL-based color palettes.

Here, we present the most recent addition to that example collection, a map of influenza severity in Germany, published by the influenza working group of the Robert Koch-Institut. Along with the original map and its poor choice of colors we:

  • highlight its problems by desaturation to grayscale and by emulating color vision deficiencies,
  • suggest a proper sequential HCL-based palette, and
  • provide the R code that can extract and replace the color palette in a PNG graphics file.

Influenza in Germany

The shaded map below was taken from the web site of the Robert Koch-Institut (Arbeitsgemeinschaft Influenza) and it shows the severity of influenza in Germany in week 8, 2019. The original color palette (left) is the classic rainbow ranging from “normal” (blue) to “strongly increased” (red). As all colors in the palette are very flashy and highly-saturated it is hard to grasp intuitively which areas are most affected by influenza. Also, the least interesting “normal” areas stand out as blue is the darkest color in the palette.

As an alternative, a proper multi-hue sequential HCL palette is used on the right. This has smooth gradients and the overall message can be grasped quickly, giving focus to the high-risk regions depicted with dark/colorful colors. However, the extremely sharp transitions between “normal” and “strongly increased” areas (e.g., in the North and the East) might indicate some overfitting in the underlying smoothing for the map.

influenza-rainbow influenza-purpleyellow

Converting all colors to grayscale brings out even more clearly why the overall picture is so hard to grasp with the original palette: The gradients are discontinuous switching several times between bright and dark. Thus, it is hard to identify the high-risk regions while this is more natural and straightforward with the HCL-based sequential palette.

influenza-rainbow-gray influenza-purpleyellow-gray

Emulating green-deficient vision (deuteranopia) emphasizes the same problems as the desaturated version above but shows even more problems with the original palette: The wrong areas in the map “pop out”, making the map extremely hard to use for viewers with red-green deficiency. The HCL-based palette on the other hand is equally accessible for color-deficient viewers as for those with full color vision.

influenza-rainbow-deutan influenza-purpleyellow-deutan

Replication in R

The desaturated and deuteranope version of the original image influenza-rainbow.png (a screenshot of the RKI web page) are relatively easy to produce using the colorspace function cvd_emulator("influenza-rainbow.png"). Internally, this reads the RGB colors for all pixels in the PNG, converts them with the colorspace functions desaturate() and deutan(), respectively, and saves the PNG again. Below we also do this “by hand”.

What is more complicated is the replacement of the original rainbow palette with a properly balanced HCL palette (without access to the underlying data). Luckily the image contains a legend from which the original palette can be extracted. Subsequently, it is possibly to index all colors in the image, replace them, and write out the PNG again.

As a first step we read the original PNG image using the R package png, returning a height x width x 4 array containing the three RGB (red/green/blue) channels plus a channel for alpha transparency. Then, this is turned into a height x width matrix containing color hex codes using the base rgb() function:

img <- png::readPNG("influenza-rainbow.png")
img <- matrix(
  rgb(img[,,1], img[,,2], img[,,3]),
  nrow = nrow(img), ncol = ncol(img)
)

Using a manual search we find a column of pixels from the palette legend (column 630) and thin it to obtain only 99 colors:

pal_rain <- img[96:699, 630]
pal_rain <- pal_rain[seq(1, length(pal_rain), length.out = 99)]

For replacement we use a slightly adapted sequential_hcl() that was suggested by Stauffer et al. (2015) for a precipitation warning map. The "Purple-Yellow" palette is currently only in version 1.4-1 of the package on R-Forge but other sequential HCL palettes could also be used here.

library("colorspace")
pal_hcl <- sequential_hcl(99, "Purple-Yellow", p1 = 1.3, c2 = 20)

Now for replacing the RGB rainbow colors with the sequential colors, the following approach is taken: The original image is indexed by matching the color of each pixel to the closest of the 99 colors from the rainbow palette. Furthermore, to preserve the black borders and the gray shadows, 50 shades of gray are also offered for the indexing. To match pixel colors to palette colors a simple Manhattan distance (sum of absolute distances) is used in the CIELUV color space:

# 50 shades of gray
pal_gray <- gray(0:50/50)

## HCL coordinates for image and palette
img_luv <- coords(as(hex2RGB(as.vector(img)), "LUV"))
pal_luv <- coords(as(hex2RGB(c(pal_rain, pal_gray)), "LUV"))

## Manhattan distance matrix
dm <- matrix(NA, nrow = nrow(img_luv), ncol = nrow(pal_luv))
for(i in 1:nrow(pal_luv)) dm[, i] <- rowSums(abs(t(t(img_luv) - pal_luv[i,])))
idx <- apply(dm, 1, which.min)

Now each element of the img hex color matrix can be easily replaced by indexing a new palette with 99 colors (plus 50 shades of gray) using the idx vector. This is what the pal_to_png() function below does, writing the resulting matrix to a PNG file. The function is somewhat quick and dirty, makes no sanity checks, and assumes img and idx are in the calling environment.

pal_to_png <- function(pal = pal_hcl, file = "influenza.png", rev = FALSE) {
  ret <- img
  pal <- if(rev) c(rev(pal), rev(pal_gray)) else c(pal, pal_gray)
  ret[] <- pal[idx]
  ret <- coords(hex2RGB(ret))
  dim(ret) <- c(dim(img), 3)
  png::writePNG(ret, target = file)
}

With this function, we can easily produce the PNG graphic with the desaturated palette and the deuteranope version”

pal_to_png(desaturate(pal_rain), "influenza-rainbow-gray.png")
pal_to_png(    deutan(pal_rain), "influenza-rainbow-deutan.png")

The analogous graphics for the HCL-based "Purple-Yellow" palette are generated by:

pal_to_png(           pal_hcl,   "influenza-purpleyellow.png")
pal_to_png(desaturate(pal_hcl),  "influenza-purpleyellow-gray.png")
pal_to_png(    deutan(pal_hcl),  "influenza-purpleyellow-deutan.png")

Further remarks

Given that we have now extracted the pal_rain palette and set up the pal_hcl alternative we can also use the colorspace function specplot() to understand how the perceptual properties of the colors change across the two palettes. For the HCL-based palette the hue/chroma/luminance changes smoothly from dark/colorful purple to a light yellow. In contrast, in the original RGB rainbow chroma and, more importantly, luminance change non-monotonically and rather abruptly:

specplot(pal_rain)
specplot(pal_hcl)

influenza-rainbow-spectrum influenza-purpleyellow-spectrum

Given that the colors in the image are indexed now and the gray shades are in a separate subvector, we can now easily rev-erse the order in both subvectors. This yields a black background with white letters and we can use the "Inferno" palette that works well on dark backgrounds:

pal_to_png(sequential_hcl(99, "Inferno"), "influenza-inferno.png", rev = TRUE)

influenza-inferno

For more details on the limitations of the rainbow palette and further pointers see “The End of the Rainbow” by Hawkins et al. (2014) or “Somewhere over the Rainbow: How to Make Effective Use of Colors in Meteorological Visualizations” by Stauffer et al. (2015) as well as the #endrainbow hashtag on Twitter.

To leave a comment for the author, please follow the link and comment on their blog: Achim Zeileis.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Never miss an update!
Subscribe to R-bloggers to receive
e-mails with the latest R posts.
(You will not see this message again.)

Click here to close (This popup will not appear again)