A large dome-shaped building is centered between two streets lined with buildings at night, one side illuminated by a full moon and the other by a shooting star.  Vehicles and pedestrians are visible on the wet streets.

When designing lighting systems, engineers face a fascinating challenge that bridges physics, electronics, and human biology. The technical implementation of dimming an LED using Pulse Width Modulation (PWM) seems straightforward on paper, but when humans interact with these systems, something curious happens. What appears to be a perfectly mathematically linear dimming curve looks completely non-linear to the human observer. This disconnect lies at the heart of why gamma correction is essential in LED technology.

Part 1: Understanding Gamma Correction in LED PWM Dimming

The Technical Approach vs. Human Experience

PWM dimming works by turning LEDs on and off faster than the brain can perceive, with the “on” time (duty cycle) determining brightness. If we set the duty cycle to 50%, the LED is on for exactly half the time. Technically speaking, this should produce 50% brightness.

But our eyes don’t perceive it that way. A 50% duty cycle actually appears much brighter than halfway between full brightness and darkness. This creates an unnatural dimming curve where:

  • Small changes at high brightness levels are barely noticeable
  • Small changes at low brightness levels cause dramatic jumps in perceived brightness

What is Gamma Correction?

Gamma correction is a mathematical adjustment applied to PWM duty cycles to compensate for our non-linear perception of light. Instead of using a linear relationship between the control value and duty cycle, we apply a power function:

Corrected duty cycle = (Input control value)^γ

Where γ (gamma) is typically around 2.2-2.8.

In practice a cubic function is close to 2.8, or a square function is close to 2.2.

How Gamma Correction Works

When we apply gamma correction:

  1. We take the input control value (typically 0-255 or 0-100%)
  2. Raise it to a power (the gamma value, typically ~2.2)
  3. Scale the result to get the actual PWM duty cycle

For example, with a gamma of 2.2:

  • A 50% input control value becomes a 22% PWM duty cycle (0.5^2.2 ≈ 0.22)
  • A 25% input control value becomes a 5% PWM duty cycle (0.25^2.2 ≈ 0.05)

This creates a curved relationship that matches human perception, making light dimming feel natural and providing finer control at low brightness levels.

Implementation Methods

In practice, gamma correction is typically implemented in three ways:

  1. Look-up tables: Pre-calculated values stored in memory, allowing for quick conversion with minimal processing power
  2. Real-time calculation: Computing the power function for each input value
  3. Approximation functions: Using simpler calculations that closely mimic the gamma curve

For microcontrollers with limited resources, look-up tables are often the most efficient approach.

Why It Matters

Without gamma correction, LED dimming would feel uneven and unpredictable:

  • Dimming from 100% to 50% would barely be noticeable
  • Dimming from 20% to 10% would cause a dramatic drop in brightness

With proper gamma correction, each step in dimming feels consistent and natural, mimicking the behavior of traditional incandescent dimming that we’ve become accustomed to.

Additionally, gamma correction enables much finer control at the lower brightness ranges where our eyes are most sensitive, allowing for subtle mood lighting and smooth transitions.

Demo

Part 2: The Logarithmic Nature of Human Perception

To fully understand why gamma correction is necessary, we need to explore the fascinating way human perception actually works. The fundamental principle behind gamma correction is that human vision roughly follows a logarithmic response curve rather than a linear one. But why did we evolve this way?

Evolutionary Adaptation

Our visual system evolved in an environment where detecting small differences in low light was crucial for survival. For our ancestors, distinguishing shapes in the shadows or at dawn/dusk could mean the difference between spotting a predator or missing it. However, the absolute difference between very bright conditions (like direct sunlight versus slight cloud cover) was less survival-critical.

This created evolutionary pressure to allocate our limited neural processing resources efficiently. Our visual system adapted to be most sensitive to relative changes in stimuli rather than absolute ones, especially in low-light conditions.

The Retina’s Response Curve

At the cellular level, the photoreceptors in our retina (rods and cones) don’t respond linearly to light intensity. When a photon of light hits a photoreceptor, it triggers a cascade of biochemical reactions. This cascade has built-in saturation effects—as more photons arrive, the incremental neural response diminishes.

Rods, which handle low-light vision, become saturated at moderate light levels. Cones, responsible for color vision, have a wider dynamic range but still follow a non-linear response curve. This creates a compressed signal where the neural response to increasing light intensity follows an approximately logarithmic function.

The Weber-Fechner Law

This logarithmic relationship was formalized in the 19th century as the Weber-Fechner Law, which states that the perceived intensity of a stimulus is proportional to the logarithm of the actual physical intensity. Ernst Weber first observed that the just-noticeable difference (JND) between two stimuli is proportional to the magnitude of the stimuli. Gustav Fechner later expanded this into a logarithmic relationship between stimulus intensity and perception.

For example, if you can just barely detect the difference between a 100-watt and 110-watt light bulb (a 10-watt difference), you would need about a 22-watt difference to detect a change from a 220-watt bulb (to approximately 242 watts). The ratio remains constant rather than the absolute difference.

Efficient Information Processing

This logarithmic perception provides several advantages:

  1. Dynamic range compression: Our eyes can function across an extraordinary range of light intensities—from starlight to bright sunlight, spanning roughly 10 billion to 1. A logarithmic response compresses this vast range into a manageable neural signal.

  2. Enhanced contrast sensitivity: By being more sensitive to changes in darker regions, we can extract more useful information from shadows and low-light environments.

  3. Neural efficiency: The brain has limited processing capacity. Logarithmic encoding allows it to represent a wide range of sensory information using fewer neural resources.

  4. Relative change detection: In nature, detecting relative changes (like percentage differences) is often more useful than detecting absolute changes.

Beyond Vision: A Universal Principle

Interestingly, this logarithmic relationship isn’t unique to vision. It applies to many other sensory systems:

  • Hearing: We perceive sound volume logarithmically (measured in decibels), not linearly.
  • Touch: Our ability to detect differences in pressure follows a similar pattern.
  • Taste/smell: Our sensitivity to increasing concentrations of substances also follows a roughly logarithmic curve.

This suggests that logarithmic perception is a fundamental principle of how our brain processes sensory information efficiently.

Part III: Microcontroller implementations

Exact function

This method is exact but requires floating point math, impractical for large LED matrices driven by a microcontroller without an FPU.

uint8_t apply_gamma(uint8_t input, float gamma, uint8_t max_input, uint8_t max_output) {
    // Normalize input to 0.0-1.0 range
    float normalized = (float)input / max_input;
    
    // Apply gamma correction
    float corrected = powf(normalized, gamma);
    
    // Scale back to output range and round to nearest integer
    return (uint8_t)(corrected * max_output + 0.5f);
}

Where gamma is a value in the range 2.2 - 2.8

Lookup table

Precomputing all possible 8 bit values is the most common approach.

This table uses a gamma value of 2.8:

static const uint8_t GAMMA8[255] = {
            0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
            0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
            0,   0,   1,   1,   1,   1,   1,   1,   1,   1,   1,   1,   1,
            1,   1,   2,   2,   2,   2,   2,   2,   2,   2,   3,   3,   3,
            3,   3,   3,   3,   4,   4,   4,   4,   4,   5,   5,   5,   5,
            6,   6,   6,   6,   7,   7,   7,   7,   8,   8,   8,   9,   9,
            9,   10,  10,  10,  11,  11,  11,  12,  12,  13,  13,  13,  14,
            14,  15,  15,  16,  16,  17,  17,  18,  18,  19,  19,  20,  20,
            21,  21,  22,  22,  23,  24,  24,  25,  25,  26,  27,  27,  28,
            29,  29,  30,  31,  32,  32,  33,  34,  35,  35,  36,  37,  38,
            39,  39,  40,  41,  42,  43,  44,  45,  46,  47,  48,  49,  50,
            50,  51,  52,  54,  55,  56,  57,  58,  59,  60,  61,  62,  63,
            64,  66,  67,  68,  69,  70,  72,  73,  74,  75,  77,  78,  79,
            81,  82,  83,  85,  86,  87,  89,  90,  92,  93,  95,  96,  98,
            99,  101, 102, 104, 105, 107, 109, 110, 112, 114, 115, 117, 119,
            120, 122, 124, 126, 127, 129, 131, 133, 135, 137, 138, 140, 142,
            144, 146, 148, 150, 152, 154, 156, 158, 160, 162, 164, 167, 169,
            171, 173, 175, 177, 180, 182, 184, 186, 189, 191, 193, 196, 198,
            200, 203, 205, 208, 210, 213, 215, 218, 220, 223, 225, 228, 231,
            233, 236, 239, 241, 244, 247, 249, 252, 255};

In Arduino PROGMEM may be used if you’re tight on RAM.

This requires either a memory tradeoff or the overhead of flash reads.

LUT generation

This table can be generated for a given max range and gamma value from the python code:

def apply_gamma(input_val, gamma, max_input, max_output):
    # Normalize input to 0.0-1.0 range
    normalized = float(input_val) / max_input
    
    # Apply gamma correction
    corrected = normalized ** gamma
    
    # Scale back to output range and round to nearest integer
    return int(corrected * max_output + 0.5)
		
def build_table(upper_limit=255, gamma=2.2):
    return [apply_gamma(i, gamma, upper_limit, upper_limit) for i in range(upper_limit + 1)]

Live generation

The below will generate a lookup table for given input parameters using javascript.

Approximations

Fixed-point arithmetic

This method requires no division operators but does require multiplying into 32 bit ints.

For a gamma value of 2.2:

uint8_t gamma_correct_fixed_point_2_2(uint8_t input) {
    // Using fixed-point math (Q8.8 format) with gamma ~2.2
    uint32_t y = ((uint32_t)input * input) >> 8;  // input^2
    uint32_t z = ((uint32_t)input * y) >> 8;      // input^3
    return (y * 3 + z) >> 2;  // Use weighted combination of powers
}

For a gamma value of 2.8:

uint8_t gamma_correct_fixed_point_2_8(uint8_t input) {
    // Using fixed-point math (Q8.8 format) with gamma ~2.8
    uint32_t y = ((uint32_t)input * input) >> 8;  // input^2
    uint32_t z = ((uint32_t)input * y) >> 8;      // input^3
    return (y + (z * 7)) >> 3;  // Use weighted combination of powers
}

Square approximation

This method is less accurate but simple, by compromising for a simpler but less accurate value of gamma as 2, instead of e.g. 2.2.

uint8_t gamma_correct_square(uint8_t input) {
    // Simple bitshift approximation (rough gamma ≈ 2)
    return (input * input) >> 8;
}

Cubic approximation

A gamma value of 2.8 can be approximated by a cubic rescaling.

uint8_t gamma_correct_cube(uint8_t input) {
    // Simple bitshift approximation (rough gamma ≈ 3)
		uint32_t input32 = input;
    return (input32 * input32 * input32) >> 16;
}

Downsampled lookup table

There are a few different ways of downsampling a large lookup table of continuous values. This can be extended to and is more useful for 16 bit values where a complete lookup table is less practical.

If the lookup table’s size is a power of 2 you can simple sample every nth value:

uint8_t gamma_small_table(uint8_t input) {
// Sampled from gamma=2.2 distribution at 16-step intervals
    static const uint8_t gamma_table[16] = {
		   1, 2, 6, 12, 19, 29, 41, 55, 71, 90, 111, 135, 161, 190, 221, 255};
	  return gamma_table[input >> 4];
}

I created the smaller lookup table table with the python code: [gamma[i + 15] for i in range(0, 256, 16)].

Downsampled lookup table with interpolation

Another approach is to use a downsampled lookup table with interpolation. You will need a table of length plus one of the nearest power of two.

uint8_t gamma_small_table(uint8_t input) {
    // Sampled from gamma=2.2 distribution at 15-step intervals
    static const uint8_t gamma_table[17] = {
        0, 1, 2, 6, 11, 17, 26, 36, 49, 63, 79, 98, 119, 141, 166, 194, 223, 255
    };
    
    // Extract the top 4 bits (>> 4) to get the lower index (0-15)
    uint8_t lower_idx = index >> 4;
    
    // Get the next index
    uint8_t upper_idx = lower_idx + 1;
    
    // The bottom 4 bits (& 0x0F) become our interpolation weight (0-15)
    uint8_t fract = index & 0x0F;
    
    // Linear interpolation using only addition, subtraction, and shifts
    uint8_t lower_val = compressed_table[lower_idx];
    uint8_t upper_val = compressed_table[upper_idx];
    
    return lower_val + (((upper_val - lower_val) * fract) >> 4);
}

I generated the lookup table using the python code [gamma[i] for i in range(0, 256, 15)]. Fortunately 255 is divisible by 17, but you can generate a table of any size from the python code further above.

Downsampled lookup table with interpolation with logarithmic spacing

We can take advantage of the property that the gamma function is exponentially increasing by using a lookup table that uses logarithmic rather than uniform spacing. This means that higher values will be sampled more densely.

It helps if your archicture has a CLZ instruction.

Here is an example with a lookup table of only 9 values:

uint8_t gamma_log_table(uint8_t input) {
    // Power-of-two size segments lookup table
    static const uint8_t log_table[9] = {
         0, 21, 34, 50, 70, 98, 135, 186, 255
    };
    
    // Special case for input=0
    if (input == 0) return 0;
    
    // Find segment using count leading zeros
    // For 8-bit input, we have values 0-255, so we find the position of the highest bit set
    // __builtin_clz counts from MSB of int (32 bits), so we subtract from 31 to get 0-7 segment
    uint8_t segment = 7 - __builtin_clz((unsigned int)input) + 24;
    
    // Get segment bounds
    uint8_t lower_val = log_table[segment];
    uint8_t upper_val = log_table[segment + 1];
    
    // Position within segment
    uint8_t pos = input - lower_val;
    
    // Normalization by power-of-two division (shift)
    // We shift by (8 - segment) to normalize to 0-255 range
    uint8_t normalized_pos = pos << (8 - segment);
    
    // Final interpolation between segment index and next index (always +1)
    // normalized_pos/255 is approximated as normalized_pos>>8
    return segment + (normalized_pos >> 8);
}

The lookup table can be generated using the python code:

def generate_pow2_log_table(bit_depth=8, gamma=2.2):
    """
    Generate a logarithmically-spaced gamma correction table with power-of-two segment sizes.
    
    Parameters:
    - bit_depth: Number of bits for input/output range (default 8 bits = values 0-255)
    - gamma: Gamma correction value (default 2.2)
    
    Returns:
    - A list of sample points with power-of-two spacing
    """
    max_val = (1 << bit_depth) - 1  # 255 for 8 bits
    
    # Create the power-of-two breakpoints
    breakpoints = []
    val = 0
    for i in range(bit_depth):
        breakpoints.append(val)
        val += (1 << i)  # Add 2^i
    
    # Add the final value
    if breakpoints[-1] != max_val:
        breakpoints.append(max_val)
    
    # Apply gamma correction to each breakpoint
    gamma_table = []
    for point in breakpoints:
        # Normalize to 0-1 range
        normalized = point / max_val
        
        # Apply gamma correction
        corrected = normalized ** (1/gamma)  # Note: using 1/gamma to convert to linear space
        
        # Scale back to output range and round
        gamma_table.append(round(corrected * max_val))
    
    return gamma_table

Conclusion

Gamma correction in LED PWM dimming elegantly solves the mismatch between linear electronic control and logarithmic human perception. By applying a mathematical transformation that mirrors our visual system’s natural response curve, we create lighting that behaves in ways that feel natural and intuitive.

This intersection of electronics, mathematics, and human biology reminds us that the most successful technologies are those that account for how humans actually perceive and interact with the world. Understanding the logarithmic nature of human perception helps us design not just better lighting systems, but better human-machine interfaces across countless applications.

The non-linear relationship between technical parameters and human perception is a fascinating reminder that creating natural-feeling technology often requires understanding not just the physics of our devices, but also the biology of our senses.