11.2 亮度分布与对比度

暗色主题的调色板几乎全落在低亮度区,文字能不能读只剩对比度这一根拐杖。本节用与卷一 ch4 同源的 WCAG ×10 整数口径,算分级后 token 的相对亮度千分比、亮度分带直方图与相邻亮度阶梯的最小间隔,并从只读真源取对比度下限,逐对检查暗色调色板是否达标。

11.2 亮度分布与对比度

《数据可视化无障碍与色彩》 讲的是图表里的色盲与对比度——柱状图、折线图怎么配色才读得清;本章不讲图表。本章取游戏调色板视角:暗黑/恐怖的画面几乎全是低亮度色,UI 文字与强调色能不能读,全靠对比度这一根拐杖。分工一句话:那篇管图表的可读性,本章管暗色画面的对比度下限。上一节 11.1 低饱和色彩分级 把饱和度压了下来,本节算压完之后亮度分布长什么样。

对比度的口径必须与卷一 ch4 同源——4.3 色盲安全与对比度下限 已经定死了 WCAG 相对亮度公式与 ×10 取整规则,本节直接沿用,不另造公式。只读真源 config/visual_spec.json 给出 contrastPairs(5 对)与 contrastFloor10(下限 45),本节拿分级后的暗色 token 去撞这道闸门。

亮度是浮点派生量,但本节把它折成整数再断言,于是整条账都是整数类:

量口径为什么可对账
相对亮度千分比int(L × 1000),L 是 WCAG 相对亮度取整后是整数,逐位一致
亮度分带边界是整数千分比,左闭右开计数是整数
对比度 ×10round((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 视觉噪声与可读性平衡 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则