You would think that there’s nothing to know about RGB LEDs: just buy a (strip of) WS2812s with integrated 24-bit RGB drivers and start shuffling in your data. If you just want to make some shinies, and you don’t care about any sort of accurate color reproduction or consistent brightness, you’re all set.
But if you want to display video, encode data in colors, or just make some pretty art, you might want to think a little bit harder about those RGB values that you’re pushing down the wires. Any LED responds (almost) linearly to pulse-width modulation (PWM), putting out twice as much light when it’s on for twice as long, but the human eye is dramatically nonlinear. You might already know this from the one-LED case, but are you doing it right when you combine red, green, and blue?
It turns out that even getting a color-fade “right” is very tricky. Surprisingly, there’s been new science done on color perception in the last twenty years, even though both eyes and colors have been around approximately forever. In this shorty, I’ll work through just enough to get things 95% right: making yellows, magentas, and cyans about as bright as reds, greens, and blues. In the end, I’ll provide pointers to getting the last 5% right if you really want to geek out. If you’re ready to take your RGB blinkies to the next level, read on!
If you’ve ever dimmed a single LED using pulse-width modulation (PWM) before, you have certainly noticed that the response is non-linear. If you ramp up the duty cycle from 0% to 100%, it looks like the LED gets brighter very quickly in the beginning and then somewhere around the 50% mark stops getting brighter at all. On a WS2812, with its eight-bit-per-color resolution, stepping from a red value of 5 to a red value of 10 more than doubles the apparent brightness, while stepping from 250 to 255 can barely be noticed at all.
It’s not the LED or the PWM controlling it that’s to blame, however. It’s your eyes.. We perceive brightness using some kind of power law: if
B is perceived brightness and
L is the luminance — the amount of physical light that’s getting through your irises — the relationship looks roughly something like this:
That exponential relationship, requiring more and more additional light to create a perceptible difference in brightness, is characterized by that Greek exponent: gamma. For your intuition, gamma values from just around 1.5 to around 3 are probably reasonable to consider. Arbitrarily picking gamma to be 2 makes that fractional gamma exponent into a more comfortable square root and usually isn’t too far wrong. 2.2 is a standard value for CRT monitors in the PC world, and 1.8 used to be the standard for Macs.
But if you really care about the way your LEDs look, you’ll want to tweak the gamma to your particular conditions. I like to think of choosing a gamma in terms of black-and-white photography. If we gamma-correct with a value that’s bigger than your eye’s natural gamma an image will look too contrasty — there will be jumps in the brightness where you’d want it to be smooth. If the gamma is set lower than your eye’s gamma, differences will be muted, and it will look muddy. Get it just right, and you get a smooth transition from dark to light across the full range.
Taking the 2.314’th root of a given number is a tall task to ask of a microcontroller, though, and it’s probably overkill. In the end, I usually implement the gamma correction as a lookup table that turns the desired brightness directly into whatever numbers the chip’s PWM routine wants, so there’s no math left to do at all at runtime. Here’s a quick and dirty Python script that will generate the lookup table for you.
Now in Color
Gamma correction can make your single-color LED effects look a lot better. But what happens when you step up from monochrome to RGB color? Imagine that you’ve gone through the whole gamma experiment above with just the red channel of a WS2812 LED. Now you add the green and blue LEDs to the mix. How much brighter does it seem? If you weren’t paying attention above (yawn, math!) you’d say three times brighter. The right answer is the gamma’th root of three.
Strictly speaking, computing brightness depends on the mix of light coming out of all three LEDs. The good news is that you can also figure out the brightness of any arbitrary color combination with gammas. Here’s the formula:
Given any ratio of red to green to blue, you can use this formula to work out the PWM values for each LED that you need to brighten or dim the overall color in equally-sized steps.
The other use of the brightness formula above is in fading from one color to another, keeping the perceived brightness constant. For instance, to fade from red to blue naïvely, you might start at (255,0,0) and head over toward (0,0,255) by subtracting some red and adding the same amount of blue. Plugging those values into the brightness formula, the result appears significantly dimmer in the middle: down to about 70% of the brightness of the pure colors. Unfortunately, this is the way that nearly everyone online tells you to do it. That doesn’t make it right. (Or maybe they just don’t care about brightness?)
A great way to figure out the gamma that you’d like for RGB LEDs is to set up a color fade and adjust the gamma until there is apparently uniform brightness across the strip. In fact, you can do this with just three LEDs. To make the effect most dramatic, it helps to start with medium brightness on either end of the fade: I’ll use (70,0,0) and (0,70,0) for instance. The middle LED should be some kind of yellow with equal parts of red and green. Tweak the amounts of these values until you think that all three LEDs are about the same brightness, and you can solve for your personal gamma.
Color Palettes and Lookup Tables
On a slow microcontroller, or on one that should be doing more important things with its CPU time than computing colors, constantly adjusting color values for brightness is a no-go. In the single-LED case, a lookup table worked well. But in RGB space, a three-dimensional array is needed. For a small number of colors, this can still be workable: five levels of red, blue, and green produces a palette with only 125 (53) entries. If you’ve got flash memory to spare, you can extend this as far as you’d like.
An alternative workaround is to gamma-adjust the individual channels first. This gets the brightness right, but it also affects the rate at which the hue changes across the cross-fade. You might like this effect or you might not — the best is to experiment. It’s certainly simple.
Color Sensitivity and Other Details
For me, getting control of the brightness of a color LED is about 95% of the battle. The remaining 5% is in getting precise control of the hue. That said, there are two quirks of the human visual system that matter for the hues.
The situation with the cross-fade of colors is actually more complicated than I’ve made them out to be; the eye isn’t uniformly sensitive to each wavelength of light. If you mixed together 10 lumens of red, 10 lumens of green, and 10 lumens of blue, the result would look overwhelmingly blue. The good news is that this effect is so strong that monitor and RGB LED manufacturers pre-weight the amount of light coming out of each LED for you.
So when you assign a value of (10%, 10%, 10%) to an RGB LED, each of the red, green, and blue LEDs are on for 10% of the time, but the green LED is about three times brighter than the red, and ten times brighter than the blue. The LEDs used take care of the (rough) color-balancing for you, so at least that’s one thing that you don’t have to worry about.
Perceptual Uniformity of Hue
If you’re trying to encode numerical values in colors, however, there’s one last quirk of the human perceptual system that you might want to be aware of. We are more sensitive to differences in some colors than in others. In particular, hues around the yellow and cyan regions are really easy for us to distinguish, while different shades of reds and blues are much more difficult. Getting this right is non-trivial, not least because our perception of one color depends on the colors that it’s surrounded by. (Remember the “white and gold” dress?)
Anyway, here’s a library that does pretty darn well at addressing the perceptual uniformity of hues issue, given they’re constrained to using piecewise linear functions. They sacrifice some degree of uniform brightness to get there, though.
If you just need a few colors along a perceptually uniform color gradient, Color Brewer has your back. Python’s matplotlib is going to change its default color scale to one with significantly increased perceptual uniformity and constant brightness, and this video explaining why and how has a great overview of the subject. It’s not simple, but at least they’re getting it right.
Finally, if you’d really like to dive into color theory, this series has much more detail than you’re ever likely to need to know.
You can get lost in colors fairly easily, and it’s fun and rewarding to geek out a bit. On the other hand, you can make your LED blinky toys look a lot better just by getting the brightness right, and you do that by figuring out the appropriate gamma for your situation and applying a little math. The “right” gamma is a matter of trial and error, but something around two should work OK for starters. Give it a shot and let me know what you think in the comments. Or better yet, use RGB-gamma-correction in your next project and show us all.