Back to main post

Deriving a method for contrast preserving color inversion

Jan 28, 2024

Seeking an approach to color inversion that preserves contrast ratios between colors.

Color contrast

To find a way to invert colors that preserve contrast ratios, we first need to understand how contrast ratios are calculated. The contrast ratio between two colors is (surprise!) a ratio based on the relative luminance of the colors:

contrast ratio=Llighter+0.05Ldarker+0.05\text{contrast ratio} = \frac{L_{\text{lighter}} + 0.05}{L_{\text{darker}} + 0.05}

where LlighterL_{\text{lighter}} is the relative luminance of the lighter color and LdarkerL_{\text{darker}} is the relative luminance of the darker color.

Relative luminance is a measure of the perceived brightness of a color, and is defined as follows:

L=0.2126×R+0.7152×G+0.0722×BL = 0.2126 \times \bold R + 0.7152 \times \bold G + 0.0722 \times \bold B

Watch out! The terms R\bold R, G\bold G, and B\bold B are not the same as the terms RR, GG, and BB used in sRGB inversion. These are the linear or “gamma-expanded” versions of the RGB components, which are calculated as follows:

C(linear)={C(sRGB)/12.92C(sRGB)0.04045(C(sRGB)+0.0551.055)2.4C(sRGB)>0.04045\bold C_{\text{(linear)}} = \begin{cases} C_{\text{(sRGB)}} / 12.92 & C_{\text{(sRGB)}} \le 0.04045 \\ \left(\frac{C_{\text{(sRGB)}} + 0.055}{1.055}\right)^{2.4} & C_{\text{(sRGB)}} > 0.04045 \end{cases}

Where C(sRGB)C_{\text{(sRGB)}} is the sRGB component (RR, GG, or BB), and C(linear)\bold C_{\text{(linear)}} is the resulting linear component (R\bold R, G\bold G, or B\bold B) used in the relative luminance equation.

As you can see, calculating a contrast ratio is fairly involved: we need to convert the colors to linear RGB, take a weighted sum to find the relative luminance, and then use that to calculate the contrast ratio.

These definitions for relative luminance and contrast ratio can be found in the Web Content Accessibility Guidelines (WCAG) 2.2 specification.

Algorithm

With these definitions in mind, we can now start to define a strategy for inverting colors that preserves contrast ratios between colors.

Scaffolding

First, we make a few observations:

  • The contrast ratio between two colors only depends on their relative luminance.
  • For any given color, we can adjust its relative luminance by changing its lightness in HSL color space, while preserving its hue and saturation.
    • At a minimum, we can achieve a relative luminance of 0 by setting the lightness to 0, and similarly we can achieve a relative luminance of 1 by setting the lightness to 1.
    • Relative luminance is monotonic with respect to lightness.
    • Therefore, for any given color, we can achieve any relative luminance between 0 and 1 by adjusting its lightness between 0 and 1.

This suggests that we only need to find a way to “invert” relative luminance that preserves the result of the contrast ratio calculation. Based on this, we can outline a strategy for inversion:

  1. Get the color’s relative luminance.
  2. Calculate the inverted relative luminance. (We still need to figure out how to do this.)
  3. Apply the inverted relative luminance to the color.

Inverting relative luminance

Finding a way to invert relative luminance boils down to a math problem:

  • We’re seeking some function f:[0,1][0,1]f : \left[0,1\right] \rarr \left[0,1\right].
  • We want ff to actually flip the relative lightness of two colors, so if L1L2L_1 \ge L_2, it should be true that f(L1)f(L2)f(L_1) \le f(L_2).
  • We want ff to preserve the result of the contrast ratio calculation, so that for all L1L2L_1 \ge L_2, L1+0.05L2+0.05=f(L1)+0.05f(L2)+0.05\frac{L_1 + 0.05}{L_2 + 0.05} = \frac{f(L_1) + 0.05}{f(L_2) + 0.05}.

I found this to be pretty tricky, and spent most of an evening working on this problem with some help from a friend. (I knew my one-class-short-of-a-math-minor would come in handy one day!)

If you’re interested in this sort of thing, I think it’s worth giving it a go.

I made a few additional observations that helped point me in the right direction:

  • We can see from the requirements at the boundaries that f(0)=1f(0) = 1 and f(1)=0f(1) = 0
  • ff is continuous, bijective, and monotonically decreasing
  • ff is actually its own inverse; that is, f(f(L))=Lf(f(L)) = L

And here is my solution:

I first observed that the function g(L)=(1L+0.050.05)g(L)=\left(\frac{1}{L + 0.05} - 0.05\right) has most of the desired properties but the wrong output range. Scaling and shifting this function to the appropriate range gives:

f(L)=(1L+0.050.05)×214003798000f(L) = \left(\frac{1}{L + 0.05} - 0.05\right) \times \frac{21}{400} - \frac{379}{8000}

I originally stopped there, but later found (via WolframAlpha) that this simplified neatly to:

f(L)=1L20L+1f(L) = \frac{1 - L}{20L + 1}

The remaining step is to “apply” the inverted relative luminance back to the original earlier. For the demo on this page, I do this by iteratively fine-tuning the lightness of the color until the resulting relative luminance is within an acceptable threshold of the target relative luminance.

It seems quite possible there is a more efficient way to do this, which I will leave as an exercise for the reader.

Contents