1 <!-- subject: Calculating {RGB↔XYZ} matrix -->
2 <!-- date: 2019-02-03 07:42:06 -->
3 <!-- update: 2021-03-21 01:21:33 -->
4 <!-- tags: rgb, srgb, colorimetry -->
5 <!-- categories: Articles, Techblog -->
8 <p>I’ve recently found myself in need of an RGB↔XYZ transformation matrix
9 expressed to the maximum possible precision. Sources on the Internet
10 typically limit the precision to a handful decimal places so I’ve performed
11 do the calculations myself.
13 <p>What we’re looking for is a
3-by-
3 matrix \(M\) which, when multiplied by
14 red, green and blue coordinates of a colour, produces its XYZ coordinates. In
16 a
<a href=
"https://www.khanacademy.org/math/linear-algebra/alternate-bases/change-of-basis/v/linear-algebra-change-of-basis-matrix">change
17 of basis matrix
</a> from a space whose basis vectors are RGB’s primary
20 $$ M = \begin{bmatrix}
31 <p>RGB primary colours are typically provided as chromaticity usually expressed
32 as
<a href=
"https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_xy_chromaticity_diagram_and_the_CIE_xyY_color_space">x
33 and y
</a> coordinates. For sRGB they are defined in
34 <a href=
"https://webstore.iec.ch/publication/6169">IEC
61966 standard
</a> (and
35 also
<a href=
"https://www.itu.int/rec/R-REC-BT.709-6-201506-I/en">Rec.
709
36 document
</a> which is
170 francs cheaper). Converting them to XYZ space is
37 simple: \(X = x Y / y\) and \(Z = (
1 - x - y) Y / y,\) but leaves luminosity
38 (the Y value) unknown.
44 <th scope=col
>\(\langle x, y\rangle\)
45 <th scope=col
>\(\langle X, Y, Z\rangle\)
49 <td>\(\langle
0.64,
0.33\rangle\)
50 <td>\(\langle {
64 \over
33} Y_r, \; Y_r, \; {
1 \over
11} Y_r\rangle\)
53 <td>\(\langle
0.30,
0.60\rangle\)
54 <td>\(\langle {
1 \over
2} Y_g, \; Y_g, \; {
1 \over
6} Y_g\rangle\)
57 <td>\(\langle
0.15,
0.06\rangle\)
58 <td>\(\langle {
5 \over
2} Y_b, \; Y_b, \; {
79 \over
6} Y_b\rangle\)
60 <th scope=row
>White (D65)
61 <td>\(\langle
0.312713,
0.329016\rangle\)
62 <td>\(\langle {
312713 \over
329016} Y_w, \; Y_w, \; {
358271 \over
329016} Y_w\rangle\)
65 <p>That’s where reference white point comes into play. Its coordinates in
66 linear RGB space, \(\langle
1,
1,
1 \rangle,\) can be plugged into the change
67 of basis formula to yield the following equation:
71 \end{bmatrix} = \begin{bmatrix}
75 \end{bmatrix} \begin{bmatrix}
79 <p>For each colour \(c\) (including white), \(X_c\) and \(Z_c\) can be expressed
80 as a product of a known quantity and \(Y_c\) (see table above). Furthermore,
81 by definition of a white point, \(Y_w =
1.\) At this point, luminosities of
82 the primary colours are the only unknowns. To isolate them, let’s define
83 \(X'_c = X_c / Y_c\) and \(Z'_c = Z_c / Y_c\) and see where that leads us:
93 \end{bmatrix} \begin{bmatrix}
95 \end{bmatrix} \\ &= \begin{bmatrix}
96 X'_r Y_r & X'_g Y_g & X'_b Y_b \\
98 Z'_r Y_r & Z'_g Y_g & Z'_b Y_b
99 \end{bmatrix} \begin{bmatrix}
101 \end{bmatrix} \\ &= \begin{bmatrix}
102 X'_r & X'_g & X'_b \\
105 \end{bmatrix} \begin{bmatrix}
109 \end{bmatrix} \begin{bmatrix}
111 \end{bmatrix} \\ &= \begin{bmatrix}
112 X'_r & X'_g & X'_b \\
115 \end{bmatrix} \begin{bmatrix}
121 \end{bmatrix} &= \begin{bmatrix}
122 X'_r & X'_g & X'_b \\
125 \end{bmatrix}^{-
1} \begin{bmatrix}
130 <p>All quantities on the right-hand side are known, therefore \([Y_r \; Y_g \;
131 Y_b]^T\) can be computed. Let’s tidy things up into a final formula.
134 <h2>Final formula
</h2>
136 <p>Given chromaticity of primary colours of an RGB space (\(\langle x_r, y_r
137 \rangle,\) \(\langle x_g, y_g \rangle\) and \(\langle x_b, y_b \rangle\)) and
138 its reference white point (\(\langle x_w, y_w \rangle\)), the matrix for
139 converting linear RGB coordinates to XYZ is:
141 $$ M = \begin{bmatrix}
142 X'_r Y_r & X'_g Y_g & X'_b Y_b \\
144 Z'_r Y_r & Z'_g Y_g & Z'_b Y_b
147 <p>which can also be written as \(M = M' \times \mathrm{diag}(Y_r, Y_g, Y_b)\)
151 & M' = \begin{bmatrix}
152 X'_r & X'_g & X'_b \\
155 \end{bmatrix}\!\!, \\ \\
160 Z'_c = (
1 - x_c - y_c) / y_c
162 \right\} \textrm{ for each colour } c, \\ \\
166 \end{bmatrix} = M'^{-
1} \begin{bmatrix}
168 \end{bmatrix} \textrm{ and} \\ \\
172 \end{bmatrix} = \begin{bmatrix}
173 x_w / y_w \\
1 \\ (
1 - x_w - y_w) / y_w
177 <p>Matrix converting coordinates in the opposite direction is the inverse of
178 \(M,\) i.e. \(M^{-
1}\).
181 <h2>Implementation
</h2>
183 <p>Having the theoretical part covered, it’s time to put the equations into
184 practice, which brings up a question of language to use. Rust programmers may
185 simply reach for
<a href=
"https://crates.io/crates/rgb_derivation">the
186 rgb_derivation crate
</a> which provides all the necessary algorithms and
187 supports rational arithmetic using big integers. However, for the sake of
188 brevity, rather than presenting the Rust implementation, we’re going to use
189 Python instead. The code begins with an overview of the algorithm:
192 Chromaticity = collections.namedtuple('Chromaticity', 'x y')
194 def calculate_rgb_matrix(primaries, white):
195 M_prime = (tuple(c.x / c.y for c in primaries),
196 tuple(
1 for _ in primaries),
197 tuple((
1 - c.x - c.y) / c.y for c in primaries))
198 W = (white.x / white.y,
1, (
1 - white.x - white.y) / white.y)
199 Y = mul_matrix_by_column(inverse_3x3_matrix(M_prime), W)
200 return mul_matrix_by_diag(M_prime, Y)
203 <p>The function first constructs \(M'\) matrix and \(W = [X_w\; Y_w\; Z_w]^T\)
204 column which are used to calculate \([Y_r\; Y_g\; Y_b]^T = M'^{-
1} W\)
205 formula. With that computed, the function returns \(M' \times
206 \mathrm{diag}(Y_r, Y_g, Y_b)\) which is the transform matrix.
208 <p>All operations on matrices are delegated to separate functions. Since the
209 matrices are small there is no need for any sophisticated algorithms.
210 Instead, the most straightforward multiplication algorithms are used:
213 def mul_matrix_by_column(matrix, column):
215 sum(row[i] * column[i] for i in range(len(row)))
218 def mul_matrix_by_diag(matrix, column):
220 tuple(row[c] * column[c] for c in range(len(column)))
224 <p>Only the function inverting a
3-by-
3 matrix is somewhat more complex:
227 def inverse_3x3_matrix(matrix):
228 def cofactor(row, col):
229 minor = [matrix[r][c]
230 for r in (
0,
1,
2) if r != row
231 for c in (
0,
1,
2) if c != col]
233 det_minor = a * d - b * c
234 return det_minor if (row ^ col) &
1 ==
0 else -det_minor
237 tuple(cofactor(row, col) for col in (
0,
1,
2))
238 for row in (
0,
1,
2))
239 det = sum(matrix[
0][col] * comatrix[
0][col] for col in (
0,
1,
2))
241 tuple(comatrix[col][row] / det for col in (
0,
1,
2))
242 for row in (
0,
1,
2))
245 <p>It first constructs
246 a
<a href=
"https://en.wikipedia.org/wiki/Minor_(linear_algebra)#First_minors">matrix
247 of cofactors
</a> of the input (i.e.
<code>comatrix
</code>). Because
248 function’s argument is always a
3-by-
3 matrix, each minor’s determinant can be
249 trivially calculated using \(\bigl|\begin{smallmatrix} a & b \\ c &
250 d \end{smallmatrix}\bigr| = a d - b c\) formula. Once the comatrix is
251 constructed,
<a href=
"https://en.wikipedia.org/wiki/Minor_(linear_algebra)#Applications_of_minors_and_cofactors">calculating
252 the determinant of the input matrix and its inverse
</a> becomes a matter
253 of executing a few loops.
255 <p>The above code works for any RGB system. To get result for sRGB its sRGB’s
256 primaries and white point chromaticities need to be passed:
259 def calculate_srgb_matrix():
260 primaries = (Chromaticity(fractions.Fraction(
64,
100),
261 fractions.Fraction(
33,
100)),
262 Chromaticity(fractions.Fraction(
30,
100),
263 fractions.Fraction(
60,
100)),
264 Chromaticity(fractions.Fraction(
15,
100),
265 fractions.Fraction(
6,
100)))
266 white = Chromaticity(fractions.Fraction(
312713,
1000000),
267 fractions.Fraction(
329016,
1000000))
268 return calculate_rgb_matrix(primaries, white)
271 <p>Full implementation with other bells and whistles can be
272 found
<a href=
"https://github.com/mina86/ansi_colours/blob/master/tools/luminance-coefficients.py">inside
273 of the
<code>ansi_colours
</code> repository
</a>.
278 <p>Finally, there’s the matrix itself. Rust programmers are in luck
279 again.
<a href=
"https://crates.io/crates/srgb">The srgb crate
</a> provides
280 <a href=
"https://docs.rs/srgb/0.1.4/srgb/xyz/constant.XYZ_FROM_SRGB_MATRIX.html">the
281 matrix
</a> along with other values and functions needed to work with sRGB
282 colour space. If the values are needed in other programming languages, they
283 can be copied from the following listing:
286 ⎡
0.4124108464885388 0.3575845678529519 0.18045380393360833 ⎤
287 M ≈ ⎢
0.21264934272065283 0.7151691357059038 0.07218152157344333 ⎥
288 ⎣
0.019331758429150258 0.11919485595098397 0.9503900340503373 ⎦
289 ⎡
33786752 /
81924984 29295110 /
81924984 14783675 /
81924984 ⎤
290 M = ⎢
8710647 /
40962492 29295110 /
40962492 2956735 /
40962492 ⎥
291 ⎣
4751262 /
245774952 29295110 /
245774952 233582065 /
245774952 ⎦
293 ⎡
3.240812398895283 -
1.5373084456298136 -
0.4985865229069666 ⎤
294 M⁻¹ ≈ ⎢ -
0.9692430170086407 1.8759663029085742 0.04155503085668564 ⎥
295 ⎣
0.055638398436112804 -
0.20400746093241362 1.0571295702861434 ⎦
296 ⎡
4277208 /
1319795 -
2028932 /
1319795 -
658032 /
1319795 ⎤
297 M⁻¹ = ⎢ -
70985202 /
73237775 137391598 /
73237775 3043398 /
73237775 ⎥
298 ⎣
164508 /
2956735 -
603196 /
2956735 3125652 /
2956735 ⎦
302 <p>It’s important to remember this is not all that is needed to perform
303 conversion between sRGB and XYZ colour spaces. The matrix assumes a linear
304 RGB coordinates normalised to [
0,
1] range which requires dealing with gamma
305 correction. I’ve written
<a href=
"/2019/srgb-xyz-conversion/">a follow-up
306 article
</a> which describes how to deal with that.
308 <p>Updated in March
2021 with more precise value for the D65 standard
309 illuminant. This affected the resulting numbers slightly.