Using Different Color Spaces to Compare Colors
Tags: webdev, javascript, tutorial, react
Published: 2023-02-24 19:31:05

[NOTE: The live web app that encompasses this functionality can be found here: https://www.paintmap.studio.  All of the underlying code for that site can be found here: https://github.com/bytebodger/color-map.]

In the previous article I took a pixelated image and matched each of the blocks to their "closest" colors in my inventory of paints.  But as we saw in the finished images, the color matches were not entirely... satisfying.  If we want to strive for better color-matching, the first thing to look at is the _color space_ that we choose to use.

We started using a basic RGB model.  This is intuitive to web developers because nearly all colors that we deal with follow an RGB model.  But RGB is only one of many different color spaces.  And each different color space will produce difference results.

For reference, here is our base image, which has already been pixelated, but has not been matched against the physical colors in my inventory of paints:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/561jpv3pkgtm687tz9nt.png)

And when we did a basic RGB calculation, we ended up with this conversion:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jb9zfy6oqu0uukm4stni.png)

That's not... _horrible_.  But it's not great either.  So let's look at using some other potential color spaces.
<br/><br/>



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oemlmmaw79l0st54jgu1.jpg)
## CMYK
CMYK is the color space used by printers.  From this perspective, it may seem to be a good fit for our needs, because we're trying to match digital colors to those that exist in the real world (e.g., paints).  

First, we'll need to update our `getClosestColorInThePalette()` function.  The new version looks like this:

```javascript
const getClosestColorInThePalette = (referenceColor = rgbModel) => {
   const key = `${referenceColor.red},${referenceColor.green},${referenceColor.blue}`;
   if (closestColors[key])
      return closestColors[key];
   let closestColor = {
      blue: -1,
      green: -1,
      name: '',
      red: -1,
   };
   let shortestDistance = Number.MAX_SAFE_INTEGER;
   const algorithm = local.getItem('algorithm');
   palette.forEach(paletteColor => {
      if (shortestDistance === 0)
         return;
      let distance;
      switch (algorithm) {
         case algorithms.CMYK:
            const {cyan: paletteCyan, magenta: paletteMagenta, yellow: paletteYellow, key: paletteKey} = convertRgbToCmyk(paletteColor);
            const {cyan: referenceCyan, magenta: referenceMagenta, yellow: referenceYellow, key: referenceKey} = convertRgbToCmyk(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceCyan - paletteCyan, 2)
               + Math.pow(referenceMagenta - paletteMagenta, 2)
               + Math.pow(referenceYellow - paletteYellow, 2)
               + Math.pow(referenceKey - paletteKey, 2)
            );
            break;
         case algorithms.RGB:
         default:
            distance = Math.sqrt(
               Math.pow(paletteColor.red - referenceColor.red, 2)
               + Math.pow(paletteColor.green - referenceColor.green, 2)
               + Math.pow(paletteColor.blue - referenceColor.blue, 2)
            );
            break;
      }
      if (distance < shortestDistance) {
         shortestDistance = distance;
         closestColor = paletteColor;
         closestColors[key] = paletteColor;
      }
   });
   return closestColor;
};
```

I added case statements so that we can use different algorithms depending upon the color model we've chosen.  Almost all of these calculations will use a root-mean-square (RMS) calculation.  RMS is the formula that you use to find the distance between two different points in three-dimensional space.  Note that I also converted the RGB algorithm to use RMS.  I've found that this alteration makes little difference in the visual output of the RGB calculation, but using RMS will make it more consistent with the rest of the color space calculations.

Also note that we're always starting from RGB values.  That's what gets passed into the function.  So if we want to calculate differences based upon the CMYK color space, we'll first need to convert our RGB value to CMYK.  That's why the values are first passed into `convertRgbToCmyk()` before we run the algorithm.

Here's what the `convertRgbToCmyk()` function looks like:

```javascript
const convertRgbToCmyk = (rgbColor = rgbModel) => {
   const {red, green, blue} = rgbColor;
   let cyan = 255 - red;
   let magenta = 255 - green;
   let yellow = 255 - blue;
   let key = Math.min(cyan, magenta, yellow);
   const divider = key === 255 ? 1 : 255 - key;
   cyan = ((cyan - key) / divider);
   magenta = ((magenta - key) / divider);
   yellow = ((yellow - key) / divider);
   key = key / 255;
   return {
      cyan,
      magenta,
      yellow,
      key,
   };
};
```

And here's what the pixelated image looks like when we do our color matching based upon CMYK:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f8s184z7kw6vvvb59s9t.png)

Well...that's a little different than the RGB calculation.  But I'd hardly call it "better".  So let's keep searching through different color spaces.
<br/><br/>



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ztg2xubs94lh3qb3w82o.jpg)
## HSL
If you've done any work in graphic tools like PaintShop, you're probably familiar with HSL.  It represents a conical color space based on hue, saturation, and lightness.  This color space is also more intuitive for some artists.

So once again, we'll update our `getClosestColorInThePalette()` function.  The new version looks like this:

```javascript
const getClosestColorInThePalette = (referenceColor = rgbModel) => {
   const key = `${referenceColor.red},${referenceColor.green},${referenceColor.blue}`;
   if (closestColors[key])
      return closestColors[key];
   let closestColor = {
      blue: -1,
      green: -1,
      name: '',
      red: -1,
   };
   let shortestDistance = Number.MAX_SAFE_INTEGER;
   const algorithm = local.getItem('algorithm');
   palette.forEach(paletteColor => {
      if (shortestDistance === 0)
         return;
      let distance;
      switch (algorithm) {
         case algorithms.CMYK:
            const {cyan: paletteCyan, magenta: paletteMagenta, yellow: paletteYellow, key: paletteKey} = convertRgbToCmyk(paletteColor);
            const {cyan: referenceCyan, magenta: referenceMagenta, yellow: referenceYellow, key: referenceKey} = convertRgbToCmyk(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceCyan - paletteCyan, 2)
               + Math.pow(referenceMagenta - paletteMagenta, 2)
               + Math.pow(referenceYellow - paletteYellow, 2)
               + Math.pow(referenceKey - paletteKey, 2)
            );
            break;
         case algorithms.HSL:
            const {hue: paletteHue, saturation: paletteSaturation, lightness: paletteLightness} = convertRgbToHsl(paletteColor);
            const {hue: referenceHue, saturation: referenceSaturation, lightness: referenceLightness} = convertRgbToHsl(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceHue - paletteHue, 2)
               + Math.pow(referenceSaturation - paletteSaturation, 2)
               + Math.pow(referenceLightness - paletteLightness, 2)
            );
            break;
         case algorithms.RGB:
         default:
            distance = Math.sqrt(
               Math.pow(paletteColor.red - referenceColor.red, 2)
               + Math.pow(paletteColor.green - referenceColor.green, 2)
               + Math.pow(paletteColor.blue - referenceColor.blue, 2)
            );
            break;
      }
      if (distance < shortestDistance) {
         shortestDistance = distance;
         closestColor = paletteColor;
         closestColors[key] = paletteColor;
      }
   });
   return closestColor;
};
```

The HSL calculation is basically identical to the RGB calculation.  We're still taking the RMS result from the values in the palette color versus the reference color.  But rather than comparing red, green, and blue between both colors, we're comparing hue, saturation, and lightness.

Just as we need to convert RGB values to CMYK to run the CMYK matching algorithm, we'll need to convert RGB values to HSL to run our new HSL matching algorithm.  

Here's what the `convertRgbToHsl()` function looks like:

```javascript
const convertRgbToHsl = (rgbcolor = rgbModel) => {
   let { red, green, blue } = rgbcolor;
   red = red / 255;
   green = green / 255;
   blue = blue / 255;
   const maximum = Math.max(red, green, blue);
   const minimum = Math.min(red, green, blue);
   const basis = (maximum + minimum) / 2;
   let hue;
   let saturation;
   let lightness = basis;
   if (maximum === minimum) {
      hue = 0;
      saturation = 0;
   } else {
      const difference = maximum - minimum;
      saturation = lightness > 0.5 ? difference / (2 - maximum - minimum) : difference / (maximum + minimum);
      switch (maximum) {
         case red:
            hue = (green - blue) / difference + (green < blue ? 6 : 0);
            break;
         case green:
            hue = (blue - red) / difference + 2;
            break;
         case blue:
         default:
            hue = (red - green) / difference + 4;
            break;
      }
      hue = hue / 6;
   }
   return {
      hue,
      saturation,
      lightness,
   };
}
```

And here's what the pixelated image looks like when we do our color matching based upon HSL:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/upqvsk04ab25xy5jstx2.png)

Honestly, I prefer this transformation to the RGB or CMYK results.  But she still kinda looks like she's wearing a mask.  At this point, it's time to look beyond these "basic" color models.
<br/><br/>



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vv51g1ht8ir96lxnbndt.png)
## XYZ
The XYZ color space (formally known as the CIE 1931 color space) is quite intriguing.  As the name implies, it was developed back in 1931.  It was the first attempt to produce a color space based on measurements of _human color perception_ and it's the basis for almost all other color spaces.  

That's what we're really going for here. We want to take two RGB colors and determine how "close" they are - _based on human perception_.

Again, we'll update our `getClosestColorInThePalette()` function.  The new version looks like this:

```javascript
const getClosestColorInThePalette = (referenceColor = rgbModel) => {
   const key = `${referenceColor.red},${referenceColor.green},${referenceColor.blue}`;
   if (closestColors[key])
      return closestColors[key];
   let closestColor = {
      blue: -1,
      green: -1,
      name: '',
      red: -1,
   };
   let shortestDistance = Number.MAX_SAFE_INTEGER;
   const algorithm = local.getItem('algorithm');
   palette.forEach(paletteColor => {
      if (shortestDistance === 0)
         return;
      let distance;
      switch (algorithm) {
         case algorithms.CMYK:
            const {cyan: paletteCyan, magenta: paletteMagenta, yellow: paletteYellow, key: paletteKey} = convertRgbToCmyk(paletteColor);
            const {cyan: referenceCyan, magenta: referenceMagenta, yellow: referenceYellow, key: referenceKey} = convertRgbToCmyk(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceCyan - paletteCyan, 2)
               + Math.pow(referenceMagenta - paletteMagenta, 2)
               + Math.pow(referenceYellow - paletteYellow, 2)
               + Math.pow(referenceKey - paletteKey, 2)
            );
            break;
         case algorithms.HSL:
            const {hue: paletteHue, saturation: paletteSaturation, lightness: paletteLightness} = convertRgbToHsl(paletteColor);
            const {hue: referenceHue, saturation: referenceSaturation, lightness: referenceLightness} = convertRgbToHsl(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceHue - paletteHue, 2)
               + Math.pow(referenceSaturation - paletteSaturation, 2)
               + Math.pow(referenceLightness - paletteLightness, 2)
            );
            break;
         case algorithms.XYZ:
            const {x: paletteX, y: paletteY, z: paletteZ} = convertRgbToXyz(paletteColor);
            const {x: referenceX, y: referenceY, z: referenceZ} = convertRgbToXyz(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceX - paletteX, 2)
               + Math.pow(referenceY - paletteY, 2)
               + Math.pow(referenceZ - paletteZ, 2)
            );
            break;
         case algorithms.RGB:
         default:
            distance = Math.sqrt(
               Math.pow(paletteColor.red - referenceColor.red, 2)
               + Math.pow(paletteColor.green - referenceColor.green, 2)
               + Math.pow(paletteColor.blue - referenceColor.blue, 2)
            );
            break;
      }
      if (distance < shortestDistance) {
         shortestDistance = distance;
         closestColor = paletteColor;
         closestColors[key] = paletteColor;
      }
   });
   return closestColor;
};
```

Just like before, we're using a RMS calculation to determine the distance between the reference color and the palette color.  But this time we're comparing the values from the XYZ color space.

Of course, that means that we need to convert the RGB values to XYZ.  Here's what the `convertRgbToXyz()` function looks like:

```javascript
const convertRgbToXyz = (rgbColor = rgbModel) => {
   const convert = color => {
      color = color / 255;
      color = color > 0.04045 ? Math.pow(((color + 0.055) / 1.055), 2.4) : color / 12.92;
      color = color * 100;
      return color;
   }

   let {red, green, blue} = rgbColor;
   red = convert(red);
   green = convert(green);
   blue = convert(blue);
   const x = (red * 0.4124564) + (green * 0.3575761) + (blue * 0.1804375);
   const y = (red * 0.2126729) + (green * 0.7151522) + (blue * 0.0721750);
   const z = (red * 0.0193339) + (green * 0.1191920) + (blue * 0.9503041);
   return {
      x,
      y,
      z,
   };
};
```

No, I certainly didn't come up with that equation on my own.  But that's how you convert an RGB value to XYZ.

And here's what the pixelated image looks like when we do our color matching based upon XYZ:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6uqxkps59dlkge7s2d8h.png)

Wow.  To my eye at least, this particular transformation is _a lot_ better.  There's still some wonkiness there.  There's some odd groupings of pinks above her eyes.  But it seems to be a much closer match than anything we accomplished with RGB, CMYK, or HSL comparisons.
<br/><br/>



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xm2ludc1eilwugisbxih.png)
## Delta-E 2000
The last model we're gonna experiment with is called Delta-E 2000.  Delta-E 2000 isn't a color space.  Rather, it's the latest in a long line of formulas that have been developed for color matching based upon the CIELAB, or L*a*b*, color space.

Here's a snapshot of the calculations that go into that formula:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g5rixnk99fvyhmr5d44u.png)

Dayyumm...  Well, let's drive into implementing this.  First, we'll update our `getClosestColorInThePalette()` function again:

```javascript
const getClosestColorInThePalette = (referenceColor = rgbModel) => {
   const key = `${referenceColor.red},${referenceColor.green},${referenceColor.blue}`;
   if (closestColors[key])
      return closestColors[key];
   let closestColor = {
      blue: -1,
      green: -1,
      name: '',
      red: -1,
   };
   let shortestDistance = Number.MAX_SAFE_INTEGER;
   const algorithm = local.getItem('algorithm');
   palette.forEach(paletteColor => {
      if (shortestDistance === 0)
         return;
      let distance;
      switch (algorithm) {
         case algorithms.CMYK:
            const {cyan: paletteCyan, magenta: paletteMagenta, yellow: paletteYellow, key: paletteKey} = convertRgbToCmyk(paletteColor);
            const {cyan: referenceCyan, magenta: referenceMagenta, yellow: referenceYellow, key: referenceKey} = convertRgbToCmyk(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceCyan - paletteCyan, 2)
               + Math.pow(referenceMagenta - paletteMagenta, 2)
               + Math.pow(referenceYellow - paletteYellow, 2)
               + Math.pow(referenceKey - paletteKey, 2)
            );
            break;
         case algorithms.HSL:
            const {hue: paletteHue, saturation: paletteSaturation, lightness: paletteLightness} = convertRgbToHsl(paletteColor);
            const {hue: referenceHue, saturation: referenceSaturation, lightness: referenceLightness} = convertRgbToHsl(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceHue - paletteHue, 2)
               + Math.pow(referenceSaturation - paletteSaturation, 2)
               + Math.pow(referenceLightness - paletteLightness, 2)
            );
            break;
         case algorithms.XYZ:
            const {x: paletteX, y: paletteY, z: paletteZ} = convertRgbToXyz(paletteColor);
            const {x: referenceX, y: referenceY, z: referenceZ} = convertRgbToXyz(referenceColor);
            distance = Math.sqrt(
               Math.pow(referenceX - paletteX, 2)
               + Math.pow(referenceY - paletteY, 2)
               + Math.pow(referenceZ - paletteZ, 2)
            );
            break;
         case algorithms.DELTA_E:
            const paletteLabColor = convertRgbToLab(paletteColor);
            const referenceLabColor = convertRgbToLab(referenceColor);
            distance = calculateDeltaE2000(paletteLabColor, referenceLabColor);
            break;
         case algorithms.RGB:
         default:
            distance = Math.sqrt(
               Math.pow(paletteColor.red - referenceColor.red, 2)
               + Math.pow(paletteColor.green - referenceColor.green, 2)
               + Math.pow(paletteColor.blue - referenceColor.blue, 2)
            );
            break;
      }
      if (distance < shortestDistance) {
         shortestDistance = distance;
         closestColor = paletteColor;
         closestColors[key] = paletteColor;
      }
   });
   return closestColor;
};
```

In this case, we're not doing the calculation directly in this function because we're not using a simple RMS algorithm.  Instead, we're passing the values into our new `calculateDeltaE2000()` function.

But first, we need to get L*a*b* values from our RGB objects.  Here's what the `convertRgbToLab()` function looks like:

```javascript
const convertRgbToLab = (rgbColor = rgbModel) => {
   const xyzColor = convertRgbToXyz(rgbColor);
   return convertXyzToLab(xyzColor);
};
```

That's obviously pretty simple, because the L*a*b* conversion expects an XYZ color.  Of course, we already wrote a `convertRgbToXyz()` function.  Once we have the XYZ value, then we call `convertXyzToLab()`.

Here's what the `convertXyzToLab()` function looks like:

```javascript
const convertXyzToLab = (xyzColor = xyzModel) => {
   const adjust = value => value > 0.008856 ? Math.pow(value, (1 / 3)) : (7.787 * value) + (16 / 116);

   let {x, y, z} = xyzColor;
   x = x / 94.811;
   y = y / 100;
   z = z / 107.304;
   x = adjust(x);
   y = adjust(y);
   z = adjust(z);
   const lightness = (116 * y) - 16;
   const redGreen = 500 * (x - y);
   const blueYellow = 200 * (y - z);
   return {
      lightness,
      redGreen,
      blueYellow,
   };
};
```

Once we have the L*a*b* values, we can plug them into our `calculateDeltaE2000()` function looks like:

```javascript
const calculateDeltaE2000 = (labColor1 = labModel, labColor2 = labModel) => {
   const {lightness: lightness1, redGreen: redGreen1, blueYellow: blueYellow1} = labColor1;
   const {lightness: lightness2, redGreen: redGreen2, blueYellow: blueYellow2} = labColor2;
   Math.rad2deg = function (rad) {
      return 360 * rad / (2 * Math.PI);
   };
   Math.deg2rad = function (deg) {
      return (2 * Math.PI * deg) / 360;
   };
   const avgL = (lightness1 + lightness2) / 2;
   const c1 = Math.sqrt(Math.pow(redGreen1, 2) + Math.pow(blueYellow1, 2));
   const c2 = Math.sqrt(Math.pow(redGreen2, 2) + Math.pow(blueYellow2, 2));
   const avgC = (c1 + c2) / 2;
   const g = (1 - Math.sqrt(Math.pow(avgC, 7) / (Math.pow(avgC, 7) + Math.pow(25, 7)))) / 2;
   const a1p = redGreen1 * (1 + g);
   const a2p = redGreen2 * (1 + g);
   const c1p = Math.sqrt(Math.pow(a1p, 2) + Math.pow(blueYellow1, 2));
   const c2p = Math.sqrt(Math.pow(a2p, 2) + Math.pow(blueYellow2, 2));
   const avgCp = (c1p + c2p) / 2;
   let h1p = Math.rad2deg(Math.atan2(blueYellow1, a1p));
   if (h1p < 0)
      h1p = h1p + 360;
   let h2p = Math.rad2deg(Math.atan2(blueYellow2, a2p));
   if (h2p < 0)
      h2p = h2p + 360;
   const avghp = Math.abs(h1p - h2p) > 180 ? (h1p + h2p + 360) / 2 : (h1p + h2p) / 2;
   const t = 1 -
      0.17 * Math.cos(Math.deg2rad(avghp - 30))
      + 0.24 * Math.cos(Math.deg2rad(2 * avghp))
      + 0.32 * Math.cos(Math.deg2rad(3 * avghp + 6))
      - 0.2 * Math.cos(Math.deg2rad(4 * avghp - 63));
   let deltahp = h2p - h1p;
   if (Math.abs(deltahp) > 180) {
      if (h2p <= h1p) {
         deltahp += 360;
      } else {
         deltahp -= 360;
      }
   }
   const deltalp = lightness2 - lightness1;
   const deltacp = c2p - c1p;
   deltahp = 2 * Math.sqrt(c1p * c2p) * Math.sin(Math.deg2rad(deltahp) / 2);
   const sl = 1 + ((0.015 * Math.pow(avgL - 50, 2)) / Math.sqrt(20 + Math.pow(avgL - 50, 2)));
   const sc = 1 + 0.045 * avgCp;
   const sh = 1 + 0.015 * avgCp * t;
   const deltaro = 30 * Math.exp(-(Math.pow((avghp - 275) / 25, 2)));
   const rc = 2 * Math.sqrt(Math.pow(avgCp, 7) / (Math.pow(avgCp, 7) + Math.pow(25, 7)));
   const rt = -rc * Math.sin(2 * Math.deg2rad(deltaro));
   const kl = 1;
   const kc = 1;
   const kh = 1;
   return Math.sqrt(Math.pow(deltalp / (kl * sl), 2)
      + Math.pow(deltacp / (kc * sc), 2)
      + Math.pow(deltahp / (kh * sh), 2)
      + rt * (deltacp / (kc * sc)) * (deltahp / (kh * sh)));
};
```

You probably don't wanna spend too much time looking at that function.  It'll give you a headache.  For now, we can just be content to know that we have the logic quantified here in JavaScript.

And now that we can calculate Delta-E 2000 values, here's what the pixelated image looks like when we do our color matching based upon that algorithm:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ql8sgsukvk02uxcx4d7i.png)

To be frank, I don't like this one nearly as much as the product that we received from the XYZ calculation.  But it's still useful as another reference point.
<br/><br/>



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uypnmvelddl0f68tmpca.png)
## Which algorithm "won"??
Based upon the test results from this _one_ image, it may be tempting to say that the XYZ algorithm is the "best".  But that assessment would be overly-simplistic.  

You see, I've learned after playing around with hundreds of images, that each algorithm (even, the simplistic RGB comparison) has its strengths and weaknesses.  For _this particular image_, it may be true that XYZ is the "winner".  But when you load other images, you may find that you get a much more satisfying result from one of the other algorithms.

For this reason, I didn't simply settle on a "winner" and then hardcode that into Paint Map Studio.  Instead, I built an interactive form that allows you to experiment with many different settings (including, different algorithms) as you try to find the best possible match.

To be honest, it can sometimes be surprising to see how well a particular algorithm performs on one image - and then how poorly it performs on the next.  So it's valuable to be able to flip between them and compare the results.
<br/><br/>


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jmmjb7n4hnwm2jj22fa6.jpg)
## In the next installment...
We've implemented all the algorithms that we need to find the "best" match.  So... is that it??  Hardly!

Even when we look at the XYZ-generated image above, it may feel closer to the original image than any of the other algorithms, but it still feels... off.  There are pinks where they really shouldn't be.  There are dark greens halo-ing her hairline.  In short, we can still do _better_.

But if we're not gonna keep cycling through one algorithm after the next, how do we improve on the color matching?

The answer comes from our palette of colors.  Even though I have more than 200 unique paints, each with their own distinct color, that's still not enough colors to faithfully portray an image like this woman's face.  We need... more colors.

In the next installment I'll show how to mix paints _virtually_ so you can determine the best palette for your image.