Neutral Correction: Are Your Grays Really Gray?
Independent gamma exponents give grays unwanted tint. Helmlab v19's neutral correction fixes this structurally at a cost of +0.04.
You define a gray background in your design system. sRGB (128, 128, 128) -- a perfect neutral gray. You convert it to a perceptual color space and expect the a and b coordinates to be zero. But they are not. Your gray has a faint bluish or yellowish tint. This is not a bug -- it is a mathematical consequence of the pipeline architecture.
In this post, we examine why independent gamma exponents cause grays to acquire color, why no linear matrix can fix this, and how Helmlab's v19 Neutral Correction (NC) method solves the problem without sacrificing any degrees of freedom.
The Problem: Independent Gammas and Colored Grays
Helmlab's pipeline starts from XYZ color space. The first step transforms XYZ into an internal space via a 3x3 matrix M1. The second step applies independent power functions (gammas) to these three channels:
// First stages of the pipeline
[u, v, w] = M1 * [X, Y, Z] // 3x3 matrix transform
[u', v', w'] = [u^g1, v^g2, w^g3] // Independent gammas
// v19 parameter values:
// g1 = 0.395, g2 = 0.421, g3 = 0.434The third step applies another 3x3 matrix (M2) for the final transformation:
[L, a_raw, b_raw] = M2 * [u', v', w']The problem emerges right here. For a gray tone, the XYZ channels are all linear functions of Y (luminance). But once different gamma exponents are applied:
// For a gray tone (as a function of Y):
u' = (m11*X + m12*Y + m13*Z) ^ 0.395
v' = (m21*X + m22*Y + m23*Z) ^ 0.421
w' = (m31*X + m32*Y + m33*Z) ^ 0.434Three different powers create three different curves. These can no longer be linearly combined to produce zero -- because Y^0.395, Y^0.421, and Y^0.434 are linearly independent functions.
Visualizing the Gray Tint
The visual below shows how this problem manifests in practice. The top row is a perfectly neutral gray gradient; the bottom row shows the subtle color drift caused by independent gammas:
The drift may appear small, but gray tones are everywhere in UI design: backgrounds, borders, dividers, disabled states. A gray background that leans slightly toward blue becomes noticeable when placed next to other neutral surfaces.
Mathematical Proof: Why M2 Cannot Fix This
A natural question arises: can we choose the M2 matrix so that a=0 and b=0 are guaranteed for grays?
The answer is no. To prove this, start with the definition of gray. In gray tones, the X, Y, and Z channels are fixed-ratio functions of Y (dependent on the white point). After M1, all three channels become linear functions of Y:
u = c1 * Y, v = c2 * Y, w = c3 * Y (constants c1, c2, c3)After applying gamma:
u' = c1^g1 * Y^g1
v' = c2^g2 * Y^g2
w' = c3^g3 * Y^g3Now compute a and b using the M2 matrix. The requirement a=0 becomes:
a = m21*c1^g1*Y^g1 + m22*c2^g2*Y^g2 + m23*c3^g3*Y^g3 = 0This equation must hold for all values of Y (since there is a gray tone at every luminance level). But since g1 ≠ g2 ≠ g3, the functions Y^g1, Y^g2, and Y^g3 are linearly independent. For a sum of linearly independent functions to be identically zero, every coefficient must be zero:
m21 = 0, m22 = 0, m23 = 0This zeroes out the entire a-row of M2 -- meaning the a-axis ceases to exist. The same argument applies to b. In other words: with independent gammas, it is mathematically impossible for any linear matrix to guarantee that grays remain neutral.
Three Candidate Solutions
We evaluated three approaches to solve this problem:
1. Shared Gamma (v18)
The simplest fix: use a single gamma value for all three channels. In that case Y^g is the same across channels, and M2 can easily enforce a=0, b=0.
Cost: Core degrees of freedom drop from 21 to 17. Three independent gammas collapse to one, reducing the pipeline's expressive power. Measurable loss in COMBVD.
2. Opponent-First
Transform to opponent channels (L, a, b-like) before applying gamma. In this configuration, the achromatic axis is structurally preserved.
Cost: Same DOF loss -- 21 to 17. The math differs but the outcome is identical: reduced expressive capacity.
3. Neutral Correction (v19)
At the end of the pipeline, subtract the a and b errors that grays have accumulated. All three independent gammas remain intact -- no DOF loss.
Cost: A LUT (look-up table) is added, but the pipeline's expressive power is fully preserved. All 72 parameters remain active.
We chose v19 NC. The reason is straightforward: shared gamma and opponent-first approaches impose a concrete loss (21 to 17 DOF), whereas NC's cost is near zero.
The v19 NC Method: LUT Subtraction and PCHIP Interpolation
The Neutral Correction method works as follows:
Step 1: Compute Gray Responses
Generate 256 gray Y values (luminance levels). Pass each through the full forward pipeline (M1, gamma, M2, hue correction, H-K, cubic L, ...). Record the resulting (L_i, a_i, b_i) for each.
// Run the pipeline for 256 gray tones
for (let Y = 0; Y < 256; Y++) {
const gray_XYZ = luminanceToXYZ(Y / 255);
const [L, a, b] = fullForward(gray_XYZ);
a_err[Y] = a; // Should be zero, but isn't
b_err[Y] = b; // Should be zero, but isn't
L_lut[Y] = L;
}Step 2: Build a PCHIP Interpolation Table
Using the 256 pairs (L, a_err) and (L, b_err), build a PCHIP (Piecewise Cubic Hermite Interpolating Polynomial) interpolation. Unlike splines, PCHIP preserves monotonicity and does not introduce spurious oscillations.
Step 3: Subtract at Pipeline End
For every color's final (L, a, b) output:
// Neutral Correction: applied at pipeline end
T(L, a, b) = (L, a - a_err(L), b - b_err(L))For grays: since a = a_err, the result is a = 0. For chromatic colors: a small portion of the error is subtracted, making a local adjustment that does not cause large color shifts -- because the error function is a slowly varying, smooth curve.
Inverse Transform
The inverse is straightforward -- subtraction becomes addition:
// Inverse Neutral Correction: applied at start of inverse pipeline
T_inv(L, a, b) = (L, a + a_err(L), b + b_err(L))In Helmlab's JavaScript implementation, this uses a pre-computed LUT with linear interpolation:
import { Helmlab } from 'helmlab';
const hl = new Helmlab();
// Perfect gray: sRGB (0.5, 0.5, 0.5)
const gray = hl.fromSRGB(0.5, 0.5, 0.5);
console.log('L:', gray.L.toFixed(6));
console.log('a:', gray.a.toFixed(6)); // ~0.000000
console.log('b:', gray.b.toFixed(6)); // ~0.000000
// Chroma: sqrt(a^2 + b^2)
const C = Math.sqrt(gray.a ** 2 + gray.b ** 2);
console.log('C:', C.toFixed(6)); // < 0.000001Why at Pipeline End? The Hue Cascade Problem
Where NC is applied in the pipeline is critical. The first instinct might be "apply the correction right after M2." But this leads to serious problems.
Applying NC after M2 means:
// WRONG: NC after M2
[L, a_raw, b_raw] = M2 * [u', v', w']
a_corrected = a_raw - a_err(L)
b_corrected = b_raw - b_err(L)
// Then hue correction, H-K, dark_L, chroma_scale... continueThe problem: modifying a and b values changes the hue angle. The changed hue angle then affects every subsequent stage:
- Hue correction: A 4-harmonic Fourier series dependent on hue angle -- different hue, different correction
- H-K effect: Hue-dependent lightness shift -- different hue, different L modification
- dark_L compression: L-value dependent -- changed L, different compression
- chroma_scale: 4 hue-dependent harmonics -- different hue, different chroma scaling
This cascading interaction is small for grays but explodes for extreme colors. Experimentally, we observed the following:
// Effect of post-M2 NC on an extreme color:
// L before NC = 0.94
// L after NC = 7.27 (L diverged through cascade)
// L-chroma interaction kills chroma
// Round-trip (Lab to XYZ to Lab) is brokenApplying NC at the pipeline end completely avoids this cascade. The enrichment stages (hue correction, H-K, dark_L, chroma_scale) see the original hue angle -- NC makes only a fine adjustment to the final output.
Results: achC < 10⁻⁶, COMBVD Cost Only +0.04
The performance of v19 NC:
Interpreting the numbers:
- COMBVD STRESS: 23.18 to 23.22 -- an increase of only 0.04 units. This is below the statistical noise level measured across 3,813 color pairs and 64,000+ human observations.
- Achromatic chroma: 0.34 to 0.000001 -- a structural guarantee. Gray tones now have effectively zero color drift in Lab space.
- Core DOF: All 21 degrees of freedom preserved. Three independent gammas remain active; the pipeline's expressive power is undiminished.
Gray Axis Test: Helmlab in Practice
To see what NC means in practice, let us test multiple gray tones with Helmlab:
import { Helmlab } from 'helmlab';
const hl = new Helmlab();
// Gray tones at different luminance levels
const grays = [0.1, 0.3, 0.5, 0.7, 0.9];
for (const v of grays) {
const lab = hl.fromSRGB(v, v, v);
const C = Math.sqrt(lab.a ** 2 + lab.b ** 2);
console.log(
`sRGB(${v}, ${v}, ${v}) => ` +
`L=${lab.L.toFixed(4)}, ` +
`a=${lab.a.toFixed(6)}, ` +
`b=${lab.b.toFixed(6)}, ` +
`C=${C.toFixed(6)}`
);
}
// Every row: C < 0.000001 -- grays are truly gray
// Round-trip test
const original = [0.5, 0.5, 0.5];
const lab = hl.fromSRGB(...original);
const back = hl.toSRGB(lab.L, lab.a, lab.b);
console.log('Round-trip error:',
Math.max(
Math.abs(back.r - original[0]),
Math.abs(back.g - original[1]),
Math.abs(back.b - original[2])
).toExponential(2)
); // ~1e-6Why Other Spaces Do Not Have This Problem
CIE Lab does not encounter this issue because it uses a single power function: f(t) = t^(1/3). The same function for all three channels means a*=0 and b*=0 are structurally guaranteed for grays. Oklab similarly uses the same cube root across all channels.
But this simplicity has a cost: a single power function eliminates the ability to model each channel with a different nonlinearity. The human visual system's three cone types (L, M, S) have different sensitivities and different nonlinearity profiles. Modeling this requires independent gammas -- but independent gammas bring the gray problem.
Helmlab, through NC, combines both advantages: the expressive power of independent gammas with a structural guarantee that grays remain neutral.
Conclusion
Independent gamma exponents give a color space significant expressive power -- they are necessary for modeling the per-channel nonlinearity of human vision. But this flexibility carries a risk: gray tones that drift into color.
Helmlab v19 solves this with Neutral Correction applied at the end of the pipeline. A 256-point LUT, PCHIP interpolation, and simple subtraction. The result: achromatic chroma drops below 0.000001, at a COMBVD cost of only +0.04 units, with all three independent gammas and all 72 parameters fully preserved.
Your grays should be genuinely gray. A 10.3 KB npm package guarantees it:
npm install helmlabSource code: GitHub | Package: npm
This is post 9 of 16 in the Helmlab series. Next up: Rigid Rotation -- free hue improvement in the ab plane.