Choosing robust distinct hue palettes for RGB LEDs at low brightness/low value resolution

25 Apr 2021 | all notes

For a couple of projects I’ve needed to display hue gradients (such as for air quality, temperature, dew point etc.) on surface-mounted (SMD) RGB LEDs such as the 5050. An issue with the multi-step gradient palettes specified for air quality indices and similar measures is that they are typically designed for print or high-resolution (LCD or OLED screen) displays which, if we are talking in terms of HSV color space, are great at representing both differences in hue as well as the other two dimensions of value (i.e. brightness) and saturation.

When trying to use such gradient palettes on powerful surface-mounted LEDs such as the 5050 there are two problems: firstly, unless you stand far away from the display, differences in value (brightness of the color) are not easily perceptible. But even more importantly, you often want to be able to adjust the overall brightness of the display based on the ambient light conditions. (5050s in a small dark room at full brightness are painful.) If you can only reduce the intensity of the LEDs digitally (rather than say by reducing the input voltage of all LEDs), an easy way to do this when specifying colors in HSV is to simply reduce, scale or cap the value component of the colors. But, since that means you effectively have fewer levels of intensity available for each LED, this in turn means you have fewer mixing ratios between the LEDs available, which means fewer hues.

I didn’t realise how bad this could be until I tried to specify my own hue-gradient color palette for an Arduino project using the FastLED library, which comes with handy on-the-fly support for HSV-to-RGB conversion. For performance reasons the library only allows you to encode hue, which can in principle be any number in the [0,360) interval, as one byte, i.e. there are only 256 levels of hue. This by itself is reasonable enough, the problem is that several other factors conspire to produce in pretty unreliable hue gradients at value levels below 50.

The following graph shows which 8 bit hue specifications of the form CHSV(hue, 255, value) map onto which output hues at different value (brightness) levels from 1-100, where again the value scale can go as high as 255. The labels on the right summarize how many different fully saturated hues (and, in brackets, how many different RGB colors) are effectively available for each value level.

This graph is based on FastLED’s improved ‘rainbow’ hue, which has a wider yellow region and subdued blue, resulting in a more ‘natural’ rainbow spectrum to the human eye. The parametric fine-tuning of this spectrum is apparent in the asymmetric expansion of the different hues as brightness (value) increases. A consequence of this is that there are some discontinuity artefacts in available hues at brightnesses as high as 50, e.g. pure yellow is available at the lowest value levels, but disappears from the available spectrum at a value of 16. There is also a very apparent mismatch of FastLED’s pre-defined HUE_* constants, marked by the vertical dotted lines: 192 is called HUE_PURPLE and 224 HUE_PINK, when it would actually be a hue of 208 that would consistently pick out a pink color across different brightness levels.

If all the colors are fully saturated (they are specified as CHSV(hue, 255, value)), how is it possible that in a single row there can be more different colors than different hues? At value levels 16-22, for example, a hue of 224 maps onto a pure RGB red, i.e. only the red LED will be on. However, the LED is driven at a lower power/brightness than it is for hues in [0,32] and [225,255]. So in this case the difference in hue is actually translated into two different RGB colors with the same hue, both pure red, just at slightly different overall brightnesses.

Based on this finding I decided that, rather than dynamically computing hues based on a numerical scale, it would be safer to hand-code a number of distinct hue levels which consistently map onto effective hues at different value levels. I assumed that for this purpose the non-tweaked, fully symmetric ‘spectrum hue’ (available through hsv2rgb_spectrum() function) would be a better bet, but in a way the results were even weirder:

There are a number of noteworthy things, both good and bad:

For sake of completeness, here’s a chart showing the distinct hues that should be available for spectrum-based HSV-to-RGB conversion at different value levels when the hue representation is limited to one byte, but without any additional loss in resolution when converting to RGB.