17

I was recently making a function that needed to convert between several different colorspaces, and I decided to do it manually. @J.M. used many built-in functions to accomplish the same goal. When I was trying to delve in and see if they produced the same results, I found that visually they did, but numerically there were differences, and so I tried to track them down.

In my code, I needed a function to convert from RGB to XYZ colorspaces, and I used the recipe described on this Wikipedia page, which is the same as is used by EasyRGB, and appears in this paper.

The recipe goes as follows: first, the RGB values are converted to "linear RGB" via

$$C_\mathrm{linear}= \begin{cases}\frac{C_\mathrm{srgb}}{12.92}, & C_\mathrm{srgb}\le0.04045\\ \left(\frac{C_\mathrm{srgb}+a}{1+a}\right)^{2.4}, & C_\mathrm{srgb}>0.04045 \end{cases}$$

and then the XYZ values are obtained from a linear transformation,

$$\begin{bmatrix} X\\Y\\Z\end{bmatrix}= \begin{bmatrix} 0.4124&0.3576&0.1805\\ 0.2126&0.7152&0.0722\\ 0.0193&0.1192&0.9505 \end{bmatrix} \begin{bmatrix} R_\mathrm{linear}\\ G_\mathrm{linear}\\ B_\mathrm{linear}\end{bmatrix}$$

So I define this as a function rgb2xyz,

rgb2xyz[r_, g_, b_] := Module[
   {transm, rl, gl, bl},
   {rl, gl, bl} = If[# > .04045,
       ((# + 0.055)/1.055)^2.4,
       #/12.92] & /@ {r, g, b};
   transm = {{0.4124, 0.3576, 0.1805}, 
             {0.2126, 0.7152, 0.0722}, 
             {0.0193, 0.1192, 0.9505}};
   transm.{rl, gl, bl}
   ];

and then I can apply it to a test color,

testcolor = {43, 15, 155}/255.;
rgb2xyz @@ testcolor
(* {0.0708348, 0.032218, 0.312589} *)

Then I realized there is a ColorConvert function, and test it against my function.

List @@ ColorConvert[RGBColor @@ testcolor, XYZColor]
(* {0.0592725, 0.0286686, 0.234891} *)

which seems to be a reasonably large discrepancy. Visually, I can see a very small difference

XYZColor @@@ {%, %%}

enter image description here

When I go to other websites, like ColorMine or EasyRGB their results agree with mine completely (although they scale their results by a factor of 100).

What is going on here? Is Mathematica using a different RGB colorspace than sRGB?

J. M.'s missing motivation
  • 124,525
  • 11
  • 401
  • 574
Jason B.
  • 68,381
  • 3
  • 139
  • 286

3 Answers3

14

Hopefully someone else can shed more light on this, someone with more expertise on colorspaces, but I was able to find out a little bit more. I found Bruce Lindbloom's website which lists many different transformation matrices for going from RGB to XYZ. The matrix used in the above-linked wiki page corresponds to converting between sRGB and XYZ, using a reference white of D65. I know not what the reference white value means, but it will apparently be important here.

I tried the hard way to find out what matrix Mathematica uses to convert between RGB and XYZ, just by taking 3 random colors and converting them and then solving for the matrix,

varmat = 
  Array[ToExpression[
     "m" <> IntegerString[#1] <> IntegerString[#2]] &, {3, 3}];
eqn = {x, y, z} == varmat.(If[# > .04045,
        ((# + 0.055)/1.055)^2.4,
        #/12.92] & /@ {r, g, b});
eqns = Table[
   rgb = RandomReal[1, 3];
   xyz = List @@ ColorConvert[RGBColor @@ rgb, XYZColor];
   eqn /. (#1 -> #2 & @@@ Transpose@{{r, g, b}, rgb}) /. (#1 -> #2 & @@@
       Transpose@{{x, y, z}, xyz})
   , {3}];
varmat /. First@Solve[eqns, Flatten@varmat] // MatrixForm

enter image description here

And this matches exactly the transformation listed on Lindbloom's site for sRGB with a reference white of D50. I had not seen anything listed on the help pages for RGBColor or XYZColor about a reference white. But then I found the following on the page for ColorConvert

ColorConvert automatically performs chromatic (white point) adaptation. D50 white point is assumed for "XYZ", "LAB", "LUV", and "LCH" and D65 for "RGB", "CMYK", "HSB", and "Grayscale".

So that solves this problem, I post it here in case anyone in the future is trying to convert colors and notices the discrepancy.

I do wonder why Mathematica uses a different reference white than seems to be the norm for sRGB.

Edit: I was also able to find that Mathematica uses the D50 Reference White when converting to CIELAB from XYZ, which is again different than many other sites that have conversion utilities.

Jason B.
  • 68,381
  • 3
  • 139
  • 286
  • 1
    Ah, you've seen Lindbloom's site already... another thing to take into account would be chromatic adaptation when converting between systems with different white points. – J. M.'s missing motivation Dec 10 '15 at 14:50
  • About the white point: it's just a way to fix where "white" ought to be in the chromaticity diagram; you might want to look at ChromaticityPlot[]. – J. M.'s missing motivation Dec 10 '15 at 14:52
  • 1

    I do wonder why Mathematica uses a different reference white than seems to be the norm for sRGB

    Given that sRGB is defined respect to D65 and CIEXYZ respect to D50 that's exactly the matrix you want to use.

    – Batracos Mar 16 '16 at 16:22
  • As far as I know CIELAB inherits the white point from the XYZ it originates from. – Batracos Mar 16 '16 at 16:24
  • @Batracos - thank you. I approached this without much knowledge of color spaces at all, but I just noticed I was getting a different result in MMA than elsewhere so I poked around until I came to the discrepency. – Jason B. Mar 16 '16 at 16:36
  • Just to chime in here: If Mathematica is converting to D50 without asking (i.e. at least having an input to the function that lets you select illuminant) then Mathematica is "*doing it wrong*". XYZ or LAB are not necessarily D50. They can be D65 or any number of other whitepoints. The CMM for ICC profiles at present uses D50 in the PCS (and as far as I'm concerned the ICC is also "doing it wrong"). If you are going into XYZ or LAB for some function, be it converting to another colormodel, colorspace, finding a color difference, whatever, there is NO NEED to convert the white-point, (cont.) – Myndex Jun 15 '19 at 02:44
  • Continued: particularly if your source and destination white points are the same. I.e. if you are working in D65, and you want to end up in D65, then *DO NOT* convert to D50 in the middle. D65 is typically used for monitors and most video, and D50 is typically used for a printed page. So if you are going to print (a big reason ICC color management was created) then D50 along the way makes sense. But if converting between video types? No thanks! – Myndex Jun 15 '19 at 02:46
  • 2
    TL;DR is that RGB is a colour encoding model, not a colour space. That means that there are an infinite number of RGB colour spaces. Indeed sRGB, AdobeRGB, etc. are a couple of many. Defining a colour space in RGB requires transfer function(s), RGB primaries, and white point. https://www.colour-science.org/posts/the-importance-of-terminology-and-srgb-uncertainty/ Accepted answer is wrong, this answer is closer, but still leaves a bit to be desired. Answering your last question, see ICC D50. ICC's working space is D50, so the matrices dumped from the ICC is D50 based. – troy_s Dec 20 '19 at 22:37
  • @troy, it'd be nice if you could go into further detail on how the other answer could be improved, as that was just a presentation of how the computations are internally done by the built-in function for color conversion, using Lindbloom's formulae. – J. M.'s missing motivation May 28 '20 at 12:37
  • There is no singular RGB “colour space”, but rather many expressed within it as a colour encoding model. Any given transform is expressed relative to XYZ, via primaries, transfer functions, and white point. White point and primaries are expressed as chromaticity positions in xy, and solved to an XYZ matrix, which represents the XYZ position normalized. It might be worth citing the sRGB specification for primaries and white point, which end up with a direct XYZ matrix. – troy_s May 28 '20 at 20:02
8

Just for reference purposes, here is a manual re-implementation of ColorConvert[color, "RGB" -> "XYZ"]:

(* whitepoints *)
d65 = {0.95047, 1., 1.08883}; d50 = {0.96422, 1., 0.82521};

(* cone response domain *)
bradford = {{0.8951, 0.2664, -0.1614},
            {-0.7502, 1.7135, 0.0367},
            {0.0389, -0.0685, 1.0296}};

(* inverse companding function *)
InversesRGBGamma = Function[x, Piecewise[{{x/12.92, x <= 0.04045}},
                                         ((x + 0.055)/1.055)^2.4], Listable];

(* chromaticity coordinates of primaries (RGB) *)
srgb = {{0.64, 0.33}, {0.3, 0.6}, {0.15, 0.06}};

(* chromatic adaptation matrix, D65 to D50 *)
d65tod50 = LinearSolve[bradford, DiagonalMatrix[(bradford.d50)/(bradford.d65)].bradford];

(* RGB -> XYZ matrix *)
r2x = With[{xyz = Transpose[{#1/#2, 1., (1 - #1 - #2)/#2} & @@@ srgb]}, 
           xyz.DiagonalMatrix[LinearSolve[xyz, d65]]];

rgb2xyz[col_RGBColor] := d65tod50.r2x.InversesRGBGamma[List @@ col]

Example:

rgb2xyz /@ ColorData[61, "ColorList"]
   {{0.199667, 0.106547, 0.00721723}, {0.073949, 0.056383, 0.147498},
    {0.823078, 0.923995, 0.234938}, {0.226765, 0.233149, 0.365262},
    {0.099347, 0.136449, 0.027346}, {0.521978, 0.511432, 0.0633633},
    {0.0405846, 0.033663, 0.113543}, {0.3308, 0.169661, 0.0191982},
    {0.671893, 0.718828, 0.33747}}

List @@@ ColorConvert[ColorData[61, "ColorList"], XYZColor]
   {{0.199667, 0.106547, 0.00721724}, {0.0739489, 0.0563829, 0.147498},
    {0.823078, 0.923995, 0.234938}, {0.226765, 0.233149, 0.365262},
    {0.099347, 0.136449, 0.027346}, {0.521978, 0.511432, 0.0633633},
    {0.0405845, 0.033663, 0.113543}, {0.3308, 0.16966, 0.0191983},
    {0.671893, 0.718828, 0.33747}}

There are discrepancies, but they seem slight.


For completeness, here is the manual "XYZ" -> "RGB" conversion:

sRGBGamma = Function[x, With[{z = Abs[x]},
                             Sign[x] Piecewise[{{12.92 z, z <= 0.0031308}},
                                               1.055 z^(1/2.4) - 0.055]],
                     Listable];

d50tod65 = LinearSolve[bradford, DiagonalMatrix[(bradford.d65)/(bradford.d50)].bradford];

xyz2rgb[col_XYZColor] :=
   Clip[sRGBGamma[LinearSolve[r2x, d50tod65.(List @@ col)]], {0., 1.}]
J. M.'s missing motivation
  • 124,525
  • 11
  • 401
  • 574
  • Nice! I had something similar here, yours is easier to follow what is happening though (and I multiplied the answer by 100 in trying to match the recipe in Moreland's paper) – Jason B. Jun 15 '16 at 13:20
  • I figured a Mathematica summary of the stuff in Lindbloom's website would be useful for those trying to debug their color conversions. I believe we've both been bitten by mistaken whitepoint assumptions, and I'm sure we won't be the only ones. – J. M.'s missing motivation Jun 15 '16 at 13:23
8

In version 13.0 of the Wolfram Language you can use WhitePoint option of ColorConvert:

In[1]:= testcolor = {43, 15, 155}/255.;

In[2]:= ColorConvert[RGBColor @@ testcolor, XYZColor] // InputForm

Out[2]//InputForm= XYZColor[0.05927180103834016, 0.028668426605330307, 0.23488586262585504]

In[3]:= ColorConvert[RGBColor @@ testcolor, XYZColor, WhitePoint -> "D65"] // InputForm

Out[3]//InputForm= XYZColor[0.07081819973906046, 0.03221228553756768, 0.31254770033610246]

Piotr Wendykier
  • 1,281
  • 9
  • 10