Gamma+WS2812
Mountain Lizard IconMountain Lizard Icon Gamma+WS2812
mountain-lizard
Displaying accurate colors and brightness scales using gamma correction designed for WS2812 LEDs

Introduction

I’ve been putting together a few different devices incorporating RGB lighting to be controlled by Home Assistant via MQTT, and along the way I’ve picked up a few interesting pointers on how to do this to get a result that “looks right”. I think pretty muchb all this information is out there, but sometimes not in the easiest format or with all the parts put together.

I’ve been using Rust and embassy on RP2040 and RP2350 processors, driving WS2812B LEDs so code examples below will be in Rust aimed at that combination, but the basic principles should apply to different systems and LEDs.

This post got a lot longer than I was intending, so as a bit of a tl;dr:

  1. The first few sections just cover gamma correction, so if you’re up to speed on this feel free to skip.
  2. It turns out that according to this very interesting post the commonly used WS2812 family of LED drivers don’t actually produce a completely linear intensity output according to the input. Specifically, lower inputs up to about 20 don’t map linearly to the PWM duty cycle used - they use a shorter duty, and so WS2812 LEDs will display dimmer than expected for low inputs. This turns out to be really useful for producing a gamma-corrected output that handles dim colors better. The linked article points this out, but doesn’t give a recipe for using this feature of the WS2812 with gamma correction - this is covered in the final section - feel free to skip there if you just want a drop-in replacement gamma look up table.

Gamma correction - achieving an even scale of brightness

The human eye is complex, and we see light and colours in fairly complex ways. One key aspect is that we don’t perceive “twice as much light” as being “twice as bright” - the relationship between the actual intensity of light (e.g. how many photons per second are hitting your eye) and its perceived brightness (how “bright” something looks to an average human) is non-linear.

LEDs on the other hand will tend to produce a fairly linear output in terms of the amount (intensity) of light. This is particularly the case when LEDs are driven in the most common way, using an approach called Pulse-Width Modulation (PWM). This turns the LED on and off very rapidly so we don’t see the flicker - each on/off cycle lasts the same time (say 1/10,000th of a second). In each cycle the LED will be turned on for some proportion of the total cycle time (the pulse width), and the longer it is on per cycle the brighter it will appear. Say we use a scale of 0.0 to 1.0, so that an input of 0.0 represents the LED being off all the time, 0.1 would mean the LED is on 10% of the time, and so on up to 1.0 where the LED is on all the time (and so is emitting the maximum amount of light it can). For an ideal PWM driver, the amount of light emitted on average is then just the proportion of the time the LED is on, multiplied by the maximum amount of light (when the LED is on all the time). The real response may be a little different, we’ll get into this more later, but this is a good model of at least some LED drivers.

So what does that mean when we are actually controlling LEDs? The first row of the table below shows an approximation of how bright a white LED would look (in relative terms) when driven directly with a PWM input from 0.0 to 1.0 (adapted from the table in the Wikipedia page for Gamma Correction):

0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0
Linear intensity (e.g. input to PWM driver)
Linear perceived Brightness

To most people, the scale will not look very even - going from 0.0 to 0.1 looks like a much bigger jump in brightness than 0.9 to 1.0. In fact the whole gradient “tails off” towards the 1.0 end, with smaller and smaller perceived increases in brightness.

The second row of the table is designed to look more even - since the eye is more sensitive to changes at lower intensity, it spreads out the low intensity values to give a more even gradient. Eyeballing it (or checking the HTML styles!), we can see that the perceived brightness values 0.0 up to 0.3 all lie within the single 0.0 to 0.1 step on the linear intensity scale, allowing us to display darker greys (and colours with darker components) more accurately.

If we plot linear intensity against perceived brightness, we can see this more clearly:

Linear and Gamma-Corrected Outputs

Along the x-axis, we have the input - our perceived brightness we are trying to achieve. We want this to represent how bright an average user might perceive an LED to be, so that if they are for example dragging a slider from 0 to 1, they will perceive 0.5 to be about half as bright as 1.0, and so on.

On the y-axis, we have the output we supply to our LED, in terms of its actual intensity (e.g. our PWM duty cycle), from 0.0 for “off” to 1.0 for “full brightness”.

The red curve is just for reference - this shows what happens if we just feed out input straight through to the output, and don’t perform any correction - this will look like the top row of the table above, with uneven steps in brightness.

The blue curve shows us what we need to output to achieve a perceived linear brightness - as described above, we can see that we need to start out slowly, since the human eye is more sensitive to small changes at lower brightness levels, but then pick up as we go along, so we exaggerate the changes at brighter levels. We still end up at an intensity output of 1, so the maximum brightness is not affected.

This is where we get to the “gamma” term you may have run into - this is the common name for a parameter we can choose to set what our blue curve looks like. In the graph above, we’ve chosen a value of 2.0 - there’s no one correct number, but values from 1.8 to 2.2 are commonly used, and 2.0 therefore represents a compromise. If we use higher gamma values, the curve becomes more curved (further away from the red line), if we use lower values the curve straightens out - if we use a gamma of 1.0 we get back to a “straight through” mapping where we don’t perform any correction - this matches the red line on the graph.

So if we’re using an “ideal” LED, we can implement this correction pretty easily - just generate a look-up table for your desired gamma value, and at the last stage before sending data to the LEDs as PWM duty cycles, look up the gamma corrected output from your desired input, e.g. in rust:

pub static GAMMA8: [u8; 256] = [
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4,
    // values omitted...
    249, 251, 253, 255,
];

This table has 256 entries, each a u8 (from 0 to 255). If we want to display 50% gray, we convert this to the 0-255 scale as about 128, then we look up index 128 in the table, and get a u8 value from 0 to 255 we can provide as the setting for the LED (e.g. as a duty cycle for a PWM with a cycle length of 256 units).

We can see one advantage of choosing a slightly lower gamma here - because of the limited range of the data we can send to the LED, we end up sending 0 for the first 12 input values, so when displaying dark shades we will often just have the LED turned off. If we used a higher gamma value, even more input values would produce 0 output values.

It’s very useful to have more range for driving LEDs via PWM - for example on an RP2040 we can get a much finer scale - we can make each cycle of the PWM last 32768 “ticks”, where each tick is 8 nanoseconds long (based on a 125MHz clock), giving around 3,800 cycles per second (which should avoid any visible flickering). Then we can produce a gamma lookup table producing u16 numbers from 0 to 32767, and this will allow us to see all the different input levels as different output levels:

pub static GAMMA16: [u16; 256] = [
    0, 1, 2, 5, 8, 13, 18, 25, 32, 41, 50, 61, 73, 85, 99, 113, 129, 146, 163, 182, 202, 222, 244,
    // values omitted...
    29756, 30001, 30247, 30495, 30743, 30993, 31243, 31495, 31747, 32001, 32255, 32511, 32767,
];

This gamma lookup table generator is a useful tool for generating tables with different input and output ranges.

Note that the colors you may be used to seeing in graphics applications, HTML/CSS and so on are often represented this way, with red, green and blue components all on a 0-255 scale, where the scale is corrected to represent linear perceived brightness. So if you’re working with colors in this format, this is another reason to apply a gamma correction. Note that there are different RGB formats and they may be designed for different gamma values, but using a similar value to display them on LEDs is much better than not correcting at all, even if the values don’t exactly match.

The conversion should always be the last step, just before sending values to your LED - if you want to make an LED half as bright, multiply by 0.5 BEFORE converting to a gamma corrected output, not after. Probably the best way to think of this is that your gamma correction and the LED are acting together as a single “module”, so you can provide an intensity on a linear perceived brightness scale, and get that module to display it accurately. At all stages before that, you are generally best working with the linear perceived brightness scale itself, as you do when manipulating colors in graphics applications, on the web, etc.

When working with RGB values, you’ll generally just convert each individual component (red, green and blue) with the same gamma correction, independently. As long as the RGB LEDs you’re using are approximately “balanced” in terms of how bright the individual red, green and blue LEDs appear at maximum brightness, this should look reasonable.

Correcting white balance (RGB)

If setting all LEDs to maximum brightness doesn’t give a reasonable-looking white color, you will have to apply your own white balance. E.g. if turning on all the LEDs produces a bluish color, try driving the blue LED to say 90% of maximum output. This needs to be done on a linear intensity scale, so without gamma correction, e.g. just using the PWM directly. Find an adjusted maximum for each LED color where you have a white looking output, with one of the colors still set to 100%.

Note that what “looks white” will be very dependent on the environment you are in when you do the calibration, since the human eye will tend to compensate for lighting etc. - ideally, compare the LED color to a well-calibrated screen displaying white, or perform the calibration in the kind of environment where you will be using the LEDs, next to a sheet of “white” paper. If you want a kind of “default” lighting, try outside in midday sunlight on a clear day (as long as the LEDs are still bright enough to see clearly!). You’ll probably find you can’t get the LEDs to look exactly the same as a good monitor or piece of paper - it’s hard to get an RGB LED mix that looks like a nice clean white, which is one reason RGBW strips with specific white LEDs exist - you’re just aiming to get things about even.

When balancing a backlight (on the excellent Pimoroni Pico GFX Pack display), I found that I got a “white” output when my red PWM value was set to the maximum of 255 (100%), green to 130 (about 51%), and blue to 90 (about 35%).

When using these numbers, we run into the only exception I’ve seen to the rule “apply gamma last” - this is because we want to adjust the brighter LEDs to just behave like they have the same maximum brightness as the others, to line everything up. By correcting after gamma, we ensure that we just get a linear proportion of the light intensity, as would be produced by a dimmer LED, rather than changing the perceived brightness scale.

For each colour channel, this just involves taking our perceived brightness value, converting it to a gamma corrected value, and then finally multiplying by the maximum percentage amount we want for that channel. Implemented as fixed point arithmetic to use integer operations, this looks like:

// Convert perceived brightess color (r, g, b) to linear intensities (rl, gl, bl)
let rl = GAMMA8[r as usize];
let gl = GAMMA8[g as usize];
let bl = GAMMA8[b as usize];

// Now scale those linear intensities by the adjusted maxima, using 8 fractional bits
// 255, 130 and 90 are the RGB PWM values we found gave us an even white output.
let ro = (((rl as u32) * 255) >> 8) as u8;
let go = (((gl as u32) * 130) >> 8) as u8;
let bo = (((bl as u32) * 90) >> 8) as u8;

Correcting white balance (RGBW)

If you’re lucky enough to have an RGBW LED with a dedicated white LED, this should give a much nicer white output than any mixture of just the red, green and blue LEDs, when displaying white or shades of grey.

If you want to drive a full range of colors, but use the white LED where possible, you can perform a longer calibration. First find the ratio of red, green and blue outputs (again on a linear intensity scale) that matches the color of the white LED (as closely as you can manage), and has one of the RGB channels set to 100%. Then find the level of white output that matches this RGB output in brightness (i.e. switch back and forth between just having the RGB LEDs on, set to their adjust maximum values, and just having the white LED on, adjusting the white LED value until there’s as little difference as possible when switching). If you can’t get the white LED bright enough, then you will need to reduce down the top RGB intensity, say from 100% to 90%, and find a new set of RGB values that matches the white LED, then find the white LED intensity that matches this RGB output. Finally, you can do a bit of maths to work out for any given perceived brightness RGB colour, what R, G, B and W outputs to use:

Going back to the backlight we used for the RGB white balance example - it also had a separate white LED channel, and when using this we found that when setting the RGB LEDs to (255, 130, 90) we got a white (ish) output that matched the color of the white LEDs. We then needed to set the white LEDs to 85 (around 33%) to match the RGB LEDs.

We can then perform a more complex calculation that basically boils down to using as much of the white LED as we can (for the light that is at the same intensity in all colors), and then making up the remaining “colored” light using the RGB LEDs:

/// The first part is the same as the plain RGB case:
// Convert perceived brightess color (r, g, b) to linear intensities (rl, gl, bl)
let rl = GAMMA8[r as usize];
let gl = GAMMA8[g as usize];
let bl = GAMMA8[b as usize];

// Now we work out how much of the RGB output intensity we
// can replace with the white LED - this is the
// minimum of r,g and b since the white LED can only
// produce equal amounts of r, g and b output.
let wl = cmp::min(cmp::min(rl, gl), bl);

// Subtract off the amount of r, g, b light we are displaying
// using white LED - we don't need to produce this from the actual
// RGB LEDs
let rl = rl - wl;
let gl = gl - wl;
let bl = bl - wl;

// Now we have linear intensities on a 0-255 scale for each LED, so the
// final stage is just like the RGB case, but we also scale the white
// LED output appropriately to line up with the RGB channels.
let ro = (((rl as u32) * 255) >> 8) as u8;
let go = (((gl as u32) * 130) >> 8) as u8;
let bo = (((bl as u32) * 90) >> 8) as u8;
let wo = (((wl as u32) * 85) >> 8) as u8;

We can then just send the (ro, go, bo, wo) values to the LEDs.

In theory, you can make a different trade-off here, and produce brighter outputs by not covering all colours and/or not using just the white LED for white output, but as far as I can tell the approach above is a good balance. It uses the white LED as much as possible to give better looking output, while still displaying any RGB input approximately correctly, and not reducing jumps or color shifts as brightness output changes.

Gamma correcting WS2812 LEDs

It turns out that if we’re using the very common WS2812 line of LEDs / LED drivers, we can (and probably should) adapt our gamma correction to account for how these LEDs behave. All the explanations above on gamma correction assume that we take an input using a perceived brightness scale, then convert it using gamma correction to an output using a linear intensity scale, and that we can then send that linear intensity value to our LED to be displayed exactly. If we send 10% to the LED, we get 10% of the maximum light. This is at least very close to true for many ways of driving LEDs (e.g. using PWM), but according to this very interesting post, WS2812 LEDs to not work exactly this way.

I’d recommend reading the whole article, but I’ve extracted the most relevant parts below, ending up with a modified gamma correction look up table for use on WS2812 LEDs that (to my eye) gives much better performance for darker colors.

The key thing finding is that for lower inputs to the LED (i.e. data values sent on the data pin), the WS2812 produces lower than expected output, in terms of the linear intensity of light produced:

WS2812 Behaviour - graph of input and output

You can see that for about the first 20 input values, the output “ramps up” considerably slower than linearly. The blue curve shows what an “ideal” LED would do, producing a straight line where output is directly proportional to input. The red curve shows the WS2812.

This is very useful, because as described above in the section on gamma correction, a key issue is that a gamma correction of 2.0 will essentially produce no light for inputs from 0 to 11, if we assume a linear output. This leads to darker colors producing completely dark LEDs, or only setting the LEDs to one of a few values, producing very inaccurate colors (either plain primary RGB, or primary plus secondary so we get yellow, cyan and purple as well as RGB). But since the WS2812 is has this non-linear mapping, we should actually be sending higher inputs to it, to produce the expected output, and this does a lot to allow us to accurately reproduce the dimmer end of our gamma curve.

To produce this adjusted gamma curve, I went through the following steps:

  1. Shamelessly grabbed some data points from the graphs in the linked post - rounding things a little we get:

    Input IntensityOutput
    30.001
    100.01
    2551

    Note that apparently later versions (v5 and later) of the WS2812B don’t produce any light output for inputs under 3, so I started at code 3 and assumed an output of 0.0 for lower inputs - we fix this up a little later.

  2. Linearly interpolated between these points, to give the graph shown above.

  3. Converted this back to log-log axes so we can check it looks like a good fit to the raw data on the linked post. To me the data looks pretty close:

    WS2812 Behaviour (log-log graph of input and output)

  4. Produced a gamma curve for gamma=2.0, for inputs from 0-255, outputs from 0.0 to 1.0.

  5. For each input to the gamma curve, find the output - this is the linear intensity we need. Then look this output intensity up on the actual curve for the WS2812, so we can see what input code to the WS2812 gets us closest to the desired linear intensity - this is the value we’ll use in our corrected “gamma” curve and LUT. This has the effect of choosing higher codes for each input, especially for lower perceived intensities.

Plotting this adjusted correction curve against a “plain” gamma curve, we can see it’s significantly different:

"Plain" and WS2812-corrected gamma curves

The plain red curve starts much closer to the x-axis, and as multiple 0 output values before the LED turns on at all. The corrected blue curve picks up much faster at these darker values, to compensate for the fact that the WS2812 does not ramp up output as fast as expected in this region. This means we’ll see much more precision in darker values, and less “banding” in gradients and rainbows.

As mentioned above, we assume a 0 output for codes under 3 sent to a WS2812, so the original calculation just ends up with the first 9 entries as exactly 2 (since this gives a 0 output). We manually adjust for this by replacing the first six values with [0, 0, 0, 1, 1, 1] - this will make no difference on a v5 WS2812, but will look a little closer to the right values on earlier versions while reducing banding, and will still turn the LED off completely for low inputs.

In practice, this makes a big difference at least on the strip where I’ve tested it - before, a 10% (perceived brightness) rainbow looked pretty awful, using only a few codes so appearing as just primary/secondary colors (red/yellow/green/cyan/blue/purple). Now a 10% rainbow has only a little banding, and we only reach the primary/secondary colors point at 2% perceived brightness, then 1% drops to red/green/blue only (probably indicating I’ve got a pre-v5 strip!).

It’s important to note that this approach is only valid on actual WS2812 LEDs or something closely reproducing their non-linear output. In the linked post, clones from other manufacturers seemed to produce a linear output, so should be used with the normal gamma table, and will produce worse results than WS2812s.

This is the full 256 entry table for converting from perceived brightness to a code to send to the WS2812, allowing for non-linear mapping of codes to actual brightness of the WS2812, it can be used exactly like a normal gamma table for a linear-output LED:

pub static GAMMA8_WS2812B: [u8; 256] = [
    0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11,
    11, 12, 12, 13, 13, 14, 14, 15, 16, 16, 17, 17, 18, 19, 19, 20, 20, 20, 21, 21, 22, 22, 22, 23,
    23, 24, 24, 24, 25, 25, 26, 26, 27, 27, 27, 28, 28, 29, 29, 30, 30, 31, 32, 32, 33, 33, 34, 34,
    35, 35, 36, 37, 37, 38, 39, 39, 40, 40, 41, 42, 42, 43, 44, 44, 45, 46, 47, 47, 48, 49, 49, 50,
    51, 52, 53, 53, 54, 55, 56, 56, 57, 58, 59, 60, 61, 62, 62, 63, 64, 65, 66, 67, 68, 69, 70, 70,
    71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 91, 92, 93, 94, 95,
    96, 97, 98, 99, 101, 102, 103, 104, 105, 106, 108, 109, 110, 111, 112, 114, 115, 116, 117, 119,
    120, 121, 122, 124, 125, 126, 128, 129, 130, 132, 133, 134, 136, 137, 138, 140, 141, 143, 144,
    145, 147, 148, 150, 151, 152, 154, 155, 157, 158, 160, 161, 163, 164, 166, 167, 169, 170, 172,
    173, 175, 177, 178, 180, 181, 183, 184, 186, 188, 189, 191, 193, 194, 196, 198, 199, 201, 203,
    204, 206, 208, 209, 211, 213, 215, 216, 218, 220, 222, 223, 225, 227, 229, 230, 232, 234, 236,
    238, 240, 241, 243, 245, 247, 249, 251, 253, 255,
];

This could probably be tweaked a little, e.g. at the moment it uses the highest code that produces an intensity below the desired one, it might be best to round to the nearest intensity.