1 <!-- subject: {sRGB↔XYZ} conversion -->
2 <!-- date: 2019-07-07 17:25:42 -->
3 <!-- update: 2021-03-21 01:21:33 -->
4 <!-- tags: rgb, srgb, colorimetry -->
5 <!-- categories: Articles, Techblog -->
8 <p>In an earlier post, I’ve shown how
9 to
<a href=
"/2019/srgb-xyz-matrix/">calculate an RGB↔XYZ conversion
10 matrix
</a>. It’s only natural to follow up with a code for converting
11 between
<a href=
"https://en.wikipedia.org/wiki/SRGB">sRGB
</a>
12 and
<a href=
"https://en.wikipedia.org/wiki/CIE_1931_color_space">XYZ
</a>
13 colour spaces. While the matrix is a significant portion of the algorithm,
14 there is one more step necessary: gamma correction.
17 <h2>What is gamma correction?
</h2>
19 <p>Human perception of light’s brightness
20 approximates
<a href=
"https://en.wikipedia.org/wiki/Stevens%27s_power_law">a power
21 function
</a> of its intensity. This can be expressed as \(P = S^\alpha\)
22 where \(P\) is the perceived brightness and \(S\) is linear intensity.
23 \(\alpha\) has been experimentally measured to be less than one which means
24 that people are more sensitive to changes to dark colours rather than to
27 <p>Based on that observation, colour space’s encoding can be made more efficient
28 by using higher precision when encoding dark colours and lower when encoding
29 bright ones. This is akin to precision of floating-point numbers scaling with
30 value’s magnitude. In RGB systems, the role of precision scaling is done
31 by
<dfn>gamma correction
</dfn>. When colour is captured (for example from
32 a digital camera) it goes through
<dfn>gamma compression
</dfn> which spaces
33 dark colours apart and packs lighter colours more densely. When displaying an
34 image, the opposite happens and encoded value goes through
<dfn>gamma
39 <figure class=fr
style=
"--w:4.5em">
40 <svg width=
"4.5em" height=
"16.5em" viewBox=
"0 0 3 11" text-anchor=end
>
41 <path d=
"M0,0 h2 v1 h-2 z" fill=
"#0f0" />
42 <path d=
"M0,1 h1 v1 h-1 z" fill=
"#00e500" />
43 <path d=
"M0,2 h1 v1 h-1 z" fill=
"#0c0" />
44 <path d=
"M0,3 h1 v1 h-1 z" fill=
"#00b200" />
45 <path d=
"M0,4 h1 v1 h-1 z" fill=
"#090" />
46 <path d=
"M0,5 h1 v1 h-1 z" fill=
"#007f00" />
47 <path d=
"M0,6 h1 v1 h-1 z" fill=
"#060" />
48 <path d=
"M0,7 h1 v1 h-1 z" fill=
"#004c00" />
49 <path d=
"M0,8 h1 v1 h-1 z" fill=
"#030" />
50 <path d=
"M0,9 h1 v1 h-1 z" fill=
"#001a00" />
51 <path d=
"M0,10 h2 v1 h-2 z" fill=
"#000" />
53 <g transform=
"translate(1)">
54 <path fill=
"#00f200" d=
"M0,1 h1 v1 h-1 z" />
55 <path fill=
"#00e800" d=
"M0,2 h1 v1 h-1 z" />
56 <path fill=
"#00d900" d=
"M0,3 h1 v1 h-1 z" />
57 <path fill=
"#0c0" d=
"M0,4 h1 v1 h-1 z" />
58 <path fill=
"#00bd00" d=
"M0,5 h1 v1 h-1 z" />
59 <path fill=
"#00ab00" d=
"M0,6 h1 v1 h-1 z" />
60 <path fill=
"#009400" d=
"M0,7 h1 v1 h-1 z" />
61 <path fill=
"#007a00" d=
"M0,8 h1 v1 h-1 z" />
62 <path fill=
"#005900" d=
"M0,9 h1 v1 h-1 z" />
65 <g transform=
"translate(3,.6)" font-size=
".4">
67 <text y=
"1" >0.9</text>
68 <text y=
"2" >0.8</text>
69 <text y=
"3" >0.7</text>
70 <text y=
"4" >0.6</text>
71 <text y=
"5" >0.5</text>
72 <text y=
"6" >0.4</text>
73 <text y=
"7" >0.3</text>
74 <text y=
"8" >0.2</text>
75 <text y=
"9" >0.1</text>
76 <text y=
"10">0.0</text>
79 <text transform=
"rotate(-90)" font-size=
".5" style=
"fill:#000"
80 ><tspan x=
"-.3" y=
".6">Encoded
</tspan
81 ><tspan x=
"-.3" y=
"1.8">Intensity
</tspan
86 <p>Many RGB systems use a simple \(S = E^\gamma\) expansion formula, where \(E\)
87 is the encoded (or non-linear) value. With decoding \(\gamma\) approximating
88 \(
1/\alpha\), equal steps in encoding space correspond roughly to equal steps
89 in perceived brightness. Image on the right demonstrates this by comparing
90 two colour gradients. The first one has been generated by increasing encoded
91 value in equal steps and the second one has been created by doing the same to
92 light intensity. The former includes many dark colours while the latter
93 contains a sudden jump in brightness from black to the next colour.
95 <p>sRGB uses slightly more complicated formula stitching together two functions:
99 12.92 × S & \text{if } S ≤ S_0 \\
100 1.055 × S^{
1/
2.4} -
0.055 & \text{otherwise}
103 {E \over
12.92} & \text{if } E ≤ E_0 \\
104 \left({E +
0.055 \over
1.055}\right)^{
2.4} & \text{otherwise}
106 S_0 &=
0.00313066844250060782371 \\
107 E_0 &=
12.92 × S_0 \\
108 &=
0.04044823627710785308233
111 <p>The formulæ assume values are normalised to [
0,
1] range. This is not
112 always how they are expressed so a scaling step might be necessary.
115 <h2>sRGB encoding
</h2>
117 <p>Most common sRGB encoding
118 uses
<a href=
"https://en.wikipedia.org/wiki/Color_depth#True_color_(24-bit)">eight
119 bits per channel
</a> which introduces a scaling step: \(E_8 = ⌊E ×
255⌉\). In
120 an actual implementation, to increase efficiency and accuracy of gamma
121 operations, it’s best to
<em>fuse
</em> the multiplication into aforementioned
122 formulæ. With that arguably obvious optimisation, the equations become:
126 ⌊
3294.6 × S⌉ & \text{if } S ≤ S_0 \\
127 ⌊
269.025 × S^{
1/
2.4} -
14.025⌉ & \text{otherwise}
130 {E_8 \over
3294.6} & \text{if } E_8 ≤
10 \\
131 \left({E_8 +
14.025 \over
269.025}\right)^{
2.4} & \text{otherwise}
133 S_0 &=
0.00313066844250060782371 \\
136 <p>This isn’t the only way to represent colours of course. For example,
10-bit
137 colour depth changes the scaling factor to
1024;
138 16-bit
<a href=
"https://en.wikipedia.org/wiki/High_color">high colour
</a> uses
139 five bits for red and blue channels while five or six for green producing
140 different scaling factors for different primaries; and HDTV caps the range to
141 [
16,
235]. Needless to say, correct formulæ need to be chosen based on the
142 standard in question.
145 <h2>The implementation
</h2>
147 <p>And that’s it. Encoding, gamma correction
148 and
<a href=
"/2019/srgb-xyz-matrix/">the conversion matrix
</a> are all the
149 necessary pieces to get the conversion implemented. Like before, Rust
150 programmers can take advantage of
<a href=
"https://crates.io/crates/srgb">the
151 srgb crate
</a> which implemented full conversion. However, to keep things
152 interesting, in addition, here’s the conversion code written in TypeScript:
155 <!-- INCLUDE ESCAPED: srgb-xyz-conversion.ts -->
160 #demo td { text-align: center; }
161 #demo input[type=number] { width:
7em; }
163 <form id=demo
style=
"display:none" novalidate
>
164 <h2>Demonstration
</h2>
166 <p>For demonstration, the form below allows converting between sRGB colours
167 and their XYZ coordinates. The table allows entry and performs automatic
168 conversion between non-linear and linear red, green and blue values.
169 Observing how they relate to each other may help visualise the effects of
176 <td colspan=
3><input type=color name=c
value=
"#FFFFFF">
177 <td><code>#RRGGBB
</code>
179 <tr><td><th scope=col
>Red
<th scope=col
>Green
<th scope=col
>Blue
<td>
181 <th scope=row
>8-bit encoded
182 <td><input type=number name=r8 value=
255>
183 <td><input type=number name=g8 value=
255>
184 <td><input type=number name=b8 value=
255>
185 <td>Non-linear in [
0,
255] range
187 <th scope=row
>Compressed
188 <td><input type=number name=r step=
0.001 value=
1.0>
189 <td><input type=number name=g step=
0.001 value=
1.0>
190 <td><input type=number name=b step=
0.001 value=
1.0>
191 <td>Non-linear in [
0,
1] range
193 <th scope=row
>Expanded
194 <td><input type=number name=lr step=
0.001 value=
1.0>
195 <td><input type=number name=lg step=
0.001 value=
1.0>
196 <td><input type=number name=lb step=
0.001 value=
1.0>
197 <td>Linear in [
0,
1] range
199 <tr><td><th scope=col
>X
<th scope=col
>Y
<th scope=col
>Z
<td>
202 <td><input type=number name=x step=
0.001 value=
0.95 >
203 <td><input type=number name=y step=
0.001 value=
1 >
204 <td><input type=number name=z step=
0.001 value=
1.089>
209 <script src=/d/srgb-xyz-conversion-demo.js
></script>
211 <p>Updated in March
2021 with more precise value for the D65 standard
212 illuminant. This affected values in
<code>xyzFromRgbMatrix
</code>
213 and
<code>rgbFromXyzMatrix
</code> matrices.