Computer Graphics · Color and Display
Deriving an RGB-to-XYZ Matrix from Primaries and a White Point
The linear algebra behind a colour space definition: why eight numbers — four chromaticities and a white — determine a 3x3 matrix, and how the solve works.
A colour space definition is short. sRGB is specified by two chromaticity pairs for each of three primaries and one pair for the white point, plus a transfer function. Rec. 2020 replaces the primaries with wider-gamut ones and keeps the same structure. From six chromaticity pairs and one transfer curve, an implementation has to produce a 3×3 matrix mapping RGB to XYZ, and it has to produce it without any spectral data. The derivation is a short piece of linear algebra that exposes exactly why the white point is a separate parameter and why a matrix built from primaries alone is wrong by a scale factor per channel.
What a primary specification actually states
A chromaticity pair $(x, y)$ fixes the direction of a tristimulus vector in XYZ, but not its length. The vector for a primary is $(X, Y, Z) = k,(x/y,\ 1,\ z/y)$ with $z = 1 - x - y$, for any positive $k$. The $x/y$ and $z/y$ ratios come from the definition of chromaticity; the factor $k$ is unconstrained by chromaticity alone. So the three primaries give three vectors whose directions are known and whose magnitudes are free: three unknown scalars.
The white point supplies the constraint. Its chromaticity fixes the direction of the white tristimulus vector, and the convention is that RGB = (1,1,1) maps to a white of a specified luminance. Two conventions exist: either $Y = 1$ for white, so the matrix’s rows sum such that full white has unit luminance, or $Y = 100$ in the older conventions where luminance is expressed in cd/m² scaled by 100. Either way, white is a fully specified XYZ triple, which gives three equations in the three unknown scalars, and a 3×3 solve closes the system.
Because a chromaticity triple only fixes direction, the matrix is entirely determined by the chromaticities plus the luminance normalization of white. This is why two colour spaces can share primaries and differ only in white point, and why the matrices differ in every entry rather than in one row: changing white rescales all three primaries.
The solve, explicitly
Let the primary chromaticities be $(x_r, y_r)$, $(x_g, y_g)$, $(x_b, y_b)$ and the white be $(x_w, y_w)$. Construct the matrix of unnormalized primary vectors:
$$P = \begin{bmatrix} x_r/y_r & x_g/y_g & x_b/y_b \ 1 & 1 & 1 \ z_r/y_r & z_g/y_g & z_b/y_b \end{bmatrix}$$
with $z_i = 1 - x_i - y_i$. Each column is a primary direction, scaled so its $Y$ component is 1. The white vector is $\mathbf{w} = (x_w/y_w,\ 1,\ z_w/y_w)$ when white luminance is normalized to 1. The scale factors are the solution of
$$P,\mathbf{s} = \mathbf{w}$$
so $\mathbf{s} = P^{-1}\mathbf{w}$, and the final matrix is $M = P \operatorname{diag}(s_r, s_g, s_b)$. The columns of $M$ are the XYZ values of the three primaries at full intensity, and $M (1,1,1)^T = \mathbf{w}$ by construction.
The inverse always exists for a physically sensible primary set: the three directions are linearly independent unless the primaries are collinear in chromaticity, which would mean a degenerate gamut with zero area. Implementations rarely need the general inverse; the 3×3 system is small enough that Cramer’s rule with explicit cofactors is a few dozen floating-point operations, and that is how most colour libraries implement it.
/* Build RGB->XYZ from primary chromaticities and a white point.
Returns 0 if the primaries are degenerate (collinear in chromaticity). */
int rgb_to_xyz_matrix(const double prim[3][2], const double white[2],
double M[3][3]) {
double P[3][3], s[3], det;
for (int i = 0; i < 3; i++) {
double z = 1.0 - prim[i][0] - prim[i][1];
P[0][i] = prim[i][0] / prim[i][1];
P[1][i] = 1.0;
P[2][i] = z / prim[i][1];
}
double zw = 1.0 - white[0] - white[1];
double w[3] = { white[0] / white[1], 1.0, zw / white[1] };
det = P[0][0] * (P[1][1] * P[2][2] - P[1][2] * P[2][1])
- P[0][1] * (P[1][0] * P[2][2] - P[1][2] * P[2][0])
+ P[0][2] * (P[1][0] * P[2][1] - P[1][1] * P[2][0]);
if (det == 0.0) return 0;
/* Cramer: replace column i with w, divide by det. */
for (int i = 0; i < 3; i++) {
double Q[3][3];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
Q[r][c] = (c == i) ? w[r] : P[r][c];
s[i] = (Q[0][0] * (Q[1][1] * Q[2][2] - Q[1][2] * Q[2][1])
- Q[0][1] * (Q[1][0] * Q[2][2] - Q[1][2] * Q[2][0])
+ Q[0][2] * (Q[1][0] * Q[2][1] - Q[1][1] * Q[2][0])) / det;
for (int r = 0; r < 3; r++)
M[r][i] = P[r][i] * s[i];
}
return 1;
}
The degenerate check is not theoretical: a set of primaries with one equal to a mixture of the other two has a zero determinant, and the code would divide by zero and produce infinities that propagate into every subsequent conversion without any diagnostic.
Why the white point cannot be folded into the primaries
A frequent error is to build the matrix from the primaries with each column normalized to unit $Y$ and to treat the resulting white as whatever it happens to be. For sRGB’s primaries this gives a white that is bluish, close to D65 but not exactly it, and the error appears as a colour cast on neutral content that is easy to mistake for a transfer function problem. The cast is not a constant offset in RGB: it is a per-channel gain, because the mismatch in white is absorbed as different scalings of red, green, and blue. Neutral greys stay on the neutral axis only if the white point constraint is satisfied.
The white point also determines what “neutral” means for a whole pipeline. Two images of the same scene rendered against different white points differ in the colour of every neutral surface, and adaptation — the transformation that maps one white to another — is a separate operation applied after conversion, not part of the matrix build. The chromatic adaptation transform itself, typically a von Kries-style scaling in a cone-like space such as Bradford, is a second matrix multiplied on the left; the two operations compose and are often pre-multiplied into a single matrix for efficiency, which hides the structure and makes debugging harder when the result is slightly off.
Checking the result
Three invariants catch nearly every mistake.
White maps to white. Feeding (1,1,1) through the matrix must produce the white tristimulus vector to within round-off. If it produces something with a different chromaticity, the scale solve is wrong.
Primaries map to the specified chromaticities. The matrix’s first column is the XYZ of pure red; its chromaticity must equal the specified $(x_r, y_r)$. This catches an accidental column permutation, which is a common bug because the ordering of channels in memory and the ordering in a specification do not always agree.
Grey stays grey. The row that produces $Y$ must have non-negative coefficients and must be the luminance row: no channel contributes negative luminance, and green must contribute the largest share for any plausible set of primaries, because photopic sensitivity peaks in the green. A matrix with a negative green coefficient in the luminance row has a reversed channel order.
| Space | Red primary (x, y) | Green primary (x, y) | Blue primary (x, y) | White |
|---|---|---|---|---|
| sRGB / Rec. 709 | 0.640, 0.330 | 0.300, 0.600 | 0.150, 0.060 | D65 |
| Rec. 2020 | 0.708, 0.292 | 0.170, 0.797 | 0.131, 0.046 | D65 |
| Display P3 | 0.680, 0.320 | 0.265, 0.690 | 0.150, 0.060 | D65 |
The numbers in the table are the published chromaticities and are what a derivation consumes. Rec. 2020’s primaries lie close to monochromatic wavelengths at the red and green corners, which is why the matrix entries are large and why converting narrow-gamut content into Rec. 2020 produces values that are mostly small in the red and blue channels — the space is wide, so ordinary colours occupy a modest fraction of it.
Conditioning and precision
The matrices for wide-gamut spaces are ill-conditioned relative to sRGB’s because the primary directions are closer to parallel in chromaticity terms once the region is large. Inverting them amplifies the relative error of the input chromaticities: an error of one part in the fourth decimal place in a chromaticity, which is the precision at which specifications are written, produces matrix entries that differ in the third or fourth significant figure. That is acceptable for rendering and not acceptable for colour tolerance measurement, which is why tolerance work uses measured spectral data rather than the published chromaticity rounding.
Double precision is worth using for the build step even when the rendering pipeline is single precision, because the scale solve involves a determinant that can be small. Building the matrix once at startup and storing it as float is the standard compromise: the derivation is accurate, and the per-pixel cost stays low.
The conversion direction matters too. Applying the inverse matrix to XYZ values outside the RGB gamut produces negative channels, and clamping them shifts the colour toward the gamut boundary along a path determined by which channels clipped. This is a gamut problem rather than a matrix problem, treated in gamut mapping and rendering intents, and the intermediate representation that makes it tractable is covered in converting through XYZ.
References
- Recommendation ITU-R BT.709: Parameter values for the HDTV standards for production and international programme exchange — ITU-R
- Recommendation ITU-R BT.2020: Parameter values for ultra-high definition television systems — ITU-R
- A Standard Default Color Space for the Internet: sRGB — W3C