Building a Color Cube

Introduction

By far the most common way in practical applications to specify color is by using what's called a color cube, where you can specify the R, G, and B value of color, each within the range of 0 and 255. What exactly are the colors that can be represented by such a color cube? How is it related to the color gamut we've discussed, and how to we construct a color cube? These are questions this tutorial explores.

Part 1: The Geometry of a Color Space's Gamut

A color space allows us to precisely describe any color using a linear combination of three primary colors. Some real colors in a color space require negative amount of the primaries. While mathematically rigorous and physically real, those colors can not be actually produced by a practical system such as a lighting or display device that uses the chosen primaries. In practice, only colors that are expressed as non-negative combination of the primaries can actually be produced from the chosen primaries. The totality of all the colors that can be physically produced in a color space is called the color gamut of the color space. This tutorial is about producing colors in practical systems, and so it is those colors that are within a color space's gamut that we care about.

How does the color gamut of a color space look like? It's a parallelepiped. The shape of the parallelepiped is uniquely dictated by four things: the three primaries and the reference white. The chart on the left shows the CIE 1931 xy-chromaticity diagram, which plots the primary colors and the reference white of the sRGB color space (which is D65). The chart on the right shows the parallelepipe-shaped gamut of the sRGB color space in the XYZ color space. For better visualization and also sort of following the convention, we normalize the Y component of the reference white to be 1.0. The spectral locus is shown as well for reference.

The Importance of Reference White

It's critical to realize the necessity of picking a reference white in determining the color gamut. Picking the three primaries will fix only the direction of $\overrightarrow{\mathbf{OR}}$, $\overrightarrow{\mathbf{OG}}$, and $\overrightarrow{\mathbf{OB}}$ vectors, while leaving their lengths/norms undecided. There are infinitely many parallelepiped that can be constructed from those three vector directions. Picking the reference white essentially fixes the norms of those three vectors and, by extension, the entire parallelepiped. To convince yourself of that, toggle the "Pick Colors" switch, and then drag the reference white to change it. As you change the reference white, the parallelepiped on the right will change accordingly.

As you change the reference white, the directions of $\overrightarrow{\mathbf{OR}}$, $\overrightarrow{\mathbf{OG}}$, and $\overrightarrow{\mathbf{OB}}$ vectors don't change but their norms do. This indicates that we are not changing the chromaticities of the primaries, but are changing their intensities. Why would the primary intensities change if we change the reference white? Remember how reference white is defined in a color space: it's mixed from one unit of each primary. If we change the reference white, it naturally follows that the power of light in one unit of each primary will have to change to.

Here are a few more things you can do to gain a better understanding of the color gamut.

  • Change a primary; the corresponding vector's direction will change. As a side effect, the primary intensities will change, too, to satisfy the requirement that reference white is mixed from one unit of each primary.
  • Move the reference white to sit on an edge of the triangle formed by the primaries; the parallelepiped degenerates into a parallelogram, indicating that the color gamut has become 2D. Why? If white sits on the R-G edge, then white can be mixed from just R and G. But white, by definition, should be mixed by one unit of R, G, and B, and so the power of the B primary should be 0. Therefore, the gamut becomes 2D.
  • Move the reference white outside the primary triangle; you will require some negative amount of a primary to match the reference white. This is evident by the negative XYZ values of the primary in the right plot. While mathematically OK, you can't build a real color space like that.
  • If you move the reference white or a primary outside the spectral locus, the corresponding vertex in the right plot will move outside the HVS gamut, which you can reveal by clicking the "HVS gamut boundary" label in the legend.

The Math

Let's now go through the math behind generating the parallelepiped from the chromaticities of the primaries and the reference white. Let's denote the chromaticies of the primaries and the reference white $r, g, b, w$. Without losing generality, the XYZ values of one unit of the reference white is conventionally normalized such that the Y is 1.0. Therefore, the XYZ value of the reference white is $[\frac{w_x}{w_y}, 1, \frac{w_z}{w_y}]$. What we want to calculate is the XYZ values of one unit of each primary such that they add up to match the reference white.

How to represent the XYZ values of one unit of the R primary? We know R's chromaticy is $r = [r_x, r_y, r_z]$. We can then denote the XYZ values of one unit of R as $[\alpha r_x, \alpha r_y, \alpha r_z]$, where $\alpha$ represents the intensity of one unit of R and is an unknown to be solved for. Overall, we should have the following system of equation:

$ \begin{bmatrix} r_x & g_x & b_x \\ r_y & g_y & b_y \\ r_z & g_z & b_z \end{bmatrix} \times \begin{bmatrix} \alpha \\ \beta \\ \gamma \end{bmatrix} = \begin{bmatrix} \frac{w_x}{w_y}\\ 1 \\ \frac{w_z}{w_y} \end{bmatrix} $, where $\alpha, \beta, \gamma$ are unknowns to be solved for.

Solving this system of equations will give us the XYZ values of one unit of each primary, which when combined with the XYZ value of the reference white gives us the parallelepiped.

Part 2: From a Parallelepiped to a Cube

$\overrightarrow{\mathbf{OR}}$, $\overrightarrow{\mathbf{OG}}$, $\overrightarrow{\mathbf{OB}}$ are the basis vectors of a vector space, but they are not orthogonal. That's mathematically OK, but perhaps what's more intuitive is to describe a color in a vector space where the basis vectors are orthogonal. That's as simple as performing a linear transformation that transforms a parallelepiped to a cube. Click the "Transform Parallelepiped to Cube" button under the right plot. You will see the transformation being applied, and the parallelepiped becomes a unit cube; hover over the cube vertices to convince you of that (barring some numerical imprecision in the calculation).

The Math

How is the transformation calculated? The transformation matrix $T$ should satisfy:

$T \times \begin{bmatrix} \alpha r_x & \beta g_x & \gamma b_x \\ \alpha r_y & \beta g_y & \gamma b_y \\ \alpha r_z & \beta g_z & \gamma b_z \end{bmatrix} = \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} $, and so $T = \begin{bmatrix} \alpha r_x & \beta g_x & \gamma b_x \\ \alpha r_y & \beta g_y & \gamma b_y \\ \alpha r_z & \beta g_z & \gamma b_z \end{bmatrix}^{-1} = \begin{bmatrix} \alpha & 0 & 0 \\ 0 & \beta & 0 \\ 0 & 0 & \gamma \end{bmatrix}^{-1} \times \begin{bmatrix} r_x & g_x & b_x \\ r_y & g_y & b_y \\ r_z & g_z & b_z \end{bmatrix}^{-1} $

$[\alpha r_x, \alpha r_y, \alpha r_z]^T$ represents the XYZ values of one unit of R primary, like we've just calculated above. The same notation applies to the other two primaries. This transformation is saying nothing more than that the colors of one unit of R, G, and B primaries should be represented as $[1, 0, 0]^T$, $[0, 1, 0]^T$, and $[0, 0, 1]^T$, respectively. The white is now represented by $[1, 1, 1]^T$, indicating that it's mixed by equal unit of the primaries.

Applying this transformation $T$ to any color/point in XYZ will generates the corresponding RGB value of that color. What if a color is outside the parallelepiped in the XYZ space? After the transformation is will be outside the RGB cube. The spectral locus gets transformed, too. One way to look at the new spectral locus is that if we go through each spectral color and collects its RGB coordinates, we get the Color Matching Functions of this particular RGB color space. Notice that the no spectral color sits on the axes of the new space, suggesting that the primaries are not spectral colors.

To be more specific, the transformation matrix $T$ for the color space you've built is:

${}$

If you keep the primaries and reference white unchanged in the first chart, $T$ should rougly match the matrix that converts a color from CIE XYZ to sRGB.

Part 3: Gamma (Quantization)

In a color cube, a color is represented by three real numbers between 0 and 1. But we can't represent an arbitrary real number on a computer. We have to encode the RGB triplet in either a floating-point format or a fixed-point format. By far the most common approach is to use a 8-bit fixed-point representation to encode each primary value, as defined in the sRGB standard. With this, each color is then encoded as three 8-bit values, taking 3 Bytes of computer memory. Converting a number from a continuous domain to a discrete domain is called quantization in signal processing parlance.

Here comes the question: how do we map a real number between 0 and 1 to an integer between 0 and 255? The easiest approach is linear mapping, essentially multiplying any RGB values between 0 and 1 with 255 and rounding the result to, say, the closest integer. Rounding introduces error, and it's inevitable. So let's take some time to understand the implication of quantization error in real applications. Consider two colors $[0.100, 0.000, 0.000]$ and $[0.101, 0.000, 0.000]$; they both are mapped to the same 8-bit RGB value $[26, 0, 0]$. When a display shows these two colors, they appear to be exactly the same, losing the color difference encoded in the original RGB triplets. Similarly, $[0.999, 0.000, 0.000]$ and $[1.000, 0.000, 0.000]$ will be displayed as exactly the same color, since they both are mapped to the same 8-bit RGB value $[255, 0, 0]$. Quantization errors translate to perceptual color differences, and we ideally want to design a quantization scheme that reduces the perceptual color difference.

You might be wondering, what does missing 0.001 unit of R primary mean perceptually? Is the resulting color difference tolerable? Recall from this tutorial that the amount of a primary (in terms of units) is linearly correlated with the power of the primary. So changing a pre-quantization RGB value would proportionally change the power of a primary light. Psychophysics studies show that our perceived brightness is non-linearly correlated with the light power. In bright scenes, the perceived brightness ($B$) relates to the light power/luminance ($L$) by $B \propto L^{0.5}$; this relationship becomes $B \propto L^{0.4}$ in dark scenes. The exponent here (0.4 and 0.5) is called the gamma.

Assuming $B \propto L^{0.5}$, the perceived brightness is proportional to the square root of the primary value. If you recall the shape of a square root curve, it initially increases sharply and then the growth rate drops. What this means is that increasing the R value by the same amount would lead to a much more significant increase in perceived brightness when the initial R value is lower. To be more concrete, increasing the R value from 0.100 to 0.101 increases the perceived brightness by about $0.0016k$, and increasing the R value from 0.999 to 1.000 increases the perceived brightness by just about $0.0005k$, where $k$ is a constant. This conclusion perhaps isn't all that surprising: the increase in brightness is much higher if you enter a completely dark room and turn one light on than if you turn a light on in a room with tens of lights already on. In fact, this logarithmic relationship between a physical stimulus and the subjective perception applies to not only color perception, but also other senses such as hearing, taste, etc., as described by the famous Weber–Fechner law.

What all these mean is that a linear quantization scheme introduces non-uniform perceptual color error. The color error is more significant at the dimmer end. Ideally, we would like the quantization error to be uniform across the brightness level. Another way to think about this is that we want to wisely allocate the 256 values (allowed by 8 bits) so that we evenly cover the entire brightness range (whatever range is allowed by a capturing device). A natural solution is then to encode the brightness, rather than the luminance/power, uniformly. This is equivalently to first transforming the RGB values of a color to its brightness by applying the gamma, and then mapping it uniformly to the $[0, 255]$ range with rounding. In the popular sRGB color space, the gamma is $\frac{1}{2.2} \approx 0.45$. So in the sRGB space, going from a real-valued RGB triplet $[R, G, B]$ in the $[0, 1]$ range to an integer triplet $[R_{8bit}, G_{8bit}, B_{8bit}]$ in the $[0, 255]$ range is done by:

$R_{8bit} = Rounding(R^{\frac{1}{2.2}} \times 255)$, $G_{8bit} = Rounding(G^{\frac{1}{2.2}} \times 255)$, $B_{8bit} = Rounding(B^{\frac{1}{2.2}} \times 255)$.

The real implementation of the sRGB standard usually uses a piece-wise function to replace the single gamma value in order to avoid certain numerical problems.