1
- # Detailed analysis of APCA (2022-07-16 )
1
+ # Detailed analysis of APCA (2022-08-05 )
2
2
3
3
I am a regular web developer with a bachelor's degree in math, but without any
4
4
training in the science around visual perception. That's why I cannot evaluate
@@ -53,15 +53,15 @@ correct 100% of the time.
53
53
### A naive approach
54
54
55
55
``` js
56
- function sRGBtoY (srgb ) {
56
+ function sRGBtoL (srgb ) {
57
57
return (srgb[0 ] + srgb[1 ] + srgb[2 ]) / 3 ;
58
58
}
59
59
60
60
function contrast (fg , bg ) {
61
- var yfg = sRGBtoY (fg);
62
- var ybg = sRGBtoY (bg);
61
+ var lfg = sRGBtoL (fg);
62
+ var lbg = sRGBtoL (bg);
63
63
64
- return ybg - yfg ;
64
+ return lbg - lfg ;
65
65
};
66
66
```
67
67
@@ -71,6 +71,28 @@ features the basic structure: We first transform each color to a value that
71
71
represents lightness. Then we calculate a difference between the two lightness
72
72
values.
73
73
74
+ ### Historical context
75
+
76
+ Lightness (L) is a measure for the perceived amount of light. Luminance (Y) is
77
+ a measure for the physical amount of light. In order to understand perceived
78
+ contrast, we first have to understand the relationship between luminance and
79
+ lightness.
80
+
81
+ In the nineteenth century, E. H. Weber found that human perception works in
82
+ relative terms, i.e. the difference between 100 g and 110 g is perceived the
83
+ same as the difference between 1000 g and 1100 g. Applied to vision this means
84
+ that a contrast between two color pairs is perceived the same if `(Y1 - Y2) /
85
+ Y2` has the same value. This is known as Weber contrast and has been called the
86
+ [ "gold standard" for text contrast] .
87
+
88
+ Fechner concluded that the relation between a physical measure ` Y ` and a
89
+ perceived measure ` L ` can be expressed as ` L = a * log(Y) + b ` . This is called
90
+ the Weber-Fechner law.
91
+
92
+ In 1961 Stevens published a different model that was found to more accurately
93
+ predict human vision. It has the form ` L = a * pow(Y, alpha) + b ` . The exponent
94
+ ` alpha ` has a value of approximately 1/3.
95
+
74
96
### WCAG 2.x
75
97
76
98
``` js
@@ -99,24 +121,25 @@ function contrast(fg, bg) {
99
121
};
100
122
```
101
123
102
- In WCAG 2.x we see the same general structure, but the individual steps are
103
- more complicated:
124
+ In WCAG 2.x we see the same general structure as in the naive approach, but the
125
+ individual steps are more complicated:
104
126
105
127
Colors on the web are defined in the [ sRGB color space] . The first part of this
106
- formula is the official formula to convert a sRGB color to luminance. Luminance
107
- is a measure for the amount of light emitted from the screen. Doubling sRGB
108
- values (e.g. from ` #444 ` to ` #888 ` ) does not actually double the physical
128
+ formula is the official formula to convert a sRGB color to luminance. Doubling
129
+ sRGB values (e.g. from ` #444 ` to ` #888 ` ) does not actually double the physical
109
130
amount of light, so the first step is a non-linear "gamma decoding". Then the
110
131
red, green, and blue channels are weighted to sum to the final luminance. The
111
132
weights result from different sensitivities in the human eye: Yellow light has
112
133
a much bigger response than the same amount of blue light.
113
134
114
- Next the [ Weber contrast] of those two luminances is calculated. Weber contrast
115
- has been called the [ "gold standard" for text contrast] . It is usually defined
116
- as ` (yfg - ybg) / ybg ` which is the same as ` yfg / ybg - 1 ` . In this case, 0.05
117
- is added to both values to account for ambient light. The shift by 1 is removed
118
- because it has no impact on the results (as long as the thresholds are adapted
119
- accordingly).
135
+ Next, 0.05 is added to both values to account for ambient light that is
136
+ reflected on the screen (flare). Since we are in the domain of physical light,
137
+ we can just add these values. 0.05 mean that we assume that the flare amounts
138
+ to 5% of the white of the screen.
139
+
140
+ Then the Weber contrast is calculated. Note that ` (Y1 - Y2) / Y2 ` is the same
141
+ as ` Y1 / Y2 - 1 ` . The shift by 1 is removed because it has no impact on the
142
+ results (as long as the thresholds are adapted accordingly).
120
143
121
144
Finally, the polarity is removed so that the formula has the same results when
122
145
the two colors are switched.
@@ -162,29 +185,80 @@ function contrast(fg, bg) {
162
185
};
163
186
```
164
187
165
- Again we can see the same structure: We first convert colors to lightness, then
166
- calculate the difference between them. However, in order to be able to compare
167
- APCA to WCAG 2.x, I will make some modifications:
188
+ The conversion from sRGB to luminance uses similar coefficients, but the
189
+ non-linear part is very different. The author of APCA provides some motivation
190
+ for these changes in the article [ Regarding APCA Exponents] . The main argument
191
+ seems to be that this is supposed to more closely model real-world computer
192
+ screens. This document also explains that this step incorporates flare.
168
193
169
- - The final steps do some scaling and shifting that only serves to get nice
170
- threshold values. Just like the shift by 1 in the WCAG formula, this can
171
- simply be ignored .
194
+ Next, the contrast is calculated based on the Stevens model. Interestingly,
195
+ APCA uses four different exponents for light foreground (0.62), dark foreground
196
+ (0.57), light background (0.56), and dark background (0.65) .
172
197
173
- - I will also ignore the ` < 0.1 ` condition because it only affects contrasts
174
- that are too low to be interesting anyway.
198
+ The final steps do some scaling and shifting that only serves to get nice
199
+ threshold values. Just like the shift by 1 in the WCAG formula, this does not
200
+ effect results as long as the thresholds are adapted accordingly. Note that the
201
+ ` < 0.1 ` condition only affects contrasts that are below the lowest threshold
202
+ anyway.
175
203
176
- - The contrast is calculated as a difference, not as a ratio as in WCAG. I
177
- will look at the ` exp() ` of that difference. Since
178
- ` exp(a - b) == exp(a) / exp(b) ` , this allows us to convert the APCA formula
179
- from a difference to a ratio. Again I user the same trick: Since ` exp() ` is
180
- monotonic, it does not change the results other than moving the
181
- thresholds .
204
+ This formula is based on the more modern Stevens model, but also has some
205
+ unusual parts. The non-standard ` sRGBtoY ` is hard to evaluate without further
206
+ information on how it was derived. All of the exponents are significantly
207
+ higher than the common 1/3. Analysis is also complicated by the fact that the
208
+ three levels of exponents (gamma, alpha, different exponents for light/dark
209
+ foreground/background) are not clearly separated .
182
210
183
- With those changes. All other differences between APCA and WCAG 2.x can be
184
- pushed into ` sRGBtoY() ` :
211
+ ### Normalization
212
+
213
+ To make it easier to compare the two formulas, I will normalize them:
214
+
215
+ - clearly seperate the individual steps of the calculation
216
+ - calculate a difference of lightnesses
217
+ - preserve polarity
218
+ - scale to a range of -1 to 1
219
+
220
+ WCAG 2.x therefore becomes:
185
221
186
222
``` js
187
- function sRGBtoY_modified (srgb , exponent ) {
223
+ function gamma (x ) {
224
+ if (x < 0.04045 ) {
225
+ return x / 12.92 ;
226
+ } else {
227
+ return Math .pow ((x + 0.055 ) / 1.055 , 2.4 );
228
+ }
229
+ }
230
+
231
+ function sRGBtoY (srgb ) {
232
+ var r = gamma (srgb[0 ] / 255 );
233
+ var g = gamma (srgb[1 ] / 255 );
234
+ var b = gamma (srgb[2 ] / 255 );
235
+
236
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
237
+ }
238
+
239
+ function YtoL (y ) {
240
+ return (Math .log (y + 0.05 ) - Math .log (0.05 )) / Math .log (21 );
241
+ }
242
+
243
+ function contrast (fg , bg ) {
244
+ var yfg = sRGBtoY (fg);
245
+ var ybg = sRGBtoY (bg);
246
+
247
+ var lfg = YtoL (yfg);
248
+ var lbg = YtoL (ybg);
249
+
250
+ return lbg - lfg;
251
+ };
252
+
253
+ function normalize (c ) {
254
+ return Math .log (c) / Math .log (21 );
255
+ }
256
+ ```
257
+
258
+ APCA becomes:
259
+
260
+ ``` js
261
+ function sRGBtoY (srgb ) {
188
262
var r = Math .pow (srgb[0 ] / 255 , 2.4 );
189
263
var g = Math .pow (srgb[1 ] / 255 , 2.4 );
190
264
var b = Math .pow (srgb[2 ] / 255 , 2.4 );
@@ -193,89 +267,75 @@ function sRGBtoY_modified(srgb, exponent) {
193
267
if (y < 0.022 ) {
194
268
y += Math .pow (0.022 - y, 1.414 );
195
269
}
196
- return Math . exp ( Math . pow (y, exponent)) ;
270
+ return y ;
197
271
}
198
- ```
199
-
200
- An interesting feature of APCA is that it uses four different exponents for
201
- light foreground (0.62), dark foreground (0.57), light background (0.56), and
202
- dark background (0.65). ` sRGBtoY_modified() ` takes that exponent as a second
203
- parameter.
204
-
205
- Now that we have aligned the two formulas, what are the actual differences?
206
-
207
- This conversion again uses sRGB coefficients. However, the non-linear part is
208
- very different. The author of APCA provides some motivation for these changes
209
- in the article [ Regarding APCA Exponents] . The main argument seems to be that
210
- this more closely models real-world computer screens.
211
272
212
- To get a better feeling for how these formulas compare, I plotted the results
213
- of ` sRGBtoY() ` . In order to reduce colors to a single dimension, I used gray
214
- ` [x, x, x] ` , red ` [x, 0, 0] ` , green ` [0, x, 0] ` and blue ` [0, 0, x] ` values.
215
-
216
- I also normalized the values so they are in the same range as WCAG 2.x. I used
217
- factors (because they do not change the contrast ratio) and powers (because
218
- they are monotonic on the contrast ratio).
273
+ function YtoL (y ) {
274
+ return Math .pow (y, 0.6 );
275
+ }
219
276
220
- ``` js
221
- var average_exponent = 0.6 ;
222
- var y0 = Math .exp (Math .pow (0.022 , 1.414 * average_exponent));
223
- var y1 = Math .exp (1 );
277
+ function contrast (fg , bg ) {
278
+ var yfg = sRGBtoY (fg);
279
+ var ybg = sRGBtoY (bg);
224
280
225
- function normalize (y ) {
226
- // scale the lower end to 1
227
- y /= y0;
281
+ var lfg = YtoL (yfg);
282
+ var lbg = YtoL (ybg);
228
283
229
- // scale the upper end to 21
230
- // we use a power so the lower end stays at 1
231
- y = Math .pow (y, Math .log (21 ) / Math .log (y1 / y0));
284
+ if (ybg > yfg) {
285
+ return Math .pow (lbg, 0.56 / 0.6 ) - Math .pow (yfg, 0.57 / 0.6 );
286
+ } else {
287
+ return Math .pow (ybg, 0.65 / 0.6 ) - Math .pow (yfg, 0.62 / 0.6 );
288
+ }
289
+ };
232
290
233
- // scale down to the desired range
234
- return y / 20 ;
291
+ function normalize ( c ) {
292
+ return (c / 100 + 0.027 ) / 1.14 ;
235
293
}
236
294
```
237
295
238
- ![ sRGBtoY comparison ] ( plots/sRGBtoY_comparison.png )
296
+ ### Comparison
239
297
240
- The four curves for APCA are very similar. Despite the very different formula,
241
- the WCAG 2.x curve also has a similar shape. I added a modified WCAG 2.x curve
242
- with an ambient light value of 0.4 instead of 0.05. This one is very similar
243
- to the APCA curves. The second column shows the differences between the APCA
244
- curves and this modified WCAG 2.x. 0.4 was just a guess, there might be even
245
- better values.
246
-
247
- I also wanted to see how the contrast results compare. I took a random sample
248
- of color pairs and computed the normalized APCA contrast, WCAG 2.x contrast
249
- (without removing the polarity) and the modified WCAG contrast with an ambient
250
- light value of 0.4.
298
+ Now that we have aligned the two formulas, what are the actual differences?
251
299
252
300
![ contrast comparison] ( plots/contrast_comparison.png )
253
301
254
- In the top row we see two scatter plots that compare APCA to both WCAG
255
- variants. As we can see, they correlate in both cases, but the modified WCAG
256
- 2.x contrast is much closer.
302
+ These are scatter plots based on a random sample of color pairs. The x-axis
303
+ corresponds to background luminance, the y-axis corresponds to foreground
304
+ luminance (both using the APCA formula). The color of the dots indicated the
305
+ differences between the respective formulas.
257
306
258
- In the bottom row we see two more scatter plots. This time the X axis
259
- corresponds to foreground luminance and the Y axis corresponds to background
260
- luminance. The color of the dots indicated the differences between the
261
- respective formulas, calculated as ` log(apca / wcag) ` . As we can see, the
307
+ The plot on the bottom right compares the APCA to WCAG 2.x. As we can see, the
262
308
biggest differences between APCA and WCAG 2.x are in areas where one color is
263
309
extremely light or extremely dark. For light colors, APCA predicts an even
264
310
higher contrast (difference is in the same direction as contrast polarity). For
265
311
dark colors, APCA predicts a lower contrast (difference is inverse to contrast
266
- polarity).
312
+ polarity). The difference goes up to 20%.
313
+
314
+ The other three plots compare APCA to a modified version of APCA where one of
315
+ the steps has been replaced by the corresponding step from WCAG 2.x. This way
316
+ we can see that ` sRGBtoY ` contributes 4% to the difference, ` YtoL ` contributes
317
+ 15%, and ` contrast ` contributes 3%.
318
+
319
+ Since the conversion from luminance to lightness causes the biggest difference,
320
+ I took a closer look at it.
321
+
322
+ ![ lightness comparison] ( plots/lightness_comparison.png )
323
+
324
+ I plotted curves for both the Weber-Fechner model (log) and the Stevens model
325
+ (pow) with different parameters.
267
326
268
- To sum up, the APCA contrast formula is certainly not as obvious a choice as
269
- the one from WCAG 2.x. I was not able to find much information on how it was
270
- derived. A closer analysis reveals that it is actually not that different from
271
- WCAG 2.x, but assumes much more ambient light. More research is needed to
272
- determine if this higher ambient light value is significant or just an
273
- artifact of the conversion I did .
327
+ - The log curve with a flare of 0.05 (WCAG 2) is closer to the pow curve with
328
+ an exponent of 1/3
329
+ - The log curve with a flare of 0.4 is closer to the pow curves with
330
+ exponents 0.56 and 0.68 (similar to APCA)
331
+ - The pow curve with an exponent of 1/3 ** and ** a flare of 0.025 is somewhere
332
+ in the middle .
274
333
275
- As we have seen, using a polarity-aware difference instead of a ratio is not a
276
- significant change in terms of results. However, in terms of developer
277
- ergonomics, I personally feel like it is easier to work with. So I would be
278
- happy if this idea sticks.
334
+ This shows that a big part of the different results between WCAG 2.x and APCA
335
+ are caused by a different choice in parameters. If we were to change the flare
336
+ value in WCAG 2.x to 0.4 we would get results much closer to APCA. And if we
337
+ were to change the exponents in APCA to 1/3 we would get results much closer to
338
+ WCAG 2.x.
279
339
280
340
## Spatial frequency
281
341
@@ -406,8 +466,7 @@ Again I generated random color pairs and used them to compare APCA to WCAG 2.x:
406
466
407
467
The columns correspond to APCA thresholds, the rows correspond to WCAG 2.x
408
468
thresholds. For example, 6.2 % of the generated color pairs pass WCAG 2.x with
409
- a contrast above 3, but fail APCA with a contrast below 45 (assuming a
410
- conventional spatial frequency).
469
+ a contrast above 3, but fail APCA with a contrast below 45.
411
470
412
471
The \* indicate cases where both a algorithms agree on a threshold level. The
413
472
cell in the bottom right is the total number of cases where both algorithms
@@ -424,11 +483,10 @@ agree, so it can be seen as an indicator of how similar the algorithms are.
424
483
| > 13.2 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.1\* | 0.2 |
425
484
| total | 35.3 | 25.8 | 18.3 | 12.3 | 6.4 | 1.8 | 0.1 | 91.0\* |
426
485
427
- The second table compares APCA to the modified WCAG 2.x contrast. The
428
- thresholds were derived by applying the normalization steps described above to
429
- the APCA thresholds. As expected, most color pairs fall into the same category
430
- with both formulas. For example, only 1.7 % pass the modified WCAG 2.x with a
431
- contrast above 3.8, but fail APCA with a contrast below 45.
486
+ The second table compares APCA to a modified WCAG 2.x contrast with a flare
487
+ value of 0.4. The thresholds were derived by applying the normalization steps
488
+ described above to the APCA thresholds. As expected, the difference is reduced
489
+ significantly, though there is still a considerable difference left.
432
490
433
491
| | < 15 | 15-30 | 30-45 | 45-60 | 60-75 | 75-90 | > 90 | total |
434
492
| -------:| -------:| -------:| -------:| -------:| -------:| -------:| -------:| -------:|
@@ -459,15 +517,19 @@ algorithm in many key aspects:
459
517
460
518
- It uses a different luminance calculation that deviates from the standards
461
519
but is supposed to be closer to real world usage.
462
- - It uses a different way of calculating a contrast from luminances.
520
+ - It uses a more accurate model and significantly different parameters for
521
+ converting luminance to perceptual lightness.
522
+ - It adds an additional step where different exponents are applied to
523
+ foreground and background.
463
524
- It uses different scaling. Crucially, this scaling is based on a difference
464
525
rather than a ratio.
465
526
- It uses a more sophisticated link between spatial frequency and minimum
466
527
color contrast that might allow for more nuanced thresholds.
467
528
468
- The new contrast formula agrees with WCAG 2.x for 83.5% of color pairs. That
469
- number rises to 91% for a modified WCAG 2.x formula with an ambient light value
470
- of 0.4. This could indicate that APCA assumes more ambient light. It would also
529
+ The new contrast formula agrees with WCAG 2.x for 83.5% of randomly picked
530
+ color pairs. That number rises to 91% for a modified WCAG 2.x formula with a
531
+ flare value of 0.4. As far as I understand, this is not a realistic value for
532
+ flare. So the physical interpretation might be incorrect. This would however
471
533
explain why APCA reports lower contrast for darker colors.
472
534
473
535
So far I like many of the ideas of APCA, but I am concerned by the [ lack of
@@ -478,7 +540,6 @@ figuring out what questions need to be answered.
478
540
479
541
[ Web Content Accessibility Guidelines ] : https://www.w3.org/TR/WCAG21/
480
542
[ sRGB color space ] : https://en.wikipedia.org/wiki/SRGB
481
- [ Weber contrast ] : https://en.wikipedia.org/wiki/Weber_contrast
482
543
[ "gold standard" for text contrast ] : https://github.com/w3c/wcag/issues/695#issuecomment-483805436
483
544
[ Regarding APCA Exponents ] : https://git.apcacontrast.com/documentation/regardingexponents
484
545
[ Studies have shown ] : https://en.wikipedia.org/wiki/Contrast_(vision)#Contrast_sensitivity_and_visual_acuity
0 commit comments