11.2 亮度分布与对比度
《数据可视化无障碍与色彩》 讲的是图表里的色盲与对比度——柱状图、折线图怎么配色才读得清;本章不讲图表。本章取游戏调色板视角:暗黑/恐怖的画面几乎全是低亮度色,UI 文字与强调色能不能读,全靠对比度这一根拐杖。分工一句话:那篇管图表的可读性,本章管暗色画面的对比度下限。上一节 11.1 低饱和色彩分级 把饱和度压了下来,本节算压完之后亮度分布长什么样。
对比度的口径必须与卷一 ch4 同源——4.3 色盲安全与对比度下限
已经定死了 WCAG 相对亮度公式与 ×10 取整规则,本节直接沿用,不另造公式。只读真源 config/visual_spec.json 给出 contrastPairs(5 对)与 contrastFloor10(下限 45),本节拿分级后的暗色 token 去撞这道闸门。
亮度是浮点派生量,但本节把它折成整数再断言,于是整条账都是整数类:
| 量 | 口径 | 为什么可对账 |
|---|---|---|
| 相对亮度千分比 | int(L × 1000),L 是 WCAG 相对亮度 | 取整后是整数,逐位一致 |
| 亮度分带 | 边界是整数千分比,左闭右开 | 计数是整数 |
| 对比度 ×10 | round((hi+0.05)/(lo+0.05) × 10) | 取整后是整数,逐位一致 |
下面这段是 S 层脚本,读 config/st11_dark.json 的分级后 token,用与 sim/consume.py / core/src/sp00/spec.ts 同源的 sRGB→linear 与 WCAG 亮度常数,算亮度千分比、分带直方图、相邻亮度阶梯的最小间隔,并从只读真源 visual_spec.json 取对比度对与下限,逐对算 WCAG 对比度 ×10。
#!/usr/bin/env python3
# file: sim/st11_luminance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st11_luminance.py
"""11.2 亮度分布与对比度(S 层,暗黑/恐怖)。
暗色主题的调色板几乎全落在低亮度区,文字能不能读只剩对比度这一根拐杖。脚本读
config/st11_dark.json 的分级后 token,用与 sim/consume.py / core/src/sp00/spec.ts
**同源**的 sRGB→linear 与 WCAG 亮度常数算三笔整数账:① 每个 token 的相对亮度千分比与
亮度分带直方图;② 排序后相邻 token 的亮度阶梯间隔(最小间隔及其 token 对);③ 从只读真源
visual_spec.json 取对比度对与下限 contrastFloor10,算 5 对 WCAG 对比度 ×10 并检查是否达标。
对比度是整数类 ×10,逐位一致。PIL 生成亮度阶梯 PNG 并回读尺寸/模式。落 out/st11_luminance.json。
"""
import hashlib
import json
import os
import sys
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
# sRGB→linear 与 WCAG 常数:与 sim/consume.py / core/src/sp00/spec.ts 同源
TH, DIV, OFF, SCA, GAM = 0.04045, 12.92, 0.055, 1.055, 2.4
LR, LG, LB = 0.2126, 0.7152, 0.0722
def lin(c):
s = c / 255
return s / DIV if s <= TH else ((s + OFF) / SCA) ** GAM
def luminance(c):
return LR * lin(c[0]) + LG * lin(c[1]) + LB * lin(c[2])
def contrast10(a, b):
"""WCAG 对比度 ×10 取整 → 整数类,逐位一致。"""
f = lambda c: LR * lin(c[0]) + LG * lin(c[1]) + LB * lin(c[2])
hi, lo = sorted((f(a), f(b)), reverse=True)
return round(((hi + 0.05) / (lo + 0.05)) * 10)
def band_of(lum_pm, edges):
"""亮度千分比落在哪个分带(左闭右开)。"""
for i in range(len(edges) - 1):
if edges[i] <= lum_pm < edges[i + 1]:
return i
return len(edges) - 2
def gen_ladder(tokens, path, size):
"""亮度阶梯:按亮度升序,列高 = 亮度千分比 × 高 // 1000(至少 4px)。"""
w, h = size
im = Image.new("RGB", (w, h), (8, 9, 14))
d = ImageDraw.Draw(im)
order = sorted(tokens.items(), key=lambda kv: luminance(kv[1]))
n = len(order)
for i, (_k, c) in enumerate(order):
pm = int(luminance(c) * 1000)
col = max(4, pm * h // 1000)
x0, x1 = i * w // n, (i + 1) * w // n - 1
d.rectangle([x0, h - col, x1, h - 1], fill=tuple(c))
im.save(path)
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st11_dark.json"), encoding="utf-8"))
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
tokens = cfg["tokens"]
edges = cfg["luminance"]["bandEdges"]
lum_pm = {k: int(luminance(v) * 1000) for k, v in tokens.items()}
bands = [0] * (len(edges) - 1)
for k in lum_pm:
bands[band_of(lum_pm[k], edges)] += 1
dark_mass = sum(1 for k in lum_pm if lum_pm[k] < 150)
order = sorted(lum_pm, key=lambda k: lum_pm[k])
gaps = [{"from": order[i], "to": order[i + 1], "gapPermille": lum_pm[order[i + 1]] - lum_pm[order[i]]}
for i in range(len(order) - 1)]
min_gap = min(g["gapPermille"] for g in gaps)
min_pair = next(f"{g['from']}->{g['to']}" for g in gaps if g["gapPermille"] == min_gap)
pairs = [{"pair": f"{a}/{b}", "contrast10": contrast10(tokens[a], tokens[b])}
for a, b in spec["contrastPairs"]]
min_c, floor = min(p["contrast10"] for p in pairs), spec["contrastFloor10"]
failing = [p["pair"] for p in pairs if p["contrast10"] < floor]
gen = next(g for g in cfg["generated"] if g["id"] == "dark_luminance")
png = os.path.join(os.path.dirname(ROOT), gen["file"])
os.makedirs(os.path.dirname(png), exist_ok=True)
gen_ladder(tokens, png, (gen["width"], gen["height"]))
with Image.open(png) as im:
info = {"size": list(im.size), "mode": im.mode}
canon = json.dumps({"lumPermille": {k: lum_pm[k] for k in sorted(lum_pm)},
"bands": bands, "minLadderGapPermille": min_gap,
"contrast10": [p["contrast10"] for p in pairs],
"minContrast10": min_c, "floor10": floor},
separators=(",", ":"), ensure_ascii=False)
report = {"project": cfg["project"], "theme": cfg["theme"], "lumPermille": lum_pm,
"bandEdges": edges, "bands": bands, "darkMass": dark_mass,
"ladder": gaps, "minLadderGapPermille": min_gap, "minLadderPair": min_pair,
"contrast": pairs, "minContrast10": min_c, "contrastFloor10": floor,
"failing": failing, "png": info, "canonical": canon,
"digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "st11_luminance.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"lumPermille={ {k: lum_pm[k] for k in sorted(lum_pm)} }")
print(f"bands(edges={edges})={bands} darkMass={dark_mass}/{len(tokens)}")
print(f"minLadderGapPermille={min_gap} pair={min_pair} gaps={[g['gapPermille'] for g in gaps]}")
print(f"minContrast10={min_c} floor10={floor} pairs={[p['contrast10'] for p in pairs]} failing={failing}")
print(f"png {info['size'][0]}x{info['size'][1]} {info['mode']}")
print(f"digest={report['digest']}")
print("wrote out/st11_luminance.json out/gen/st11_luminance_ladder.png")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/st11_luminance.py(同机相对量级):
lumPermille={'accent': 199, 'bg': 4, 'danger': 234, 'ink': 697, 'muted': 353, 'ok': 568, 'surface': 10, 'warn': 465}
bands(edges=[0, 50, 150, 300, 500, 1000])=[2, 0, 2, 2, 2] darkMass=2/8
minLadderGapPermille=6 pair=bg->surface gaps=[6, 189, 35, 119, 112, 103, 129]
minContrast10=46 floor10=45 pairs=[137, 74, 124, 46, 47] failing=[]
png 256x64 RGB
digest=c1df24e9f65e1cee
wrote out/st11_luminance.json out/gen/st11_luminance_ladder.png
亮度千分比从 bg=4 一路排到 ink=697,8 个 token 均匀铺在 5 个分带里([2, 0, 2, 2, 2]),只有最低的 [50,150) 一档是空的——bg=4 与 surface=10 落在最低档,accent=199 与 danger=234 在中低档,muted=353 与 warn=465 在中高档,ok=568 与 ink=697 在高档。相邻亮度阶梯的最小间隔是 6(bg→surface)——这两个色几乎靠色相而非亮度区分,是暗色底的通病,本节的阈值 minLadderGapPermille=5 放它过,但它是全套阶梯里最脆的一环。
关键是 5 对对比度:[137, 74, 124, 46, 47],最小 46,刚好压在下限 45 之上(failing=[])。accent/bg=46 与 danger/surface=47 都是贴着底线过关——压饱和度把对比度推到了悬崖边。这也解释了暗黑风为什么难做:低饱和压掉色相区分,低亮度又压掉明度区分,两边一起收,只剩对比度这一根拐杖,稍有闪失就掉到下限以下。
下面这段是 P 层 TypeScript 内核 core/src/st11/luminance.ts。lumPermille 把相对亮度折成整数千分比,bandOf 求分带,contrast10 复用与 sim/consume.py / core/src/sp00/spec.ts 同源的 sRGB 与 WCAG 亮度常数——与卷一 ch4 同一口径。
// file: core/src/st11/luminance.ts
// tier: P
// verify: node --test 'core/test/st11_*.test.ts'
// 11.2 亮度分布与对比度的 TS 内核:相对亮度千分比、亮度分带、WCAG 对比度 ×10。
// sRGB→linear 与 WCAG 亮度常数与 sim/consume.py / core/src/sp00/spec.ts 同源。
// 对比度输出为整数 ×10,是整数类,跨语言逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export const SRGB_THRESHOLD = 0.04045;
export const SRGB_DIVISOR = 12.92;
export const SRGB_OFFSET = 0.055;
export const SRGB_SCALE = 1.055;
export const SRGB_GAMMA = 2.4;
export const LUMA_R = 0.2126;
export const LUMA_G = 0.7152;
export const LUMA_B = 0.0722;
export type RGB = number[];
export function srgbToLinear(c: number): number {
const s = c / 255;
return s <= SRGB_THRESHOLD
? s / SRGB_DIVISOR
: Math.pow((s + SRGB_OFFSET) / SRGB_SCALE, SRGB_GAMMA);
}
export function luminance(rgb: RGB): number {
return (
LUMA_R * srgbToLinear(rgb[0]) +
LUMA_G * srgbToLinear(rgb[1]) +
LUMA_B * srgbToLinear(rgb[2])
);
}
/** 相对亮度千分比(整数类)。 */
export function lumPermille(rgb: RGB): number {
return Math.floor(luminance(rgb) * 1000);
}
/** WCAG 对比度 ×10 取整 → 整数类,逐位一致。 */
export function contrast10(a: RGB, b: RGB): number {
const la = luminance(a);
const lb = luminance(b);
const hi = Math.max(la, lb);
const lo = Math.min(la, lb);
return Math.round(((hi + 0.05) / (lo + 0.05)) * 10);
}
/** 亮度千分比落在哪个分带(左闭右开)。 */
export function bandOf(lumPm: number, edges: number[]): number {
for (let i = 0; i < edges.length - 1; i++) {
if (lumPm >= edges[i] && lumPm < edges[i + 1]) return i;
}
return edges.length - 2;
}
配套测试 core/test/st11_luminance.test.ts 读 out/st11_luminance.json,跑 node --test 'core/test/st11_luminance.test.ts':
✔ 亮度千分比与分带:与 Python 逐位一致 (0.949291ms)
✔ 对比度 ×10:整数类逐位一致,且全部不低于下限 (0.143292ms)
✔ 亮度阶梯:相邻间隔的最小值与 Python 逐位一致 (0.0835ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.387333ms)
✔ 负例:分级前的原 token 对比度更低——压饱和度会把对比度推向底线 (0.094208ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
最后一条负例是关键的对抗:把分级前的原 token 拿去算,最小对比度是 55;压饱和度后掉到 46——低饱和分级真的把对比度推向了底线,且仍在下限之上。跨语言 digest c1df24e9f65e1cee 与 Python 侧逐位相同(整数类)。测试全文见工作区 core/test/st11_luminance.test.ts。
自检表
| 检查项 | 合格标准 |
|---|---|
| 对比度同源 | 用卷一 ch4 的 WCAG ×10 口径,不另造公式 |
| 下限来自真源 | contrastFloor10 取自只读 visual_spec.json |
| 分布可对账 | 分带计数之和 = token 数 |
| 阶梯显式 | 相邻亮度最小间隔被报出,不藏 |
亮度分布定下来,画面还剩最后一笔账——细节密度。暗底上的细节太密会糊成一团,太淡又会消失,下一节算这笔平衡账。
阅读导航:上一节:11.1 低饱和色彩分级 · 下一节:11.3 视觉噪声与可读性平衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。