\[ \definecolor{mycolor}{rgb}{0.3,0.7,0.7} \newcommand{\units}[1]{ { \color{mycolor} #1 } } \newcommand{\unitunit}[]{ \text{unit} } \newcommand{\unitm}[]{ \text{m} } \newcommand{\unitnm}[]{ \text{nm} } \newcommand{\unitcm}[]{ \text{cm} } \newcommand{\unitkg}[]{ \text{kg} } \newcommand{\unitsec}[]{ \text{s} } \newcommand{\unitsr}[]{ \text{sr} } \newcommand{\unitcd}[]{ \text{cd} } \newcommand{\unitlm}[]{ \text{lm} } \newcommand{\unitlx}[]{ \text{lx} } \newcommand{\unitnit}[]{ \text{nit} } \newcommand{\unitC}[]{ \text{C} } \newcommand{\unitW}[]{ \text{W} } \newcommand{\unitkW}[]{ \text{kW} } \newcommand{\unitJ}[]{ \text{J} } \newcommand{\unitK}[]{ \text{K} } \newcommand{\unitN}[]{ \text{N} } \newcommand{\unitspercent}[]{ \units{ \% } } \newcommand{\unitsaccel}[]{ \units{ \unitm \cdot \unitsec^{-2} } } \newcommand{\unitsE}[]{ \units{ \unitW / \unitm^2 } } \newcommand{\unitsL}[]{ \units{ \unitW/(\unitsr\cdot\unitm^2) } } \newcommand{\unitsnit}[]{ \units{ \unitnit } } \newcommand{\unitsm}[]{ \units{ \unitm } } \newcommand{\unitsnm}[]{ \units{ \unitnm } } \newcommand{\unitsAU}[]{ \units{ \text{AU} } } \newcommand{\unitsA}[]{ \units{ \text{A} } } \newcommand{\unitsmA}[]{ \units{ \text{mA} } } \newcommand{\unitsW}[]{ \units{ \text{W} } } \newcommand{\unitsmW}[]{ \units{ \text{mW} } } \newcommand{\unitsHz}[]{ \units{ \text{Hz} } } \newcommand{\unitsTHz}[]{ \units{ \text{THz} } } \newcommand{\unitsJ}[]{ \units{ \text{J} } } \newcommand{\unitsN}[]{ \units{ \text{N} } } \newcommand{\unitsOmega}[]{ \units{ \text{Ω} } } \newcommand{\unitsV}[]{ \units{ \text{V} } } \newcommand{\unitsdeg}[]{ \units{ \deg } } \newcommand{\unitsDegC}[]{ \units{ \deg\text{C} } } \]

Light and Color: The RGB / CIE XYZ Conversion

The conversion from absolute XYZ to linear RGB (for more explanation) is:

\begin{gather*} \begin{bmatrix}R\\G\\B\end{bmatrix} ~~=~~ \left( M^{-1} \right) \left( \eta \begin{bmatrix}X\\Y\\Z\end{bmatrix} \right) \\[1em] \hspace{8em}\eta = 1/Y_W \end{gather*}

where \(M\) is a \(3 \times 3\) matrix and \(\eta\) is a normalization factor for the RGB space's luminance[1].

The normalization factor is commonly glossed over by colorimetry references (or even sloppily omitted) and forgotten / replaced by hacks in engineering references, but we will be explicit here. It pays to be careful; this value determines how the space treats luminance, and even though it's just one number, it is fairly involved to compute.

We will also denote the normalized CIE XYZ 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 xy space induced from a particular standard observer, and a white point, usually taken to be the chromaticity of some spectral power distribution, with a luminance. For example, the ubiquitous sRGB color-space is (at least colorimetrically[2]) defined by:

Luminance:\(80~\unitsnit\)
Observer:CIE 1931 2° Standard Observer
White point:D65's chromaticity (in CIE 1931 2° xy)
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 parameters \(M\) and \(\eta\), but how?


The White Point and Luminance

Our goal is to ensure that the brightness of the spectra in XYZ space transfers across into RGB space with no unintentional adjustment.

For example, suppose I have a white virtual object that's supposed to be \(16~\unitsnit\) of brightness. This means that the emitted (linear) RGB in the sRGB color-space should be

\[ \vecinline{~R=0.2,~G=0.2,~B=0.2~} \]

which 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\). 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[4] it was simulated at.

The white-point is a chromaticity, not a reference spectrum. It has no luminance (nor even units). The luminance is needed, but is supplied separately. The combination uniquely determines how the RGB space abstracts 'white'.


Computing the Reference White and Normalization

The first and main challenge is to get the 'reference white', which is the normalized CIE XYZ coordinates of the white point.

The RGB space almost always[5] gives the white point as being the chromaticity of a given standard illuminant (a normalized spectral power distribution (SPD) \(W(\lambda)\)). The most accurate way to use this information is unfortunately a bit convoluted.

First, we calculate the scaling, which I'll call \(\sigma\), needed to make the normalized SPD \(W(\lambda)\) have the RGB space's defined luminance—i.e., to 'un-normalize' it, if you will. Luminance is calculated with the luminous efficiency function:

\begin{gather*} L_v ~=~ K_{\text{cd},\lambda} \sum_{360~\unitsnm}^{830~\unitsnm} L(\lambda) \, V(\lambda) \, \Delta\lambda \\[1em] K_{\text{cd},\lambda} := 683.002~\units{\unitlm/\unitW},\hspace{1em} \Delta\lambda := 1~\unitsnm \end{gather*}

(Note: it is unclear whether this is supposed to be an integral or a sum per-wavelength, but it's probably a sum like below; see notes on main article.)

This lets us compute the scaling factor \(\sigma\) by dividing the target luminance by the SPD's actual luminance. For example, for sRGB, \(L(\lambda) := \text{D65}(\lambda)\) and it's supposed to be at \(80~\unitsnit\). We calculate \(L_v \approx 0.007\,217\,337~\unitsnit\), so the scaling factor to fix it is:

\begin{align*} \sigma ~&\approx~ (80~\unitsnit) ~/~ (0.007\,217\,337~\unitsnit) \\ &\approx~ 11\,084.419 \end{align*}

We then scale the SPD to convert it into units of spectral radiance, and apply the standard observer function to calculate the absolute XYZ coordinates (equivalently, we could factor and apply the scaling at the end):

\begin{gather*} \begin{bmatrix}X_W\\Y_W\\Z_W\end{bmatrix} ~~=~~ \sum_{360~\unitsnm}^{830~\unitsnm} (\sigma \cdot L(\lambda)) \begin{bmatrix} \bar{x}(\lambda)\\\bar{y}(\lambda)\\\bar{z}(\lambda) \end{bmatrix} \, \Delta\lambda ~~\approx~~ \begin{bmatrix} 0.111\,329 \\ 0.117\,130 \\ 0.127\,534 \end{bmatrix} \\[1em] \hspace{10em} \Delta\lambda := 1~\unitsnm \hspace{4em}\text{(for sRGB)} \end{gather*}

(Note: this is supposed to be computed as a per-wavelength sum—not an integration; see notes on main article.)

Finally, we calculate the normalization factor \(\eta := 1 / Y_W\), required to scale absolute \(Y_W\) down to normalized \(\hat{Y}_W=1\). Multiplying by this number gives us the normalized XYZ coordinates (our reference white) \(\vecinline{\hat{X}_W,\hat{Y}_W,\hat{Z}_W}\). For example, for sRGB, \(\eta \approx 1 / 0.117\,130 \approx 8.537\,525\) and scaling produces:

\[ \begin{bmatrix}\hat{X}_W\\\hat{Y}_W\\\hat{Z}_W\end{bmatrix} \approx \begin{bmatrix} 0.950\,471 \\ 1.0\phantom{00\,000} \\ 1.088\,829 \end{bmatrix} \]

This approach is probably not what the colorimetry folk envisioned. Probably we were supposed to compute the CIE xy chromaticity of the reference spectrum, compute the target \(Y_W\) from the brightness as \(Y_W := (\text{target lum})/K_{\text{cd},\lambda}\), scale the chromaticity up to get absolute XYZ coordinates, and then proceed as above.

I say this because many RGB spaces commonly give the chromaticities of the white point in addition to the reference SPD, which allows us to skip the most annoying parts of the calculation (the dot-products). In fact, a few RGB spaces (looking at you, P3) only give a chromaticity, so we have to do it this way for those spaces.

This approach will produce the exact same answers, nowadays, but I think it's a bit sloppier.

Another consideration is that we have to take \(V(\lambda) = \bar{y}(\lambda)\). This wasn't quite true with the original \(5~\unitsnm\) datasets, but with the official \(1~\unitsnm\) datasets recommended for computation, the equivalence is exact (see notes on main article), so there is no problem.

Keeping a rigorous separation could still be useful in that it might be interesting to look at modern revisions of \(V(\lambda)\) and / or \(\bar{y}(\lambda)\), where the functions might again not match. It's probably not a good idea in practice to actually use e.g. a modern \(V(\lambda)\) with the old \(\bar{y}(\lambda)\) required by the RGB space, though: the RGB space was defined according to the contemporary versions of the function, so even though the older functions are less accurate, they may be more true to the original perceptual intent.


Computing the Matrix

Nearly all[6] other treatments of this work I have seen reference, or rather 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[7]. Despite that, everyone else seems to have just copied his presentation uncritically.

With just a small amount of algebra, I was able to boil all of it down into just two matrix equations, whose meaning is also readily understandable:

\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}\hat{X}_W\\\hat{Y}_W\\\hat{Z}_W\end{bmatrix} \\[0.5em] \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 computed from the \(x\) and \(y\) components in the 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. More precisely, these coefficients scale the RGB chromaticities up into columns defining a basis transform from RGB to normalized XYZ.

Note that the chromaticities on the left are in CIE xy space, and the normalized XYZ on the right is in CIE XYZ space. These spaces are linear scalings of each other though, so there's no problem doing that; it's merely a bit subtle.

The second equation is nearly the same, but we have 'generalized' it with RGB values and changed the right-hand-side to be the corresponding normalized XYZ values of a general value (not necessarily white). If it isn't already, the meaning of this will become clear with a few examples:

  • Suppose that \(RGB=\vecinline{1,1,1}\), that is, white. When we do this, we get basically the first equation back again, and see that the normalized coordinates are the same as the XYZ of the white point. That is, white RGB in means the normalized XYZ of white out.
  • Suppose 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. So, the right hand side will be the normalized XYZ of the red primary.

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}\hat{X}_W\\\hat{Y}_W\\\hat{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 normalized XYZ and want to get RGB, so we must compute the inverse of this, \(M^{-1}\). It is possible to compute \(M^{-1}\) from scratch with only one full matrix inversion[8].


Precomputed Matrices

For convenience, the following are the rendering-relevant parameters for the sRGB color-space, the most common color-space by far. All of these numbers are exact to double-precision (i.e. they differ from the infinitely precise results by 0.5 ULP or less). I have information on many other RGB spaces, too! Please see the RGB Spaces Colorimetric Data page.

The white point is the chromaticity of the D65 standard illuminant, and the luminance is \(80~\unitsnit\):

\begin{align*} \begin{bmatrix}x_W\\y_W\\z_W\end{bmatrix}_\text{D65} &= \begin{bmatrix} 0.31272687102656477 \\ 0.329023206641284\phantom{00} \\ 0.35824992233215125 \end{bmatrix} \hspace{1em}\text{(chromaticity)} \\[1em] \begin{bmatrix}X_W\\Y_W\\Z_W\end{bmatrix}_\text{D65} &= \begin{bmatrix} 0.11132858277478344 \\ 0.11712996448033829 \\ 0.1275344712192216\phantom{0} \end{bmatrix} \hspace{1em}\text{(absolute XYZ)} \\[1em] \begin{bmatrix}\hat{X}_W\\\hat{Y}_W\\\hat{Z}_W\end{bmatrix}_\text{D65} &= \begin{bmatrix} 0.950470558654283\phantom{0} \\ 1.0\phantom{000000000000000} \\ 1.0888287363958846 \end{bmatrix} \hspace{1em}\text{(normalized XYZ, reference white)} \end{align*}

The normalization factor to convert from absolute XYZ to normalized XYZ is[9]:

\[ \eta = 8.537524999999999 \]

The normalized SPD for sRGB, D65, can be converted into an SPD for white in units of spectral radiance, by multiplying it by a factor of:

\[ \sigma = 11084.41934789392 \]

\(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} \]

End-to-end: given an SPD in units of spectral radiance, we integrate it with the CIE 1931 standard observer functions to get absolute XYZ, then scale to normalized XYZ by multiplying by \(\eta\), and finally transform by left-multiplying by \(M^{-1}\). This gives a result in linear RGB, ℓRGB. From there, the result can be gamma-encoded and sent to the screen or image file.


Notes