using Colors
using Dates
using Evolutionary
using Luxor
using Printf
using Random
# Seed RNG for reproducibility
my_rng = Xoshiro(20231112);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. 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 Computers represent colors used for displays like the one you’re reading this on as a hash followed by three 8-bit hexadecimal numbers from 0 to 255. Each number corresponds to the intensity of red, green, and blue channels, respectively (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 scaled to \([0, 1]\) (through dividing by 255), we get the hexadecimal color’s coordinate in the sRGB color space.2 Color spaces define reproducible ways to represent colors, either by using an index or a coordinate system.
For example, let’s convert #c0ffee to its corresponding point in the sRGB color space.
c0ffee = parse(RGB, "#c0ffee") # (192, 255, 238)
println(c0ffee)
c0ffeeRGB{N0f8}(0.753, 1.0, 0.933)
The sRGB color space is neither the first nor the only color space; For example, the CIE XYZ color space was designed by the International Commission 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 If you want to learn more about color spaces like these, I recommend checking out Kuvina Saydaki’s video “The Amazing Math behind Colors!”.
Calculating Color Contrasts with WCAG 2.2
What conditions make a website accessible for all users? 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, is WCAG 2.2. It 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. We can calculate a color’s relative luminance (which corresponds to the Y-component in the CIE XYZ color space) by first 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
c in [color.r, color.g, color.b]
]
return 0.2126 * R_linear + 0.7152 * G_linear + 0.0722 * B_linear
endTo 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)
Ymin, Ymax = minmax(relative_luminance(color1), relative_luminance(color2))
return (Ymax + 0.05) / (Ymin + 0.05)
endThe 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 is simple and inaccurate in some cases. The Accessible Perceptual Contrast Algorithm (APCA).8 will replace it in WCAG 3.0.
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
gruvbox = Dict(
"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"),
)
dark_bgs = ["dark0_hard", "dark0", "dark0_soft"]
dark_fg = "light1"
light_bgs = ["light0_hard", "light0", "light0_soft"]
light_fg = "dark1"
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",
]We can split Gruvbox’s palette 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
brightcolors, only used in dark mode - The
fadedcolors, only used in light mode - The
neutralcolors, used in both dark & light modes - The intermediate dark & light colors
dark2throughdark4,gray,light4throughlight2
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)
pic_length = 600
pic_width = 600
width_div = pic_width / (length(backgrounds) + 1)
length_div = pic_length / (length(foregrounds) + 1)
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);
halign=:center,
valign=:middle,
)
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])),
(c + 0.5) * width_div,
(r + 0.5) * length_div;
halign=:center,
valign=:middle,
)
end
end
return 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 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 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 called Δ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.
- Determine 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
costfunction 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)
individual_srgb = RGB((individual / 255)...)
contrast = contrast_ratio(background, individual_srgb)
difference = colordiff(original, individual_srgb; metric=DE_2000())
return (contrast < 4.5) * 100 + difference
end
result = Evolutionary.optimize(
cost,
BoxConstraints(zeros(Int, 3), fill(255, 3)),
GA(;
populationSize=POPULATION_SIZE,
crossoverRate=1.0,
mutationRate=0.2,
selection=tournament(2),
crossover=UX,
mutation=MIPM(zeros(3), fill(255, 3)),
),
[rand(my_rng, 0:255, 3) for i in 1:POPULATION_SIZE],
Evolutionary.Options(;
store_trace=false, show_trace=false, parallelization=:thread, rng=my_rng
),
)
return result
endwcag_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.
bright_alternatives::Vector{RGB} = []
faded_alternatives::Vector{RGB} = []
for color_name in vcat("gray", bright_colors)
if contrast_ratio(gruvbox["dark0_soft"], gruvbox[color_name]) < 4.5
result = wcag_closest(gruvbox["dark0_soft"], gruvbox[color_name])
best_color = RGB((result.minimizer / 255)...)
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
result = wcag_closest(gruvbox["light0_soft"], gruvbox[color_name])
best_color = RGB((result.minimizer / 255)...)
append!(faded_alternatives, [best_color])
else
append!(faded_alternatives, [gruvbox[color_name]])
end
endLet’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
Julia 1.11.7
Colors 0.13.1
Evolutionary 0.11.1
Luxor 4.3.0
Last Run 2025-10-04T12:50:00.852
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.↩︎