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:
- note how this graph only goes up to a value level of 45, at which point there are already more hues and colors available than at value 100 with the rainbow spectrum
- while this raw spectrum conversion is computationally faster than the rainbow spectrum, it relies on the initial 8 bit hue specification first being truncated to 6 bits, which means that even at full brightness there will never be more than 192 different hues available
- note how there is no data at a value (brightness) level of 1: for
some reason
hsv2rgb_spectrum()
produces individual LED brightnesses which are one lower than they should be, so a value of 1 actually maps onto all LEDs being completely off, a value of 2 maps onto one or two of the LEDs being lit at the minimal level 1 etc. - a catastrophic loss of available hues at value levels of 8, 16 and 32, who knows why, probably some (lack of) rounding issue
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.