Tweaking Color Schemes for Accessibility with Genetic Algorithms

Time to show some true colors

ai
color
julia
optimization
programming
Published

November 20, 2023

Modified

December 3, 2023

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.

using Dates
using Printf
using Random

using Colors
using Evolutionary
using Luxor

# Seed RNG for reproducibility
my_rng = Xoshiro(20231112);

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.

c0ffee = parse(RGB, "#c0ffee")  # (192, 255, 238)
println(c0ffee)
c0ffee
RGB{N0f8}(0.753,1.0,0.933)

square with color #c0ffee ()

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
        c in [color.r, color.g, color.b]
    ]
    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)
    Ymin, Ymax = minmax(relative_luminance(color1), relative_luminance(color2))
    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

Side Note

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
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",
]

Gruvbox’s palette can be split into the following categories:

  • The dark mode backgrounds dark0_hard, dark0, and dark0_soft
  • The light mode backgrounds light0_hard, light0, and light0_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 through dark4, gray, light4 through light2

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
    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.

WCAG 2.2 Contrast ratio table of Gruvbox light mode backgrounds and light mode colors as an image.

WCAG 2.2 Contrast ratio table of Gruvbox light mode backgrounds and light mode colors as an image.
WCAG 2.2 Contrast ratio table of Gruvbox light mode backgrounds and light mode colors.
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

WCAG 2.2 Contrast ratio table of Gruvbox dark mode backgrounds and dark mode colors as an image.

WCAG 2.2 Contrast ratio table of Gruvbox dark mode backgrounds and dark mode colors as an image.
WCAG 2.2 Contrast ratio table of Gruvbox dark mode backgrounds and dark mode colors.
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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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
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.

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
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 

From left to right: #AB9E8F, #FC6758, #B8BB26, #FABD2F, #83A598, #D3869B, #8EC07C, #FE8019

Alternative WCAG 2.2 compliant colors for Gruvbox Dark.

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 

From left to right: #76604B, #9D0006, #716800, #975700, #076678, #8F3F71, #20754C, #AF3A03

Alternative WCAG 2.2 compliant colors for Gruvbox Light.

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

Graphic Communications Open Textbook Collective. Graphic Design and Print Production Fundamentals. BCcampus, 2015. https://opentextbc.ca/graphicdesign/.
Kerr, Douglas A. “The CIE XYZ and xyY Color Spaces,” March 2010. http://www.haralick.org/DV/CIE_XYZ.pdf.
Ruitiña, John. APCA: The New Algorithm for Accessible Colour Contrast.” Orange Is the New Whatever, May 2022. https://ruitina.com/apca-accessible-colour-contrast/.
Schuessler, Zachary. “Delta E 101.” Accessed November 13, 2023. http://zschuessler.github.io/DeltaE/learn/.
Wirsansky, Eyal. Hands-On Genetic Algorithms with Python: Applying Genetic Algorithms to Solve Real-World Deep Learning and Artificial Intelligence Problems. Birmingham: Packt Publishing Ltd, 2020.
World Wide Web Consortium. “Web Content Accessibility Guidelines 2.2.” World Wide Web Consortium, October 2023. https://www.w3.org/TR/WCAG22/.

Footnotes

  1. Graphic Communications Open Textbook Collective, Graphic Design and Print Production Fundamentals.↩︎

  2. World Wide Web Consortium, WCAG 2.2”.↩︎

  3. The CIE’s acronym comes from its French name, le Commission Internationale de l’Eclairage.↩︎

  4. Kerr, “The CIE XYZ and xyY Color Spaces”.↩︎

  5. World Wide Web Consortium, WCAG 2.2”.↩︎

  6. Kerr, “The CIE XYZ and xyY Color Spaces”.↩︎

  7. World Wide Web Consortium, WCAG 2.2”.↩︎

  8. Ruitiña, APCA.↩︎

  9. Graphic Communications Open Textbook Collective, Graphic Design and Print Production Fundamentals.↩︎

  10. The “delta” in ΔE* is borrowed from its mathematical use as representing change. The “E” in ΔE* stands for the German word empfindung.↩︎

  11. Schuessler, “Delta E 101”.↩︎

  12. Wirsansky, Hands-On Genetic Algorithms with Python.↩︎