The Science of Gamma Correction
Mar 22, 2025

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
- Part 2: The Logarithmic Nature of Human Perception
- Part III: Microcontroller implementations
- Conclusion
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:
- We take the input control value (typically 0-255 or 0-100%)
- Raise it to a power (the gamma value, typically ~2.2)
- 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:
- Look-up tables: Pre-calculated values stored in memory, allowing for quick conversion with minimal processing power
- Real-time calculation: Computing the power function for each input value
- 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:
-
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.
-
Enhanced contrast sensitivity: By being more sensitive to changes in darker regions, we can extract more useful information from shadows and low-light environments.
-
Neural efficiency: The brain has limited processing capacity. Logarithmic encoding allows it to represent a wide range of sensory information using fewer neural resources.
-
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.