颜色对比度不是算一次就完事:从 4.5 到 1.2,一次 24 组配色的实测

举报
yd_263929376 发表于 2026/11/07 13:31:58 2026/11/07
【摘要】 颜色对比度不是算一次就完事:从 4.5 到 1.2,一次 24 组配色的实测对比度校验器给出的往往是一个两位小数和一个绿勾。看到 4.5 就收工,是这类工具最常见的用法,也是最容易出事的用法——那个数字至少有五处会在你不知情的时候变得不准:先舍入再比较会放过 4.4989,字号与字重决定了阈值到底是 4.5 还是 3,半透明颜色让朴素算法高估两倍以上,父级 opacity 把整块文字一起压...

颜色对比度不是算一次就完事:从 4.5 到 1.2,一次 24 组配色的实测

对比度校验器给出的往往是一个两位小数和一个绿勾。看到 4.5 就收工,是这类工具最常见的用法,也是最容易出事的用法——那个数字至少有五处会在你不知情的时候变得不准:先舍入再比较会放过 4.4989,字号与字重决定了阈值到底是 4.5 还是 3,半透明颜色让朴素算法高估两倍以上,父级 opacity 把整块文字一起压暗,同一套 token 换个背景就从 15.8 掉到 1.2。

下面的数字全部来自本机实测:Windows 11、Python 3.13.14、Chrome 无头模式,样本是一个 3183 字节的真实报告样式表(report.css),共抽出 24 组前景/背景组合,其中 20 组文字、4 组边框。

一、公式只有九行,先和权威值对齐

WCAG 2.x 的对比度定义并不复杂,难点全在 sRGB 反伽马那一行:

def lin(c):
    c /= 255.0
    return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4

def luminance(rgb):
    r, g, b = (lin(x) for x in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast(fg, bg):
    la, lb = luminance(fg), luminance(bg)
    hi, lo = max(la, lb), min(la, lb)
    return (hi + 0.05) / (lo + 0.05)

实现完先跑三个已知值:#000000 对白底得 21.000,#767676 对白底得 4.542,#777777 对白底得 4.478。后两个数字是这套标准的民间锚点——4.54 与 4.48,中间只差一个十六进制位,判定却相反。

顺带清掉一个长期被讨论的分歧:反伽马阈值到底该用 0.03928(WCAG 原文)还是 0.04045(sRGB 规范)。在 8 位色深下这两个值完全不可观测——256 个灰阶逐一对比,差异为 0;再取 20 万组随机前景/背景对,最大差异也是 0。原因是 10/255 = 0.03922 落在两个阈值之下,11/255 = 0.04314 落在两者之上,中间那段区间没有任何 8 位取值。为一个产生不了差异的常数争论,纯属浪费。

二、边界是一格十六进制,不是 0.001

灰阶扫描的结果很干脆:

#767676 对白底  4.5422  通过
#777777 对白底  4.4781  失败
相邻灰阶步长    0.0641

也就是说,设计稿上把 #767676 调成 #777777,肉眼完全看不出变化,校验结果直接翻转。3:1 的边界同样是"差一格":#949494 得 3.0335,#959595 得 2.9953。256 个灰阶里,能过 4.5 的只有 119 个,能过 3.0 的 149 个——过半的灰都过不了正文标准,这解释了为什么"看起来还行"的浅灰文字在实际阅读中总是吃力。

三、先四舍五入再比较,会放过 4.4989

校验器最容易写错的一行,是把结果舍入到两位再和阈值比:

# 错误写法
if round(contrast(fg, bg), 2) >= 4.5:
    pass

实测抓到一个真实样本:#2da3fe 文字压在 #700229 背景上,对比度 4.4989,round(..., 2) 得到 4.50,判定"通过"。差了 0.0011,就是不合格。正确写法是拿原始值比较,且只在展示时格式化:

ratio = contrast(fg, bg)          # 4.498945107923931
if ratio >= 4.5:                  # 不要在这里 round
    print(f"{ratio:.2f}")         # 展示时才保留两位

同一个坑还有反向版本:某些工具把 4.495~4.5 之间的值显示成 4.50 并标绿,用户于是把 4.50 写进设计规范,后面的所有计算都建立在一个不存在的数字上。

四、3:1 只给"大文本",18px/600 不算

WCAG 对大文本的定义是硬的:字号 ≥ 24px,或者字号 ≥ 18.66px(14pt)且字重 ≥ 700。两个条件是与的关系。

18px / 600  → 不算大文本,阈值 4.5
18px / 700  → 算大文本,阈值 3.0
24px / 400  → 算大文本,阈值 3.0

font-weight: 600 是 semibold,不是 bold,这一条在视觉上几乎无法分辨,却决定了阈值差 1.5 倍。另一个隐蔽点是 rem:1.5rem 只有在根字号为 16px 时才是 24px,若 html { font-size: 10px },1.5rem 等于 15px,立刻退回 4.5 的判据。

同一份样式表里有个正面例子:.kv .n 是 21px / 700,命中"大文本",按 3:1 判定,实际 4.877,余量 62%;如果误按 4.5 判,余量只剩 8%——阈值取错,风险判断跟着错。

五、半透明颜色:朴素算法高估 2.6 倍

rgba() 里的 alpha 不参与对比度公式,必须先与背景合成:

def composite(fg, bg, alpha):
    return tuple(round(f * alpha + b * (1 - alpha)) for f, b in zip(fg, bg))

四组实测,朴素算法(直接用 fg)与真实合成值的差距:

前景 背景 朴素值 合成后 真实值 结论
rgba(0,0,0,.60) #ffffff 21.00 #666666 5.74 高估 265.7%
rgba(0,0,0,.45) #ffffff 21.00 #8c8c8c 3.36 高估 524.5%,实际不合格
rgba(255,255,255,.70) #0969da 5.19 #b5d2f4 3.34 高估 55.7%,实际不合格
rgba(31,35,40,.55) #ffffff 15.80 #848689 3.65 高估 332.7%,实际不合格

后三行都是"看起来通过、实际不通过"。黑色半透明尤其危险:任何 alpha 下朴素值都是 21.00,一个 .45 的次要文字会被判成最高对比度。

六、父级 opacity 会把整棵子树一起压暗

opacity 与 rgba 的差别是作用范围:它先把整个元素(含子元素、边框、阴影)画到离屏图层,再整体与背景合成。所以对文字的影响不是"变淡一点",而是重新算一遍对比度。以 #1f2328(原值 15.80)在白底上为例:

父级 opacity 合成后文字色 对比度
0.9 #35393d 11.64
0.8 #4c4f53 8.23
0.7 #626569 5.86
0.6 #797b7e 4.24
0.5 #8f9194 3.16

这条线的下限是 0.7。用 opacity: .6 做"禁用态或次要文字",15.80 会掉到 4.24,直接不合格;而禁用的控件在 WCAG 里是豁免项,不承载信息的次要文字却不是——同一个 .6,一处合规一处违规,取决于它压在什么内容上。

七、真正的坑:token 跨主题复用

同一套语义色,白底全部合格(5.08 到 5.36),搬到 #0d1117 深色底上:

#1f2328 → 1.20     #0969da → 3.64     #9a6700 → 3.89
#59636e → 3.10     #1a7f37 → 3.73     #cf222e → 3.53
#d8dee4 → 13.96    #f0f6fc → 17.39

六个语义色(正文、次要文字、链接、成功、警告、危险)在深色底上全部低于 4.5,最低的正文色只有 1.20——几乎是同色。这不是"深色模式没调好",而是浅色 token 直接复用必然的结果:深色底需要的是提亮后的同一色相,不是原来的十六进制值。

结论很直接:对比度校验必须按"主题 × 组件"的组合跑,不能按 token 单独跑。一个 token 在白底合格,不代表它在任何背景上都合格;反过来,一个 token 被判失败,也可能只是因为它被拿去配了不该配的背景。

八、1% 的余量等于没有余量

审计里最险的一组是评级徽章:白字压在 #6e7781 上,4.547,余量 1.0%。相邻一档 #6f7a84 立刻掉到 4.381:

#ffffff on #6e7781 → 4.547  通过(余量 1.0%)
#ffffff on #6f7a84 → 4.381  失败
#ffffff on #6d7780 → 4.565  通过

20 组文字配色全部过了 4.5,把阈值抬到 4.725(即 5% 余量)后只剩 1 组落榜,正是这个 4.547。所以把 CI 卡在 4.725 是低成本的:它只淘汰真正贴线的配色,却能挡住后续任何一次微调。

九、装饰性边框报红,不等于违规

24 组里有 2 组失败,都是同一个颜色:#d8dee4 在白底上的分隔线,1.356。但 WCAG 1.4.11(非文本对比)的适用范围是"可操作组件的视觉边界与有含义的图形",纯装饰性的分隔线不在其中。同一份样式表里真正需要看的是焦点环与表单边框:

#afb8c1 焦点环候选 → 2.01   失败(需 3.0)
#8c959f 焦点环候选 → 3.04   刚过
#d8dee4 表格/标题分隔线 → 1.36  装饰,不适用

这两个数字解释了为什么键盘 Tab 一圈常常"看不见焦点在哪":2.01 的环在浅灰背景上几乎等于背景。

十、公式描述的是纯色,不是屏幕上的字

最后一步实测最容易被忽略。用 Canvas 逐像素扫描渲染结果,把 #767676 在 16px / 400 下的墨迹像素按对比度排序:

公式值                4.54
达到该色的墨迹像素     19.0%
墨迹中位像素等效对比度  2.61

12px / 400 时中位只有 2.03;16px / 700 时达到该色的比例升到 33%,中位 3.11;24px / 400 时中位仍是 2.61。原因不神秘:字形边缘由抗锯齿像素填充,越细的笔画里"纯色像素"占比越低。

这不是 WCAG 的算法,也不能拿它去判违规;它只说明一件事——4.542 描述的是最深处的那个颜色,不是整行字给人的观感。所以 4.5 应当被当成及格线而不是目标值:这份样式表里 body 是 15.80、.sub 是 6.11,正文读起来就比 4.55 的徽章舒服得多。

写在最后

对比度校验的价值不在那个两位小数,而在三个动作:把 alpha 和 opacity 先合成再计算,把字号字重真正代入阈值判断,把 token 放进每一个实际背景里去跑。少做任何一步,绿勾都只是"这个颜色单独看没问题",和页面能不能读,关系不大。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。