DEV Community

Cover image for How to measure color the way your eyes actually see it
4thwithme
4thwithme

Posted on

How to measure color the way your eyes actually see it

Source post: https://4thwithme.dev/blog/color-distance-why-rgb-math-lies/

Take two colors, (0, 64, 0) and (255, 64, 0). The plain math says they're 255 apart. Now compare (255, 64, 0) to (255, 64, 128): only 128 apart, half as much. Trust just the numbers and the second pair looks closer together. Look at them with your own eyes and that can flip completely, because plain math has no idea what your eyes actually see. Good news: people already fixed this. It just took fifty years and four tries. Two real examples with real numbers, later in this post, show exactly how big the gap can be.

[ WHY YOU CAN'T JUST SUBTRACT THE NUMBERS ]

distance = sqrt(
  (R1 - R2)^2 +
  (G1 - G2)^2 +
  (B1 - B2)^2
)
Enter fullscreen mode Exit fullscreen mode

This is the first thing everyone tries, and it's wrong. Your eyes aren't equally good at spotting changes in red, green, and blue. A change of 10 in green is much easier to see than the same change of 10 in blue. Plain math doesn't know that, it treats every kind of "different" the same. It isn't the same.

ΔE (Delta E) - the standard way to measure how far apart two colors are. A score of 1.0 is supposed to be the smallest gap a human eye can notice, the Just Noticeable Difference. Every formula below is an attempt to make that number mean the same thing everywhere in the color space.

[ WHERE THESE NUMBERS ACTUALLY COME FROM ]

Every color space in this post, RGB, CIELAB, Oklab, comes from the same place: tests on real people in the 1920s. Someone looked at a light and mixed red, green, and blue until the mix matched it, across every color the eye can see.

CIE 1931 xy chromaticity diagram, the horseshoe-shaped map of all colors visible to the human eye
the 1931 chromaticity diagram: every color the human eye can see, mapped from those matching experiments (Sakurambo, Wikimedia Commons, CC BY-SA)

Some matches needed a negative amount of a light, which isn't possible in real life. So the results got turned into a new system, XYZ, where every visible color gets a normal, positive number. Every color space since is built on top of XYZ. But none of it matches what "similar" looks like to a person: XYZ is complete, not built to match perception. That gap is why the next fifty years of formulas exist.

MacAdam ellipses plotted on the CIE 1931 chromaticity diagram, showing regions of colors indistinguishable to a human observer, magnified 10x
MacAdam ellipses (10x scale): equal steps in this raw space aren't equal steps in perception, some directions need way more distance before you'd notice

[ THE RACE TO FIX IT ]

CIELAB (1976) was the first real attempt: a space where an equal number should mean an equal amount of visible change. It still gets strong blues wrong, so every formula after it patches the last one's mistake:

Formula Year What changed
ΔE76 1976 Plain distance in CIELAB. The smallest visible gap lands near 2.3, not the intended 1.0.
CMC l:c 1984 Weighted specifically for the textile industry.
ΔE94 1995 Weights that change by region, from car-paint testing. Still weak on blue-violet.
CIEDE2000 2001 Weights that adapt, plus a fix aimed straight at blue.

Some industries take this dead seriously: car paint has to stay under 0.5 ΔE, printing under 2.0 ΔE. A narrower formula, ΔE_NS (2019), exists just for that near-zero range: the pass/fail check for "are these basically the same color."

[ WHAT'S ACTUALLY INSIDE CIEDE2000 ]

Here's the kid version. Say you have two crayons and want to know how different they look. You could measure three things: which one is lighter or darker, which one is more vivid or more washed-out, and which one leans more red vs. more blue. Add those three differences together (with a little extra twisting for blue, because blue kept fooling the older formulas) and that's basically the whole idea.

ΔE00 = sqrt(
  (ΔL' / (kL·SL))^2 +
  (ΔC' / (kC·SC))^2 +
  (ΔH' / (kH·SH))^2 +
  RT · (ΔC' / (kC·SC)) · (ΔH' / (kH·SH))
)
Enter fullscreen mode Exit fullscreen mode

Scary-looking, but every piece is simple once you name it:

  • ΔL' - the lightness difference. How much lighter or darker is one color than the other.
  • ΔC' - the chroma difference. How much more vivid or washed-out one is.
  • ΔH' - the hue difference. How much the actual color type shifted, more red vs. more blue.
  • S_L, S_C, S_H - "stretchy rulers." The same real gap counts as bigger or smaller depending on where you are in the color space, because our eyes aren't equally picky everywhere. Near black, a tiny lightness change stands out, so that ruler stretches. In a very vivid color, the same numeric change is harder to spot, so it shrinks.
  • R_T - the twist. A small correction added just because blues specifically kept breaking the older formulas, it nudges the chroma and hue pieces to work together in that one problem region.
  • k_L, k_C, k_H - dials you can turn for your specific use case (lighting, industry). Left at 1 almost always.

That's the whole trick: three simple differences, three rulers that stretch and shrink depending on where you're measuring, and one small twist for blue. What makes it hard isn't the idea, it's getting all those moving pieces to line up in code without a sign error. A 2005 paper by Sharma, Wu, and Dalal exists because early implementations, including ones people pointed to as "the right way," had bugs in exactly that plumbing.

Two real pairs, both exactly 30 apart in plain RGB math. Run them through CIEDE2000 and the story flips completely:

  • 🟦 rgb(0,0,255) vs rgb(30,0,255)ΔE2000 = 0.62. Below the notice-it threshold. Nobody sees a difference.
  • rgb(10,10,10) vs rgb(10,10,40)ΔE2000 = 14.99. Same RGB gap, an obviously different color.

Same 30-point RGB step, roughly 24x difference in how much it actually matters (computed with the colour-science Python library).

Where it breaks - the hue-angle math is easy to flip a sign on, the wraparound at 0°/360° needs its own special handling, not a plain remainder, and the weights genuinely change by region. Copy a CIE94-shaped formula (one formula, no branches) and you get a wrong number back, not an error. The good news: use a library people already tested, and check it against the published test numbers.

[ THE CHEAP TRICK: STAY IN RGB ]

All that trig and region-hopping is worth the trouble when accuracy is the whole point. It stops being worth it the moment you're running the comparison millions of times a second, palette matching, image quantization, because converting every pixel to Lab first is exactly the kind of work that piles up faster at scale. Redmean skips the trip entirely. It never leaves RGB, it just leans harder on red or blue depending on how much red is already in the pair:

r_avg = (R1 + R2) / 2

distance = sqrt(
  (2 + r_avg/256)         * (R1 - R2)^2 +
  4                       * (G1 - G2)^2 +
  (2 + (255-r_avg)/256)   * (B1 - B2)^2
)
Enter fullscreen mode Exit fullscreen mode

It's not the most accurate choice, plain CIELUV usually beats it on average. Its real strength is that it never has a bad day: Luv can go badly wrong on some colors, skin tones especially, and redmean doesn't, for a lot less computer work.

[ OKLAB: A FRESH START, NOT ANOTHER PATCH ]

Redmean and CIEDE2000 are still playing the same game: patch RGB, patch CIELAB, patch the patch. Oklab walks away from the table instead. Björn Ottosson published it in 2020 after running a simple test on CIELAB: hold lightness and saturation fixed, slide through every hue, and watch what happens to the color wheel. Yellow, magenta, and cyan come out visibly brighter than red and blue, a strip that flickers as it spins, not because the colors changed but because the space measuring them is uneven. Oklab was built from real perception data specifically to make that flicker disappear.

HSV hue gradient at constant value and saturation, showing yellow and cyan appearing noticeably lighter than red and blue
Oklab hue gradient at constant lightness and chroma, showing even perceived brightness across all hues
same hue sweep, top (HSV/sRGB) visibly flickers in brightness, bottom (Oklab) stays flat (Björn Ottosson)

Oklab / Oklch - XYZ turns into Oklab through two simple matrix multiplications with a cube root in between: cheap, stable, no special cases by region. Make a color brighter or darker and the numbers just scale, nothing else needs fixing.

It's been part of the CSS color spec since 2021, works in every major browser, and is Photoshop's default way to blend colors: the better starting point for gradients and color pickers on the web today, not CIELAB. (Two colors can share the exact same XYZ numbers and still look different under different lighting, that's what a color appearance model like CIECAM02 is built to handle.)

[ WHERE THIS SHOWS UP IN A REAL FEATURE ]

A tool that searches photos by color first has to decide what "the main color" even means. Just counting pixels doesn't work: point it at a beach photo and "blue" always finds the sky, never someone's blue shirt. One real version of this uses plain Lab distance, but turns the lightness weight down to 0.7, so a light and dark version of the same hue don't get scored as too far apart. The color math here was the easy part. The hard part was speeding up the database and showing results as they arrive, instead of making people wait. Swapping in CIEDE2000 wouldn't have made this feature faster. It usually doesn't.

[ WHAT TO ACTUALLY USE ]

Use plain Lab distance by default, it's good enough for almost everything. Reach for CIEDE2000 only when checking against a limit already written in ΔE2000: printing, paint, fabric. Reach for redmean when converting to Lab is genuinely too slow. And for anything visual on the web today, start with Oklab/Oklch: already built into your browser, designed to skip the problems everything above it has been patching since 1976.

[ LIBRARIES THAT DO THIS FOR YOU ]

Don't write CIEDE2000 by hand. Here's what already does it, checked against current docs, not memory:

Python

Library What it covers
colour-science Broadest: ΔE76/94/2000, CMC, DIN99, HyAB, plus Oklab and CIECAM02/CIECAM16.
scikit-image skimage.color.deltaE_cie76, deltaE_ciede94, deltaE_ciede2000, deltaE_cmc. No Oklab.
coloraide Native Oklab/Oklch, delta_e() with CIE76, DE2000, HyAB, and an Oklab-based "OK" method.
colormath The old classic, archived since Dec 2023. Use the colormath2 fork instead.

Node.js

Library What it covers
culori CIE76/94/2000 difference functions, native Oklab/Oklch parsing and conversion.
colorjs.io By the CSS Color 4 spec editors. deltaE() with 76, CMC, 2000, Jz, deltaEOK.
color-diff CIEDE2000 specifically, plus palette-mapping (closest, mapPalette).
nearest-color Plain Euclidean RGB nearest-neighbor, no perceptual conversion, the cheap option from earlier.

One thing to watch for: chroma-js ships a deltaE() too, but it's CMC l:c under the hood, not CIEDE2000, despite the name. Don't use it for perceptual ΔE2000 matching.

[ USEFUL LINKS ]


Originally posted on 4thwithme.dev/blog.

GitHub · LinkedIn · Substack

May the --force be with you. See you next week.

Top comments (0)