一个客户的字标,透明PNG:一个词是白色,另一个词是品牌蓝,中间一个连字符。放在品牌蓝的横条上,蓝色那个词测出来1.15:1,没了。放在白色横条上,白色那个词测出来1.00:1,彻底没了。

两条横条,各自抹掉了同一个logo的一半。这个集合里根本不存在正确答案——中性色的第三条横条就是这么被逼出来的。

打开网易新闻 查看精彩图片

我做的服务是替别人的品牌发布落地页。每个页面的页头都要放客户自己的logo,页头横条有三种颜色:白色、品牌色、中性深色。选横条看起来是整个系统里最微不足道的一个决定,结果它产出了一个对所有常规信号都隐形的bug。页面渲染正常,logo文件正常返回,其他每一道关卡都是绿的,而客户的名字少了一半。

两个选项,平均值是错的统计量

最直觉的实现是:把logo合成到横条上,逐像素算对比度,取平均,选赢的那个。它失败的原因是分不清这两种情况——整个词消失了,和一处淡淡的内部高光褪色了。两者抹掉的低对比度墨迹量差不多,但只有一个是缺陷。把它们平均成一个数字,一个词的消失看起来就只是一次轻微下滑。

差别不在丢了多少墨,而在丢在哪里。一个词占据着它自己那一段水平切片,那些列里没有别的东西。一处丢失的内部细节,则和仍然可读的墨迹共享它的列。所以你要停止测量像素,开始测量列。

把标记切成16条竖列

先合成到横条上(带上alpha——这才是眼睛真正接收到的),然后把标记的包围盒切成整高的竖列,问哪些列变暗了:

  • VIS_COLS = 16:沿标记包围盒切出的整高列数
  • VIS_COL_MIN_INK = 0.015:一列墨迹占比低于1.5%算噪声,不参与判定
  • VIS_COL_LOST = 0.12:被判定的一列若可读墨迹保留低于12%,就是一个洞
  • VIS_MAX_LOST_INK = 0.15:洞里的墨迹不超过总墨迹15%才算可读
  • VIS_MIN_VISIBLE = 0.25:且全部墨迹中至少25%是清晰的

逐像素遍历时,alpha低于25的直接跳过,其余按alpha合成到横条上再算对比度,把每个像素归入墨迹、可读、清晰三档。然后按列分桶,把洞标出来:墨迹总量达到噪声下限、且可读比例低于12%的列,计入丢失墨迹。空白从不进入计算——没有墨迹的列根本不存在。那个1.5%的下限处理的是剩下的情况:一列里只有几个零散的抗锯齿像素,否则其中两个褪色就会被读成一个洞。

WCAG的3:1在这里是错的下限

我最初的阈值用的是WCAG针对非文本对比度的3.0:1。它把屏幕上明显没问题的logo拒掉了:一个实心绿色图标约2.1:1,一个橙色图标约2.2:1,两个都完全可读。WCAG的3:1是为细的UI描边校准的。一个厚实的实心标记每个字形承载的墨迹多得多,远低于它也能活下来。

所以下限是两级的:可读下限设为1.8。一列只有在它的墨迹连1.8:1都达不到时才算丢失——是真的没了(白底白1.00,蓝底蓝1.15),而不只是被压暗。另一路“清晰”占比仍然用严格阈值,于是一个被压暗的标记得分低,但不会被直接判死。

那个代码块里的每一个阈值,都由一个针对真实渲染页头的校准测试钉住。当你靠人手调常数去对齐人类感知时,测试套件是唯一挡在你和三个月后重新弄坏它之间的东西。

不透明logo问的是另一个问题

这部分反直觉。上面所有内容都适用于透明logo——横条透出来,问题是墨迹还读不读得出来。而一个不透明logo,自带一块烘焙好的底板,按构造它在任何横条上都可读,墨迹问题没有意义。真正的问题是横条和底板是否匹配,因为如果不匹配,logo就会渲染成一个贴在页头上的可见矩形。

用墨迹对比度去判定不透明logo,不只是帮不上忙,它会主动选出错误的横条——因为最大墨迹对比度往往恰恰是和底板冲突最狠的那条。三种logo形态,对应三个不同的问题。

我反复回到这一个案例,是因为它是一整类bug:每一个自动化信号都是绿的,输出仍然是错的。页面渲染了,logo文件返回了,每一道关卡都过了。唯一的探测器是一个人盯着页头看——而这在几十个页面之后就无法扩展,也恰恰是无人值守的运行会跳过的那一步。

修法从来不是“用眼睛检查一下”。而是找到那个真正编码了你眼睛在做的事情的测量方式,然后用你曾经手工、认真地验证过一次的基准真值,把它钉进测试里。我做的SEOSellers里,这项检查是页面获准发布前必须通过的十一道关卡之一。