A note on LED PWM linearization

Posted on 2025-12-13

A problem that is frequently misunderstood is how to correct for the human nonlinear brightness perception when dimming light sources in a way that is linear in the amount of photons emitted over time (most commonly dimming LEDs via PWM). I used to refer people to this blog post, but recently noticed that the formula used there has been "corrected" based on a source that contains a typo and now behaves much worse than before. In particular, the function is now non-continuous and only uses 93% of the available brightness range.

Since I have been unable to reach the author to get the post corrected and want to have a concise summary to link people to, I've decided to put it up here.

When searching the internet for how to linearize LED brightness, one tends to come across many formulas that kind of work in practice, but the application of which is based on misguided assumptions. In particular, the concept of "gamma correction" is more of a technological artifact than a dedicated solution for this problem. In particular, people tend to choose γ\gamma arbitrarily based on what "looks fine" for them.

Thankfully, the nice people of the CIE have been distilling the scientific results about the quirks of the human visual system into a myriad of standards for more than a century. The CIELAB/CIELUV color spaces in particular provide a mapping from perceived lightness (L*, ranging from 0 to 100) to physical luminance (Y in the CIEXYZ space) and are equivalent in this regard. Here's the relevant part of the official formulas (see section 8.2.1.1):

L=116f(YYn)16f(YYn)={YYn3if YYn>(24/116)3(841/108)YYn+16/116otherwiseL^\star = 116 * f(\frac{Y}{Y_n}) - 16 \\ f(\frac{Y}{Y_n}) = \begin{cases} \sqrt[3]{\frac{Y}{Y_n}} & \text{if } \frac{Y}{Y_n} > (24/116)^3 \\ (841/108)*\frac{Y}{Y_n} + 16/116 & \text{otherwise} \end{cases}

Simplifying this a bit (and assuming that YY is already normalized by setting Yn=1Y_n = 1 ) yields:

L={116Y316if Y>(6/29)3(29/3)3YotherwiseL^\star = \begin{cases} 116 * \sqrt[3]{Y} - 16 & \text{if } Y > (6/29)^3 \\ (29/3)^3*Y & \text{otherwise} \end{cases}

Note the difference in the first case from the original blog post: The correct coefficient is 116, not 119. Everything else follows equivalently.

We are interested in the inverse conversion:

Y={(L+16116)3if L>8L(29/3)3L903.3otherwiseY= \begin{cases} \left(\frac{L^\star + 16}{116}\right)^3 & \text{if } L^\star > 8 \\ \frac{L^\star}{(29/3)^3} \approx \frac{L^\star}{903.3} & \text{otherwise} \end{cases}

For reference, here's the fixed table generation code:

INPUT_SIZE = 255       # Input integer size
OUTPUT_SIZE = 255      # Output integer size
INT_TYPE = 'const unsigned char'
TABLE_NAME = 'cie';

def cie1976(L):
    L = L*100.0
    if L <= 8:
        return (L/903.3)
    else:
        return ((L+16.0)/116.0)**3

x = range(0,int(INPUT_SIZE+1))
y = [round(cie1976(float(L)/INPUT_SIZE)*OUTPUT_SIZE) for L in x]

with open('cie1976.h', 'w') as f:
    f.write('// CIE1976 correction table\n')
    f.write('// Automatically generated\n\n')

    f.write('%s %s[%d] = {\n' % (INT_TYPE, TABLE_NAME, INPUT_SIZE+1))
    f.write('\t')
    for i,L in enumerate(y):
        f.write('%d, ' % int(L))
        if i % 10 == 9:
            f.write('\n\t')
    f.write('\n};\n\n')