Skip to article
Görkem Yıldız, home
Available for October

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.

· 9 min readEnglishTürkçe oku

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:

Code
// 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.434

The third step applies another 3x3 matrix (M2) for the final transformation:

Code
[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:

Code
// 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.434

Three 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:

Clean neutral gray gradient Color drift from independent gammas (exaggerated) The subtle purple/blue tint in the bottom row is the result of independent gamma exponents

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:

Code
u = c1 * Y,  v = c2 * Y,  w = c3 * Y   (constants c1, c2, c3)

After applying gamma:

Code
u' = c1^g1 * Y^g1
v' = c2^g2 * Y^g2
w' = c3^g3 * Y^g3

Now compute a and b using the M2 matrix. The requirement a=0 becomes:

Code
a = m21*c1^g1*Y^g1 + m22*c2^g2*Y^g2 + m23*c3^g3*Y^g3 = 0

This 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:

Code
m21 = 0,  m22 = 0,  m23 = 0

This 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.

Code
// 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:

Code
// 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:

Code
// 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:

Code
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.000001

Why 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:

Code
// 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... continue

The 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:

Code
// 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 broken

Applying 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:

v19 Neutral Correction Results Metric v14 (Before NC) v19 (After NC) COMBVD STRESS 23.18 23.22 (+0.04) Achromatic Chroma 0.340 0.000001 Core DOF 21 (3 gammas) 21 (3 gammas preserved) Round-Trip Error 10⁻⁶ 10⁻⁶ Achromatic chroma reduced by 340,000x; COMBVD cost only +0.04

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:

Code
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-6

Why 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:

Terminal
npm install helmlab

Source 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.

Görkem Yıldız

Helmlab, a family of perceptual color spaces for UI design systems (arXiv:2602.23010)

I'm Görkem, a UI/UX designer and engineer based in Ankara, leading the design team at [PushBrands](https://pushbrands.app/en), a Saudi UGC marketplace. I've spent over eight years designing and building digital products, and lately the systems underneath them: Helmlab, a color space trained on 64,000 human color judgments that now ships in Color.js; open-source tools for icons, sound and the terminal; and, as Orbit Design, Figma plugins with more than 100,000 users.

Book a call

Talk about color, design systems or Helmlab.

Saturdays 19:00–21:00 Istanbul time, on Google Meet.

Book a call