using Dates
using Printf
using Random
using Colors
using Evolutionary
using Luxor
# Seed RNG for reproducibility
= Xoshiro(20231112); my_rng
Tweaking Color Schemes for Accessibility with Genetic Algorithms
Time to show some true colors
If you’re a fan of customizing Linux setups as much as I am, you’ve probably noticed that my website’s color palette is based on Gruvbox. It’s a popular terminal color scheme that I like for its retro aesthetic and unified palette for six different related color schemes: two “modes” (dark & light) and three background types (soft, medium & hard). However, Gruvbox’s color palettes do not provide enough contrast between foreground and background colors for people with low vision to read easily. So, let’s learn why this is and use a genetic algorithm to design a drop-in alternative accessible color scheme for my website.
Color Spaces
Red, Green, and Blue (RGB) are the additive primaries of light; Different combinations of these color sets allow us to trick one’s brain into seeing other colors instead of a combination of primaries.1 Hence, this is why hexadecimal colors used for displays are represented as a hash followed by three 8-bit numbers from 0 to 255 that respectively correspond to the intensity of red, green, and blue channels (e.g., #c0ffee
corresponds to 192 for red, 255 for green, and 238 for blue to mix into a cyan-like color). When the ranges of [0, 255] for each red, green, and blue channel are normalized to [0, 1] (through dividing by 255), we can obtain the hexadecimal color’s coordinate in the sRGB color space.2 Color spaces define specific ways in which colors can be reproduced, either through an index of colors or a coordinate system.
For example, let’s convert #c0ffee
to its corresponding point in the sRGB color space.
= parse(RGB, "#c0ffee") # (192, 255, 238)
c0ffee println(c0ffee)
c0ffee
RGB{N0f8}(0.753,1.0,0.933)
The sRGB colorspace is neither the first nor the only color space; For example, the CIE XYZ color space was designed by the International Commision on Illumination (CIE)3. This color space is based on how colors are perceived and designed such that the relative luminance (perceived brightness) of a color corresponds to the Y-dimension.4
Calculating Color Contrasts with WCAG 2.2
What conditions make a website accessible for all users? As of writing this article, the Web Content Accessibility Guidelines (WCAG), managed by the World Wide Web Consortium (W3C), is the de facto set of rules for designing websites with accessibility in mind. Its most recent version (as of writing this article), WCAG 2.2, provides equations for calculating the contrast ratio of two colors regardless of which is foreground and which is background based on the two colors’ relative luminances. A color’s relative luminance (which corresponds to the Y-component in the CIE XYZ color space) is calculated first by linearizing the R, G, and B components of the sRGB color. The luminance is a linear transformation on the linearized sRGB components,5 which correspond to the linear transformation that converts from the linearized sRGB color space to the CIE XYZ color space.6
\[ C_{\text{linear}} = \begin{cases} \frac{C_{\text{sRGB}}}{12.92} & \text{if } C_{\text{sRGB}} \leq 0.04045 \\ \left(\frac{C_{\text{sRGB}} + 0.055}{1.055}\right)^{2.4} & \text{if } C_{\text{sRGB}} > 0.04045 \end{cases} \quad \text{where } C \text{ is one of } \{R, G, B\} \]
\[ Y = 0.2126 R_{\text{linear}} + 0.7152 G_{\text{linear}} + 0.0722 B_{\text{linear}} \]
"Calculates the relative luminance of an sRGB color according to IEC 61966-2-1:1999."
function relative_luminance(color::RGB)
= [
R_linear, G_linear, B_linear ifelse(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055)^2.4) for
in [color.r, color.g, color.b]
c
]return 0.2126 * R_linear + 0.7152 * G_linear + 0.0722 * B_linear
end
To calculate the contrast between two colors (according to the WCAG), we take their two luminances and apply the following formula:
\[ \text{contrast ratio} = \frac{\max(Y_{1}, Y_{2}) + 0.05}{\min(Y_{1}, Y_{2}) + 0.05} \]
"Calculates the contrast ratio of two sRGB colors according to WCAG 2.2."
function contrast_ratio(color1::RGB, color2::RGB)
= minmax(relative_luminance(color1), relative_luminance(color2))
Ymin, Ymax return (Ymax + 0.05) / (Ymin + 0.05)
end
The WCAG recommends a contrast ratio of 4.5 for text and images of text and a contrast ratio of 3 for larger text.7
This method of calculating color contrasts has been considered too simple and inaccurate in some cases. It will be replaced in WCAG 3.0 with the Accessible Perceptual Contrast Algorithm (APCA).8
Sizing up Gruvbox and its WCAG Contrast Ratios
Let’s define the Gruvbox palette as a dictionary of colors. This definition is pretty large, so I’ve folded it here.
Gruvbox Palette Definition
= Dict(
gruvbox "dark0_hard" => parse(RGB, "#1d2021"),
"dark0" => parse(RGB, "#282828"),
"dark0_soft" => parse(RGB, "#32302f"),
"dark1" => parse(RGB, "#3c3836"),
"dark2" => parse(RGB, "#504945"),
"dark3" => parse(RGB, "#665c54"),
"dark4" => parse(RGB, "#7c6f64"),
"gray" => parse(RGB, "#928374"),
"light0_hard" => parse(RGB, "#f9f5d7"),
"light0" => parse(RGB, "#fbf1c7"),
"light0_soft" => parse(RGB, "#f2e5bc"),
"light1" => parse(RGB, "#ebdbb2"),
"light2" => parse(RGB, "#d5c4a1"),
"light3" => parse(RGB, "#bdae93"),
"light4" => parse(RGB, "#a89984"),
"bright_red" => parse(RGB, "#fb4934"),
"bright_green" => parse(RGB, "#b8bb26"),
"bright_yellow" => parse(RGB, "#fabd2f"),
"bright_blue" => parse(RGB, "#83a598"),
"bright_purple" => parse(RGB, "#d3869b"),
"bright_aqua" => parse(RGB, "#8ec07c"),
"bright_orange" => parse(RGB, "#fe8019"),
"neutral_red" => parse(RGB, "#cc241d"),
"neutral_green" => parse(RGB, "#98971a"),
"neutral_yellow" => parse(RGB, "#d79921"),
"neutral_blue" => parse(RGB, "#458588"),
"neutral_purple" => parse(RGB, "#b16286"),
"neutral_aqua" => parse(RGB, "#689d6a"),
"neutral_orange" => parse(RGB, "#d65d0e"),
"faded_red" => parse(RGB, "#9d0006"),
"faded_green" => parse(RGB, "#79740e"),
"faded_yellow" => parse(RGB, "#b57614"),
"faded_blue" => parse(RGB, "#076678"),
"faded_purple" => parse(RGB, "#8f3f71"),
"faded_aqua" => parse(RGB, "#427b58"),
"faded_orange" => parse(RGB, "#af3a03"),
)
= ["dark0_hard", "dark0", "dark0_soft"]
dark_bgs = "light1"
dark_fg = ["light0_hard", "light0", "light0_soft"]
light_bgs = "dark1"
light_fg
= [
bright_colors "bright_red",
"bright_green",
"bright_yellow",
"bright_blue",
"bright_purple",
"bright_aqua",
"bright_orange",
]
= [
neutral_colors "neutral_red",
"neutral_green",
"neutral_yellow",
"neutral_blue",
"neutral_purple",
"neutral_aqua",
"neutral_orange",
]
= [
faded_colors "faded_red",
"faded_green",
"faded_yellow",
"faded_blue",
"faded_purple",
"faded_aqua",
"faded_orange",
]
Gruvbox’s palette can be split into the following categories:
- The dark mode backgrounds
dark0_hard
,dark0
, anddark0_soft
- The light mode backgrounds
light0_hard
,light0
, andlight0_soft
- The dark mode foreground
light1
- The light mode foreground
dark1
- The
bright
colors, used exclusively in dark mode - The
faded
colors, used exclusively in light mode - The
neutral
colors, used in both dark & light modes - The intermediate dark & light colors
dark2
throughdark4
,gray
,light4
throughlight2
Let’s create a function that visualizes these contrasts, then run it for our dark mode and light mode palettes.
function palette_contrasts(backgrounds, foregrounds, pic_name)
= 600
pic_length = 600
pic_width = pic_width / (length(backgrounds) + 1)
width_div = pic_length / (length(foregrounds) + 1)
length_div Drawing(pic_width, pic_length, pic_name)
fontsize(18)
for (r, fg) in enumerate(foregrounds)
setcolor(gruvbox[fg])
rect(0, r * length_div, width_div, length_div; action=:fill)
if contrast_ratio(gruvbox[fg], parse(RGB, "#ffffff")) >
contrast_ratio(gruvbox[fg], parse(RGB, "#000000"))
setcolor("#ffffff")
else
setcolor("#000000")
end
text(
fg,Point(0.5 * width_div, (r + 0.5) * length_div);
=:center,
halign=:middle,
valign
)end
for (c, bg) in enumerate(backgrounds)
setcolor(gruvbox[bg])
rect(c * width_div, 0, width_div, pic_length; action=:fill)
if contrast_ratio(gruvbox[bg], parse(RGB, "#ffffff")) >
contrast_ratio(gruvbox[bg], parse(RGB, "#000000"))
setcolor("#ffffff")
else
setcolor("#000000")
end
text(bg, (c + 0.5) * width_div, 0.5 * length_div; halign=:center, valign=:middle)
for (r, fg) in enumerate(foregrounds)
setcolor(gruvbox[fg])
text(
@sprintf("%.2f", contrast_ratio(gruvbox[bg], gruvbox[fg])),
+ 0.5) * width_div,
(c + 0.5) * length_div;
(r =:center,
halign=:middle,
valign
)end
end
finish()
end
palette_contrasts(
dark_bgs,vcat([dark_fg, "gray"], neutral_colors, bright_colors),
"gruvbox_dark_contrasts.png",
)palette_contrasts(
light_bgs,vcat([light_fg, "gray"], neutral_colors, faded_colors),
"gruvbox_light_contrasts.png",
)
I’m purposefully breaking the WCAG 2.2 guidelines here by showing you how poor the contrast ratios are. If you have trouble reading the text on these images, you can switch over to the tabulated version.
colors | light0_hard | light0 | light0_soft |
---|---|---|---|
dark1 | 10.53 | 10.22 | 9.23 |
gray | 3.33 | 3.24 | 2.92 |
neutral_red | 4.97 | 4.82 | 4.35 |
neutral_green | 2.81 | 2.73 | 2.47 |
neutral_yellow | 2.25 | 2.19 | 1.97 |
neutral_blue | 3.84 | 3.73 | 3.37 |
neutral_purple | 3.84 | 3.73 | 3.37 |
neutral_aqua | 2.88 | 2.80 | 2.52 |
neutral_orange | 3.51 | 3.41 | 3.08 |
faded_red | 7.83 | 7.60 | 6.86 |
faded_green | 4.42 | 4.29 | 3.87 |
faded_yellow | 3.43 | 3.33 | 3.00 |
faded_blue | 6.00 | 5.82 | 5.25 |
faded_purple | 6.12 | 5.94 | 5.36 |
faded_aqua | 4.53 | 4.40 | 3.97 |
faded_orange | 5.56 | 5.40 | 4.87 |
colors | dark0_hard | dark0 | dark0_soft |
---|---|---|---|
light1 | 11.95 | 10.75 | 9.57 |
gray | 4.47 | 4.02 | 3.58 |
neutral_red | 3.00 | 2.69 | 2.40 |
neutral_green | 5.29 | 4.76 | 4.24 |
neutral_yellow | 6.61 | 5.94 | 5.29 |
neutral_blue | 3.88 | 3.48 | 3.10 |
neutral_purple | 3.87 | 3.48 | 3.10 |
neutral_aqua | 5.17 | 4.65 | 4.14 |
neutral_orange | 4.24 | 3.81 | 3.40 |
bright_red | 4.77 | 4.29 | 3.82 |
bright_green | 7.94 | 7.14 | 6.36 |
bright_yellow | 9.67 | 8.69 | 7.74 |
bright_blue | 6.09 | 5.48 | 4.88 |
bright_purple | 5.98 | 5.37 | 4.78 |
bright_aqua | 7.79 | 7.01 | 6.24 |
bright_orange | 6.49 | 5.84 | 5.20 |
The WCAG 2.2 contrast formula has provided us with quantitative evidence of how low-contrast Gruvbox really is. So, that begs the question: if Gruvbox’s original color palette isn’t a good choice for color contrast when building websites, can we find colors close enough to the original color palette that are high-contrast enough?
Measuring Color Differences with ΔE*
Hold on; what does “closeness” mean when comparing colors? Well, we can reason that two colors that are close together should be harder to distinguish than two colors that are farther apart. Since we already have existing color spaces that map colors to coordinates, maybe we could measure the closeness of two colors using the Euclidean distance between their coordinates. Unfortunately, trying this idea out with the sRGB color space doesn’t work well; that color space is more suited for replicating colors with computers.
Fortunately, the CIELAB color space, introduced in 1976, is designed in such a way that color difference almost corresponds to Euclidean distance. As its name suggests, its three axes are named L*, a* and b*. The L* axis corresponds to darkness to lightness; the a* axis corresponds to greenness to redness; and the b* axis corresponds to blueness to yellowness.9 In this space, the Euclidean distance between two colors is notated as ΔE*76.10 Since 1976, the CIE has since updated the ΔE* formula for determining the difference between two colors in both 1994 and 2000 to account for oversights and mistakes in the previous version. Each ΔE* color distance formula is named after the year it was published, so we can choose from ΔE*76, ΔE*94, or ΔE*00;11 I’m going to choose ΔE*00 since it’s the most accurate.
The formula for calculating ΔE*00 between two colors is surprisingly complicated, so I’m not going to implement it myself. Fortunately, the Colors.jl
library I’m using already has this metric built in. If you want to see the equations for yourself, I suggest viewing them on Bruce Lindbloom’s site.
Applying a Genetic Algorithm for Color Tweaking
When given a base background color and a base foreground color, what is the closest new color to the base foreground color that has a WCAG 2.2 contrast ratio of 4.5 or higher? While we have all the parts to write a formula to help find the sRGB coordinates of the color we want, we probably shouldn’t solve the problem with this approach. Converting from the sRGB color space to the CIELAB color space is a pain, and just looking at the steps needed to determine two colors’ closeness with ΔE*00 makes my head spin. So, let’s opt for a different approach by using a genetic algorithm.
Genetic algorithms are probably my favorite family of AI techniques. As their name suggests, they imitate Darwinian evolution – survival of the fittest / most adaptable – by iteratively finding more and more optimal solutions to a problem. Here are the steps for implementing a genetic algorithm, with the specific choices that I’ve made for our use case.
- Start with an initial population of individuals, possible solutions to the problem that we want to solve.
- Our individuals are colors, so their chromosomes are 3-long integer vectors. Each integer in an individual is a gene that represents the intensity of one channel in the individual’s corresponding hex color code.
- Evaluate the fitness of each individual using a function that represents how well an individual solves the problem.
- We want a new color that minimizes the ΔE*00 distance to an original foreground color and has a contrast ratio above 4.5 to the base background color. We can write this as a
cost
function to minimize, equal to the negative of our fitness function.
- We want a new color that minimizes the ΔE*00 distance to an original foreground color and has a contrast ratio above 4.5 to the base background color. We can write this as a
- Select which individuals of the current population will reproduce to form the next generation, and discard the rest.
- Let’s use tournament selection to choose which individuals get to pass on their genes. We’ll randomly pair up our population of individuals and select the best (lowest cost or highest fitness) of each pair.
- Pair up the selected individuals to become two parents, and interchange their genes in some way to create two new offspring for the next population.
- Let’s use uniform crossover on the parent individuals. The first child has a fifty-fifty chance of receiving each gene from its two parents, and the second child gets the genes not selected by the first.
- Mutate the children’s genes to augment them slightly, introducing new genes to the gene pool.
- We’ll use mixed-integer power mutation, since this is a mixed-integer problem.
- Repeat steps 2-4 with the next generation, comprised of both the parent individuals and their children.
Our genetic algorithm now is all set to find the closest WCAG-compliant foreground color to a background color.12
const POPULATION_SIZE = 500
"Find the closest WCAG 2.2 compliant color to an original foreground color."
function wcag_closest(background, original)
"Cost function for the internal genetic algorithm."
function cost(individual)
= RGB((individual / 255)...)
individual_srgb = contrast_ratio(background, individual_srgb)
contrast = colordiff(original, individual_srgb; metric=DE_2000())
difference return (contrast < 4.5) * 100 + difference
end
= Evolutionary.optimize(
result
cost,BoxConstraints(zeros(Int, 3), fill(255, 3)),
GA(;
=POPULATION_SIZE,
populationSize=1.0,
crossoverRate=0.2,
mutationRate=tournament(2),
selection=UX,
crossover=MIPM(zeros(3), fill(255, 3)),
mutation
),rand(my_rng, 0:255, 3) for i in 1:POPULATION_SIZE],
[Options(;
Evolutionary.=false, show_trace=false, parallelization=:thread, rng=my_rng
store_trace
),
)return result
end
wcag_closest
For dark mode, I want to find the closest “bright” foreground colors that are compliant with the dark0_soft
background. Similarly, I want to find the closest “faded” foreground colors that are compliant with the light0_soft
background. To further speed up the process, we can skip over colors that are already compliant with their corresponding background. Let’s run our genetic algorithm and view the results.
::Vector{RGB} = []
bright_alternatives::Vector{RGB} = []
faded_alternatives
for color_name in vcat("gray", bright_colors)
if contrast_ratio(gruvbox["dark0_soft"], gruvbox[color_name]) < 4.5
= wcag_closest(gruvbox["dark0_soft"], gruvbox[color_name])
result = RGB((result.minimizer / 255)...)
best_color append!(bright_alternatives, [best_color])
else
append!(bright_alternatives, [gruvbox[color_name]])
end
end
for color_name in vcat("gray", faded_colors)
if contrast_ratio(gruvbox["light0_soft"], gruvbox[color_name]) < 4.5
= wcag_closest(gruvbox["light0_soft"], gruvbox[color_name])
result = RGB((result.minimizer / 255)...)
best_color append!(faded_alternatives, [best_color])
else
append!(faded_alternatives, [gruvbox[color_name]])
end
end
Let’s first look at our compliant bright color palette for the dark color theme.
foreach(c -> print("#" * hex(c) * " "), bright_alternatives)
bright_alternatives
#AB9E8F #FC6758 #B8BB26 #FABD2F #83A598 #D3869B #8EC07C #FE8019
Now, let’s look at our compliant faded color palette for the light color theme.
foreach(c -> print("#" * hex(c) * " "), faded_alternatives)
faded_alternatives
#76604B #9D0006 #716800 #975700 #076678 #8F3F71 #20754C #AF3A03
I’d say that’s a success!
Package Versions & Last Run Date
@printf "Julia %s\n" VERSION
@printf "Colors %s\n" pkgversion(Colors)
@printf "Evolutionary %s\n" pkgversion(Evolutionary)
@printf "Luxor %s\n" pkgversion(Luxor)
@printf "Last Run %s\n" now(UTC)
Julia 1.9.2
Colors 0.12.10
Evolutionary 0.11.1
Luxor 3.8.0
Last Run 2023-12-03T15:02:39.303
References
Footnotes
Graphic Communications Open Textbook Collective, Graphic Design and Print Production Fundamentals.↩︎
The CIE’s acronym comes from its French name, le Commission Internationale de l’Eclairage.↩︎
Graphic Communications Open Textbook Collective, Graphic Design and Print Production Fundamentals.↩︎
The “delta” in ΔE* is borrowed from its mathematical use as representing change. The “E” in ΔE* stands for the German word empfindung.↩︎