Skip to content
HasanYahşi

Notes · · 4 min

paintcheck: what two wrong versions taught me about measuring contrast from pixels

The idea was simple, screenshot the page and read the colours. The first two implementations were confidently wrong in opposite directions. What the fixtures caught, and the method that held.

paintcheck measures text contrast from the pixels Chrome painted instead of from CSS. This note is about the part that is not in the README: how the method went wrong before it went right. Everything below is reconstructed from the repository's changelog and test fixtures, not from memory.

Why CSS was not enough

Tools like axe-core compute contrast from color and background-color. On my own site the text sat on a photograph under a gradient scrim; there was no background colour to read, so the checkers reported the element as incomplete. The real ratio at the worst point was about 3:1 against a 4.5:1 requirement, and nothing had told me.

Attempt one: sample next to the text

The first version took a screenshot and read the background colour from pixels beside each glyph. On flat backgrounds this works. On a gradient the pixel beside a letter is a different colour from the pixel under it, so the readings were wrong in both directions: false alarms on passing text and clean results on failing text. I noticed because the numbers did not match what I could see.

Attempt two: estimate coverage from change

The second version took two screenshots, one normal and one with all text hidden, and estimated how much each pixel was covered by a glyph from how much it changed. Then it read the colours from the most-covered pixels.

On a gradient the change between the two screenshots scales with background darkness. The estimate therefore selected pixels only in the highest-contrast region and never looked at the light end. The fixture gradient.html, where the background crosses the text's own colour, has a worst point near 1.1:1. This version reported 4.47:1 and passed it. The test failed; I had not.

The method that held

Five screenshots of the same viewport:

  • A: the page as rendered
  • A2: 300 ms later; pixels that changed are excluded (fonts still loading, animations)
  • B: all text forced transparent
  • C: all text forced black
  • D: all text forced white

C and D give the coverage of each pixel exactly. If a pixel is covered by a fraction α over background bg, then C = (1−α)·bg and D = α·255 + (1−α)·bg, so α = (D − C)/255, independent of the background and of the text's real colour. Pixels near the element's maximum α are the glyph core; antialiased edges are dropped.

The foreground is then read from A at those pixels and the background from B at the same pixels. Nothing is computed. Opacity chains, rgba colours and pseudo-element scrims were already applied by Chrome when it painted.

Two corrections the suite forced afterwards

Subpixel antialiasing. On Windows, ClearType gives each colour channel a different coverage. Pixels treated as fully covered were still blends, and every reading came out about half a point low: 3.92 measured against 4.48 computed for #777 on white. Chrome is now launched with greyscale antialiasing (--disable-lcd-text).

Thin glyphs. Where a stem is narrower than a pixel, no pixel is fully covered, so even the densest one is a mixture. Since α is known the mixture can be undone: E = B + (A − B)/α. Without it, 12 px footer links at a real 5.85:1 came out as 3.81:1. The first version of this correction also divided the colour's own alpha out of the coverage, which is wrong: the black and white probes override the colour but not the element's opacity, so only opacity belongs in that division. An rgba .68 line that must pass and an rgba .5 line that must fail at 3.32:1 now pin both directions.

What is verified and what is not

The recorded 57-test run for revision 37bbe2a passed locally on Windows 11 with Chrome 152. The benchmark runs axe-core and paintcheck on the same fixtures, using calculated reference ratios for flat backgrounds. Photo and scrim fixtures have no hand-computed reference value. These results describe the local revision, which has not been published to GitHub.

Update, 16 September 2026: revision 37bbe2a is now on GitHub and its CI run on Ubuntu with Node 20/22 passed. macOS is still untested. Font rendering can change measured ratios; cross-machine pass/fail stability has not been established beyond these two environments.

The tool is not on npm yet. It runs from the repository with npx github:hasanyahsitr/paintcheck and needs a local Chrome.

If you think I measured something wrong, write to me; I will correct it and note the change here. Contact