I run a service that publishes landing pages for other people's brands. Every page
gets the client's own logo in the header bar, and the bar comes in three colours:
white, the brand colour, or a neutral dark.
Picking the bar looked like the most trivial decision in the system. It produced
a bug that is invisible to every signal you would normally trust. The page renders.
The logo file is served. Every other gate is green. And half the client's name is
not there.
The case that broke the naive version
A client wordmark, transparent PNG: the word in white, a second word in the brand
blue, hyphen between them.
- On the brand-blue bar, the blue word measured 1.15:1. Gone.
- On the white bar, the white word measured 1.00:1. Perfectly gone.
Two bars, and each one erased a different half of the same logo. There was no
correct answer in the set, which is how the neutral third bar got built.
That is the first lesson: with two options it is easy to find one that scores
acceptably on average, and averages are exactly the wrong statistic here.
Why average contrast is the wrong measurement
The obvious implementation is: composite the logo over the bar, compute contrast
per pixel, average it, pick the winner.
It fails because it cannot tell these two situations apart:
- A whole word vanished.
- A faint interior highlight faded.
Both remove a similar quantity of low-contrast ink. Only one is a defect. Average
them into a single number and the word disappearing looks like a mild dip.
The difference is not in how much ink is lost, it is in where:
A word owns its horizontal slice of the mark — nothing else is in those columns.
A lost interior detail shares its columns with ink that still reads.
So you stop measuring pixels and start measuring columns.
The implementation
Composite over the bar (alpha included — this is what the eye actually receives),
then slice the mark's bounding box into full-height columns and ask which ones went
dark:
VIS_COLS = 16 # full-height column slices across the mark's bbox
VIS_COL_MIN_INK = 0.015 # a column holding <1.5% of total ink is noise, never judged
VIS_COL_LOST = 0.12 # a judged column keeping <12% of its ink readable is a hole
VIS_MAX_LOST_INK = 0.15 # legible iff hole ink is <=15% of the mark...
VIS_MIN_VISIBLE = 0.25 # ...and >=25% of all ink is CRISP overall
for y in range(h):
for x in range(w):
r, g, b, a = px[x, y]
if a < 25:
continue
# What the eye gets: the pixel alpha-composited over the bar.
f = a / 255.0
comp = (r * f + bar[0] * (1 - f),
g * f + bar[1] * (1 - f),
b * f + bar[2] * (1 - f))
c = contrast(comp, bar)
ink.append((x, c >= INK_MIN_CONTRAST, c >= VIS_READABLE_FLOOR))
Then bucket that ink by column and flag the holes:
lost_ink = sum(tot for tot, seen in cols.values()
if tot >= VIS_COL_MIN_INK * total_ink # ignore noise columns
and seen / tot < VIS_COL_LOST) # this column went dark
Whitespace never enters the calculation — a column with no ink simply isn't there.
The VIS_COL_MIN_INK floor handles the case that is left: a column holding a few
stray anti-aliased pixels, where two of them fading would otherwise read as a hole.
WCAG 3:1 is the wrong floor here
My first threshold was WCAG's 3.0:1 for non-text contrast. It rejected logos that
were obviously fine on screen: a solid green icon at ~2.1:1, an orange one at
~2.2:1. Both perfectly readable.
WCAG's 3:1 is calibrated for thin UI strokes. A chunky solid mark carries far
more ink per glyph and survives well below it. So the floor is two-tier:
VIS_READABLE_FLOOR = 1.8
A column only counts as lost when its ink cannot manage even 1.8:1 — truly gone
(white-on-white at 1.00, blue-on-blue at 1.15), not merely muted. The separate
"crisp" share still uses the strict threshold, so a muted mark scores low without
being failed outright.
Every threshold in that block is pinned by a calibration test against real rendered
headers. When you are hand-tuning constants against human perception, the test suite
is the only thing standing between you and re-breaking it in three months.
Opaque logos ask a completely different question
This part is counter-intuitive.
Everything above applies to transparent logos, where the bar shows through and
the question is can I still read the ink.
An opaque logo — one with its own baked-in background plate — is legible on
every bar by construction. The ink question is meaningless. The real question is
whether the bar matches the plate, because if it does not, the logo renders as
a visible pasted rectangle sitting on your header.
Judging an opaque logo by ink contrast doesn't merely fail to help. It actively
selects the wrong bar, because maximum ink contrast is often precisely the bar that
clashes hardest with the plate.
Three logo shapes, three different questions:
| Logo | Question | Failure mode |
|---|---|---|
| Transparent | Does the ink survive the bar? | A word disappears |
| Opaque | Does the bar match the plate? | A pasted rectangle |
| None | — | Text wordmark, safe by construction |
The takeaway
The reason I keep coming back to this one: it is a whole category of bug where
every automated signal is green and the output is still wrong. The page renders,
the logo file is served, every gate passes. The only detector is a human
looking at the header — which does not scale past the first few dozen pages, and
is exactly the step an unattended run skips.
The fix is never "check it by eye." It is finding the measurement that actually
encodes what your eye is doing, then pinning it with tests against ground truth you
verified once, by hand, properly.
I build SEOSellers,
where this check is one of eleven gates a page has to clear before it is
allowed to publish. First page and article are free if you want to see the output.
Top comments (2)
Two bars each erasing a different half of the same wordmark is a nasty one, since an average contrast score over the whole logo would have called both of them fine. Judging the ink column by column gets around that.
I would have checked the file, seen it served, and moved on.
The threshold block leaves
INK_MIN_CONTRASTundefined, even though it controls thetotal_inkdenominator and which columns can be judged as holes. Is that floor fixed across rendered logo sizes?