Light and Color: The RGB / CIE XYZ Conversion
The conversion from XYZ to RGB is:
\[ \begin{bmatrix}R\\G\\B\end{bmatrix} ~~=~~ \left( M^{-1} \right) \left( \frac{1}{Y_W} \begin{bmatrix}X\\Y\\Z\end{bmatrix} \right) \]where \(M\) is a \(3 \times 3\) matrix and \(Y_W\) is the CIE XYZ \(Y\)-coordinate of the RGB space's white point. The normalization by \(Y_W\) is commonly glossed over (if not outright forgotten and replaced with hacks), but it's important. (For more explanation.) We will, however, denote the normalized coordinates with a hat (\(~\hat{\phantom{M}}~\)) symbol, allowing us to write the transformation more conventionally:
\[ \begin{bmatrix}R\\G\\B\end{bmatrix} ~~=~~ \left( M^{-1} \right) \begin{bmatrix}\hat{X}\\\hat{Y}\\\hat{Z}\end{bmatrix} \]An RGB space is defined by three primaries, usually taken to be chromaticity points in the CIE xyY space induced from a particular standard observer, and a white point, usually taken to be a spectral power distribution and a luminance. For example, the ubiquitous sRGB color-space is (at least colorimetrically[1]) defined by:
Observer: | CIE 1931 2° Standard Observer |
---|---|
White point: | D65 at brightness \(80~\unitsnit\) |
Red primary: | \(\vecinline{~x_r=0.64,~y_r=0.33~}\) (in CIE 1931 2° xy) |
Green primary: | \(\vecinline{~x_g=0.30,~y_g=0.60~}\) (in CIE 1931 2° xy) |
Blue primary: | \(\vecinline{~x_b=0.15,~y_b=0.06~}\) (in CIE 1931 2° xy) |
This information can be used to construct the above transformation, but how?
Computing the White Point
As described more completely in my treatment here (also linked above), our goal is to ensure that the brightness of the spectra in XYZ space transfers across into RGB space with no unintentional adjustment. This could have (should have, in my view) been done automatically through well defined units and conversion constants, but it wasn't.
For example, if I have a white virtual object that's supposed to be \(16~\unitsnit\) of brightness, this means that the emitted pre-gamma RGB in the sRGB color-space should be \(\vecinline{~R=0.2,~G=0.2,~B=0.2~}\). This will cause the physical display to emit white at \(20\unitspercent\) of sRGB's nominal brightness of \(80~\unitsnit\)—which is to say, \(16~\unitsnit\), just like the virtual object. This is the Right Thing to do because it gives the best possible representation of the virtual object, reproducing its brightness in the real world at the same brightness[2] it was simulated at.
The first thing we have to do is compute the appropriately scaled white point's spectral power distribution. This is done by integrating the defining spectrum with the luminous efficiency function \(V(\lambda)\), and multiplying by \(K_{\text{cd},\lambda}:=683.002~\units{\unitlm/\unitW}\) to get brightness. Taking the white point to be a spectral power distribution of radiance \(L_W(\lambda)\), this procedure gives an output in luminance \(L_v\) (\(\unitsnit\)). Then we scale the distribution by whatever is required to get the right brightness for the RGB space.
This is simpler than it sounds. For our example of sRGB, we compute the brightness of \(L_W(\lambda):=\text{D65}(\lambda)\) from the luminous efficiency function:
\begin{align*} L_v ~~&=~~ K_{\text{cd},\lambda}\int_0^\infty L_W(\lambda) \, V(\lambda) \, d\lambda,\hspace{1cm} \begin{matrix} K_{\text{cd},\lambda} &:=& 683.002~\units{\unitlm/\unitW}\\ L_W(\lambda) &:=& \text{D65}(\lambda)\hspace{3em} \end{matrix}\\ ~~&\approx~~ 0.007\,217~\unitsnit \end{align*}The white point for sRGB is supposed to be \(80~\unitsnit\), so we need to scale this up by a factor of \(\approx 1.108 \times 10^{4}\). This factor times the spectrum for D65 forms the white point of sRGB (value exact for double
-precision).
Computing White Point XYZ and the Normalization Factor
Next, we take the XYZ coordinates of the white point. This is just the standard integration used for computing XYZ tristimulus values. Ensuring we're using the scaled distribution and the CIE XYZ 1931 2° standard observer functions, we get:
\[ \begin{bmatrix}X_W\\Y_W\\Z_W\end{bmatrix} ~~:=~~ \int_0^\infty W(\lambda) \begin{bmatrix}\bar{x}(\lambda)\\\bar{y}(\lambda)\\\bar{z}(\lambda)\end{bmatrix} \, d\lambda ~~\approx~~ \begin{bmatrix} 0.111\,328 \\ 0.117\,130 \\ 0.127\,534 \end{bmatrix} \]The normalization factor is just \(1/Y_W\), as above.
Computing the Matrix
Nearly all[3] other treatments of this work I have seen reference, or in many cases outright plagiarize, a certain website by a Bruce Lindbloom. Lindbloom's treatment is clear enough, but the algorithm is not explained and in fact contains redundant computation that can be simplified considerably[4]. Despite that, everyone else seems to have just copied his presentation uncritically.
With just a small amount of algebra, we can boil all of it down into just two matrix equations, whose meaning is also immediately clear:
\begin{gather*} \begin{bmatrix}x_r&x_g&x_b\\y_r&y_g&y_b\\z_r&z_g&z_b\end{bmatrix} \begin{bmatrix}c_r\\c_g\\c_b\end{bmatrix} = \begin{bmatrix}X_W\\Y_W\\Z_W\end{bmatrix} \\ \begin{bmatrix}x_r&x_g&x_b\\y_r&y_g&y_b\\z_r&z_g&z_b\end{bmatrix} \begin{bmatrix} c_r\,R \\ c_g\,G \\ c_b\,B \end{bmatrix} = \begin{bmatrix}\hat{X}\\\hat{Y}\\\hat{Z}\end{bmatrix} \end{gather*}(Note that the \(z_{r,g,b} = 1 - x_{r,g,b} - y_{r,g,b}\) is an uncommon third component sometimes seen in relation to CIE xy space.)
The first equation says that there are three unknown coefficients \(c_{r,g,b}\) that should be used to weight the red, green, and blue primaries so that they sum up to the normalized XYZ value of white. These coefficients say how much of each primary needs to be mixed in so that we get white at the correct brightness.
The second equation is nearly the same, but we have added the RGB values and changed the right-hand-side to be the corresponding normalized XYZ values. If it isn't already, the meaning of this will become clear with a few examples:
- Pretend that \(RGB=\vecinline{1,1,1}\), that is, white. When we do this, we get basically the first equation, and see that the normalized coordinates are the same as the XYZ of the white point. That is, white RGB in means white XYZ out.
- Pretend that \(RGB=\vecinline{1,0,0}\), that is, red. When we do this, we see that only the red primary \(\vecinline{x_r,y_r,z_r}\) gets included in the matrix multiplication, and the right hand side represents the XYZ of the red primary alone.
That is, the second matrix equation is just weighting the previous equation, which scaled the primaries up to XYZ, by RGB so as to scale the relative contribution of each of the primaries.
Hopefully it should be clear how to solve these equations, but just to spell it out, here is the math:
\begin{gather*} z_{r,g,b} = 1 - x_{r,g,b} - y_{r,g,b} \\[0.5em] \begin{bmatrix}c_r\\c_g\\c_b\end{bmatrix} = \begin{bmatrix}x_r&x_g&x_b\\y_r&y_g&y_b\\z_r&z_g&z_b\end{bmatrix}^{-1} \begin{bmatrix}X_W\\Y_W\\Z_W\end{bmatrix} \\[0.5em] M = \begin{bmatrix} c_r\,x_r & c_g\,x_g & c_b\,x_b \\ c_r\,y_r & c_g\,y_g & c_b\,y_b \\ c_r\,z_r & c_g\,z_g & c_b\,z_b\end{bmatrix} \end{gather*}Remember that we usually start with XYZ and want to get RGB, so we must compute the inverse of this, \(M^{-1}\).
Precomputed Matrices
For convenience, the following are the rendering-relevant parameters for the sRGB color-space, the most common color-space by far. These numbers are exact to double
-precision. I have information on many other RGB spaces, too! Please see the RGB Spaces Colorimetric Data page.
\(M^{-1}\) for sRGB:
\[ M_\text{sRGB}^{-1} = \begin{bmatrix} \phantom{-}3.2404462546477406\phantom{00} & -1.5371347618200821 & -0.49853019302272933 \\ -0.9692666062446794\phantom{00} & \phantom{-}1.8760119597883693 & \phantom{-}0.04155604221443006 \\ \phantom{-}0.055643503564352756 & -0.2040261797359601 & \phantom{-}1.0572265677227024\phantom{0} \end{bmatrix} \]The white point is the D65 standard illuminant at \(80~\unitsnit{nit}\), which means you need to scale D65 by \(11084.41934789392\) (assuming integration in meters instead of nanometers). Your scene data \(\vecinline{X,Y,Z}\) should be divided by \(Y_W = 0.11712996448033829\) to get \(\vecinline{\hat{X},\hat{Y},\hat{Z}}\).