Color correction fails in unbounded sRGB
Unless an unwanted color cast was created in the sRGB color space, sRGB is the wrong color space to use when removing the color cast. The fact that unbounded sRGB encodes out of gamut colors using negative channel values makes a bad situation worse: Color casts are corrected using multiplication, and multiplying out of gamut colors in the unbounded sRGB color space produces meaningless results.
Written May 2014.
This article is part of a series of articles on the limitations of unbounded sRGB as a universal color space for image editing. However, the problems illustrated below are more broadly applicable: Color balancing an image is a chromaticity-dependent editing operation and so an unwanted color cast should always be corrected in the RGB working space in which it was created.
Introduction: When correcting an image color cast, the RGB working space you use does matter
A color cast is created by multiplying an image layer by a particular color. That same color cast is removed by multiplying the image layer by the inverse of that same color.
The inverse of a color is the same color (has the same location in the XYZ reference color space) before and after an image is converted from one RGB color to another. But the RGB channel values are different. This has the perhaps unexpected consequence that a color cast that is created in any given RGB working space can't be correctly removed in some other RGB working space. The problem, of course, is that multiplication is a chromaticity-dependent editing operation: in other words, the result of multiplying two colors depends on the chromaticities of the color space in which the multiply operation is performed.
Occasionally a software developer will conclude that because unbounded sRGB (also called "extended sRGB") can be used to encode and display all possible colors, therefore it's also suitable as a "universal working space" for editing all images. However, except in the limiting case where the color cast was created in the sRGB color space (bounded or unbounded), color correction fails in the unbounded sRGB color space. If the image has any colors that are out of gamut with respect to the bounded sRGB color space, and especially if the color cast was created using a color that's out of gamut with respect to the bounded sRGB color space, then correcting a color cast in the unbounded sRGB color space produces not just wrong results, but spectacularly wrong results.
Software used in this article:
High bit depth GIMP 2.9 from git is unusual among image editors in that it actually does allow editing images that have been converted to the unbounded sRGB color space.
The default GIMP from git uses linear gamma RGB values for some editing operations and uses "gamma corrected" RGB values (using the default sRGB almost gamma=2.2 tone reproduction curve) for other editing operations. For images presented on this page I compiled and used a version of GIMP from git that I modified to ensure that all editing operations were done using linear gamma processing.
Removing five different cyan color casts created in five different RGB working spaces
Figure 1 below shows a picture of two men sitting at an Oxygen Bar. I'm not sure what people do at Oxygen Bars, but that's not the point. The point is both men are wearing shorts. So when the image is given various color casts in different RGB working spaces, it will be easy to see the incorrect skin tones that result from trying to remove these color casts in the unbounded sRGB color space. The white dot in the lower right corner of the picture provides a known "should be white" patch for using with the Levels "Pick white point" eyedropper.
Figure 2 below has two columns. The left column shows the result of giving the original image a cyan color cast in five different RGB working spaces. The right column shows the result of using the Levels "Pick White Point" eyedropper to correct the color casts after the five images were converted to unbounded sRGB:
Why the chromaticities matter when color correcting images
The mathematics of correcting a color cast
Regardless of what UI function you might use to give an image a color cast, somewhere "under the hood" the color cast was created by multiplying the image by the appropriate color.
Likewise, regardless of what UI function you might use to remove a color cast, what really happens is that "somewhere under the hood" the image is multiplied by a color equal to the inverse of the color cast. For example, here's what really happens when you use the Levels White Point Picker to click on a nominally neutral spot in an image:
- The spot's uncorrected RGB channel values are determined. Let's say the resulting channel values are: (R1, G1, B1).
- A color is calculated that, when multiplied by the uncorrected RGB channel values, produces unity: (1/R1, 1/G1, 1/B1) = (R2, G2, B2).
- The image is multiplied by the calculated color (R2, G2, B2).
When the above procedure is performed in the color space in which the color cast was created, the color cast is removed. But when the procedure is performed in a different color space, the color cast is not removed. Instead any non-neutral color is more or less damaged. This is because the results of multiply depend on the color's space's chromaticities.
When attempting to remove a color cast in the wrong color space, neutral colors (black, white, and gray) are indeed made neutral. But all the other colors are systematically made incorrect. The greater the differences between the color space in which the color cast was created and the color space you use when trying to remove the color cast, the worse the results will be.
When trying to remove a color cast that was created in some other color space after the image is converted to the unbounded sRGB color space, if the color cast itself is out of gamut with respect to the bounded sRGB color gamut, then the resulting image colors are not just damaged but also meaningless. This is because multiplying out of gamut colors produces meaningless results.
Using unbounded sRGB to correct a cyan color cast that was created in the sRGB color space
First let's consider the version of the Oxygen Bar image above that was given a cyan color cast in the sRGB color space (the first row of Figure 2 above):
- Originally the image was in the sRGB color space.
- Before the image was given the cyan color cast in the sRGB color space, the white dot eyedroppers as (1.000000, 1.000000, 1.000000).
- Also before the image was given the cyan color cast in the sRGB color space, a selected spot on the man's leg eyedroppers as (0.347365, 0.199789, 0.160058). In the XYZ reference color space this sRGB color has the location (0.251302, 0.230215, 0.138496). Let's call this XYZ location our "target color".
- After the image was given the cyan color cast in the sRGB color space, the now-cyan dot eyedroppers as (0.500000, 1.000000, 1.000000) and the man's leg eyedroppers as (0.173682, 0.199789, 0.160058) and .
- Using the Levels "White point picker" on the now-cyan dot to correct the cyan color cast is the same as multiplying the image by (1/0.500000, 1/1.000000, 1/1.000000), which equals (2.000000, 1.000000, 1.000000). Doing so of course makes the cyan dot white again.
- Multiplying the channel values of the selected spot on the man's leg by (2.000000, 1.000000, 1.000000) gives (0.347364, 0.199789, 0.160058).
- After using the Levels White point picker on the cyan dot, as expected the man's leg eyedroppers (0.347364, 0.199789, 0.160058), which of course still has the XYZ location (0.251302, 0.230215, 0.138496). So we successfully removed the cyan color cast and got back to the target color.
In the unbounded sRGB color space, removing a cyan color cast that was created in the sRGB color space is trivially easy.
Using unbounded sRGB to correct a cyan color cast that was created in the Rec. 2020 color space
Now let's consider the version of the Oxygen Bar image above that was given a cyan color cast in the Rec. 2020 color space (the third row of Figure 2 above):
- Originally the image was in the Rec. 2020 color space. Before the image was given a cyan color cast in the Rec. 2020 color space, the white dot eyedroppers as (1.000000, 1.000000, 1.000000).
- Before the image was given a cyan color cast in the Rec. 2020 color space, a selected spot on the man's leg eyedroppers as (0.290654 0.209538 0.166624). In the XYZ reference color space this Rec. 2020 color has the channel values (0.251302 0.230215 0.138495). Let's call this XYZ location our "target color".
- After the image was given the cyan color cast in the Rec. 2020 color space, the now-cyan dot eyedroppers as (0.500000, 1.000000, 1.000000) and the selected spot on the man's leg eyedroppers as (0.145327 0.209538 0.166624).
- After the image is converted to unbounded sRGB, the cyan dot eyedroppers as (0.169710, 1.062298, 1.009072) and the previously selected spot on the man's leg eyedroppers as (0.106037 0.217897 0.162695).
- Using the Levels "White point picker" on the cyan dot to correct the cyan color cast is the same as multiplying the image by (1/0.169710, 1/1.062298, 1/1.009072) = (5.892405, 0.941355, 0.991010). Doing so of course makes the cyan dot white again.
- Multiplying the channel values of the selected spot on the man's leg by (5.892405, 0.941355, 0.991010) gives (0.624813, 0.205118, 0.161232). Eyedroppering the same spot after using the Levels White point picker on the cyan dot gives (0.624817, 0.205118, 0.161232), which departs from our predicted color by (0.000004, 0.000000, 0.000000). We will assume a rounding error accounts for this very small difference.
- Converting the sRGB color (0.624817, 0.205118, 0.161232) to the XYZ color space gives (0.374501, 0.295836, 0.143712), which isn't particularly close to our target color of (0.251302 0.230215 0.138495).
In the unbounded sRGB color space, we didn't succeed in removing the cyan color cast that was created in the Rec. 2020 color space. Well, we did manage to make the cyan dot white. But the rest of the image colors are all messed up. Of course we already knew this just by looking at the third row of Figure 2 above. But now we know the mathematics that prevent correcting a Rec. 2020-created cyan color cast after the image is converted to unbounded sRGB.
Using unbounded sRGB to correct a cyan color cast that was created in the ProPhotoRGB color space
Now let's consider the version of the Oxygen Bar image above that was given a cyan color cast in the ProPhotoRGB color space (the fifth row of Figure 2 above):
- Originally the image was in the ProPhotoRGB color space. Before the image was given a cyan color cast in the ProPhotoRGB color space, the white dot eyedroppers as (1.000000, 1.000000, 1.000000).
- Before the image was given a cyan color cast in the ProPhotoRGB color space, a selected spot on the man's leg eyedroppers as (0.272312 0.213187 0.167892). In the XYZ reference color space this ProPhotoRGB color has the channel values (0.251301 0.230215 0.138496). Let's call this XYZ location our "target color". For those of you with very sharp eyes, I'm using the ArgyllCMS xicclu utility to ascertain the XYZ values; xicclu gives results to 6 decimal places, so let's assume the difference in the red channel of 0.000001 between the XYZ location of the selected spot on the man's leg in the ProPhotoRGB color space and the Rec. 2020 and sRGB color spaces is a rounding error.
- After the image was given the cyan color cast in the ProPhotoRGB color space, the now-cyan dot eyedroppers as (0.500000, 1.000000, 1.000000) and the selected spot on the man's leg eyedroppers as (0.136175 0.213191 0.167893).
- After the image is converted to unbounded sRGB, the cyan dot eyedroppers as (-0.017195, 1.114419, 1.004272) and the previously selected spot on the man's leg eyedroppers as (0.070405 0.230948 0.161221).
- Using the Levels "White point picker" on the cyan dot to correct the cyan color cast is the same as multiplying the image by (1/-0.017195, 1/1.114419, 1/1.004272) = (-58.156441, 0.897329, 0.995746). Doing so of course makes the cyan dot white again.
- Multiplying the channel values of the selected spot on the man's leg by (-58.156441, 0.897329, 0.995746) gives (-4.094504, 0.207236, 0.160535). Eyedroppering the same spot after using the Levels White point picker on the cyan dot gives (-4.094407 0.207236 0.160536), which is an exact match in the Green and Blue channels and off by 0.000097 in the Red channel. Considering that we are dealing with completely meaningless numbers here, I'll assume there's a rounding error somewhere in the chain between the eyedropper readout and GIMP's ability to accurately multiply imaginary floating point RGB values.
- The ArgyllCMS xicclu utility doesn't calculate XYZ values for meaningless RGB values such as (-4.094407 0.207236 0.160536). LCMS's transicc (which requires multiplying the channel values by 255 for input and dividing by 100 for output equivalent to xicclu's output), gives (-1.682531, -0.752660, 0.077748). This XYZ location represents an imaginary color with a physically impossible Lumunance of Y = -0.752660.
In the unbounded sRGB color space, we didn't succeed in removing the cyan color cast that was created in the ProPhotoRGB color space. Well, we did manage to make the cyan dot white. But the rest of the image colors are completely meaningless because in the unbounded sRGB color space we needed to multiply by an out of gamut color to make the cyan dot white. Of course we already knew the colors were meaningless just by looking at the fifth row of Figure 2 above. But now we know the mathematics that prevent correcting a ProPhotoRGB-created cyan color cast after the image is converted to unbounded sRGB.
Conclusion: Color correction fails in the unbounded sRGB color space
Except for the limiting case where the color cast was actually created in the sRGB color space, color correction fails in the unbounded sRGB color space for two very different reasons:
- Color correction depends on multiplication, and the result of multiplying two colors depends on the color space chromaticities:
- Color casts are created by multiplying an image layer by a color, and removed by multiplying the image layer by the inverse of the color that was used to create the color cast.
- The inverse of a color is the same color (same location in XYZ space) in both color spaces, but the channel values are different after the image is converted from some other color space to unbounded sRGB. Hence multiplying by the inverse of a color cast only properly removes that color cast when done in the color space in which the cast was created.
Hence multiplying an image layer by a given color in one working space can't be undone by multiplying the image layer by the inverse of that color in a different color space.
- Multiplying out of gamut colors produces meaningless results. Because color correction depends on multiplication, if any of the image colors or the color cast itself are out of gamut with respect to the bounded sRGB color space, the results of color correcting the out of gamut colors are not just incorrect, but also meaningless. For example, consider the ProPhotoRGB cyan color cast (0.50, 1.00, 1.00). In the unbounded sRGB color space, this color is (-0.017195, 1.114419, 1.004272) and its inverse is (-58.156441, 0.897329, 0.995746). In the unbounded sRGB color space, multiplying the image layer by the wildly out of gamut color (-58.156441, 0.897329, 0.995746) produces entirely meaningless results.
Are such extreme color casts unrealistic?
If the argument is made that such extreme color casts as I show on this page are never encountered in actual image editing, my answer is threefold:
- Yes, in fact such extreme color casts are encounted in actual image editing.
- Correcting less extreme color casts in the unbounded sRGB color space causes less extreme errors, but nonetheless does cause errors. A smaller error is still an error.
- The source of the errors when correcting extreme color casts in the unbounded sRGB color space isn't the extremity of the color casts but rather the fact that the color casts were corrected in the unbounded sRGB color space.
Color correction should be done in the color space in which the color cast was originally created. Unless the color cast was created in the sRGB color space, unbounded sRGB is not the right color space to use to remove the color cast. The fact that unbounded sRGB encodes out of gamut colors using negative channel values makes a bad situation worse: Multiplication is necessary to correct color casts, and multiplying out of gamut colors produces meaningless results.
Appendix 1: Normalizing the RGB channel values before color balancing doesn't work
The suggestion has been made that normalizing RGB channel data allows operations to be correctly performed in the unbounded sRGB color space. Alas normalizing just makes a bad situation worse. After converting the ProPhotoRGB image to unbounded sRGB, normalizing the RGB channel data before attempting to remove the ProPhotoRGB-created cyan color cast ends up producing an image that's mostly red.
Appendix 2: Additional examples of odd colors that result from trying to color balance images in the unbounded sRGB color space
Figures 3 through 6 show what happens to various images with various color casts from various source color spaces, when the images are converted to unbounded sRGB for color correction: