4.3 色盲安全与对比度下限

有了感知距离这把尺子,本节把它用到最要紧的地方:色觉异常。我们自实现 Viénot–Brettel 二色觉模拟,对 8 个 token 逐一投影成 8-bit 整数,实测等亮度红/绿在绿色盲下距离从 0.3755 塌到 0.0656,并抓住真实规格违例 danger/surface 对比度只有 31。

4.2 给了我们「感知距离」这把尺子,本节把它用到最要紧的地方:色觉异常。这与 数据可视化中的无障碍配色 是同一个判据、不同场景——图表要求「相邻系列在色觉异常下仍可分辨」,游戏美术要求「血条、警示、稀有度、状态图标在色觉异常下仍可分辨」。区别在于:图表是静态的、可加文字标注,游戏 UI 往往只能靠颜色本身,所以下限必须更硬。

4.3 色盲安全与对比度下限

4.3.1 为什么这是硬门禁

红绿色盲(红色盲 protan、绿色盲 deutan)在男性中约占 8%,是最高发的色觉异常;蓝色盲(tritan)少见得多。对红绿两类,红与绿、绿与黄、橙与绿这几组最容易塌陷成同一个颜色,所以「红=危险、绿=安全」对约 8% 的男性玩家几乎等于没做区分。门禁有两条,都可计算:① 可区分性——两色模拟成二色觉后 OKLab 距离是否仍 ≥ 阈值;② 对比度下限——文字/图标与背景的对比度 ×10 是否 ≥ 45(§0.3 真实规格)。4.1 那条结论在这里很关键:对比度只由亮度决定,所以两色色相完全不同、亮度却接近时,对比度可能都合格,却依然会在色觉异常下塌陷。

4.3.2 D 层:把样例与判据写成配置

D 层配置 config/sp04_colorblind.json 声明三型、OKLab 阈值、要检查的 token 对、一对等亮度红/绿,以及要过对比度下限的对比度对(完整条目见 config/ 落盘文件)。

{
  "observation": "公开可观察,观察时点 2026-10;Viénot–Brettel–Mollon 1999 的二色觉模拟属公开算法,本文件只声明要复算的样例与判据",
  "note": "第 4.3 节:Brettel/Viénot 二色觉模拟(自实现,无 colour 库)+ 可区分性判据 + 对比度下限反例",
  "types": ["protan", "deutan", "tritan"],
  "typeLabels": { "protan": "红色盲", "deutan": "绿色盲", "tritan": "蓝色盲" },
  "oklabThreshold": 0.08,
  "pairs": [["ok", "danger"], ["accent", "ok"], ["warn", "danger"], ["accent", "bg"], ["ink", "bg"]],
  "isoLuminance": { "label": "等亮度红/绿(OKLab L 匹配)", "a": [255, 30, 30], "b": [30, 170, 60] },
  "contrastPairs": [["danger", "surface"], ["accent", "bg"], ["ink", "bg"], ["muted", "bg"]]
}

4.3.3 S 层:自实现二色觉模拟

本机没有 colour 库,所以这段 S 层脚本自己实现 Viénot–Brettel–Mollon 1999 的二色觉模拟:linear RGB → LMS(Viénot 矩阵)→ 按型做二色觉投影 → LMS → linear RGB,输出 sRGB 8-bit 整数三元组。矩阵常数与 core/src/sp04/colorblind.ts 同源。

#!/usr/bin/env python3
# file: sim/sp04_colorblind.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp04_colorblind.py
"""色盲安全与对比度下限(第 4.3 节 S 层)。

本机**没有 colour 库**,故自己实现 Viénot–Brettel–Mollon 1999 二色觉模拟:linear RGB → LMS(Viénot
矩阵)→ 二色觉投影(红/绿/蓝三型)→ LMS → linear RGB,输出 sRGB 8-bit 整数三元组。再做两件判断:
① 可区分性(模拟后 OKLab 距离是否仍 ≥ 阈值);② 对比度下限(danger/surface 是否低于下限,§0.3 真实规格违例)。
落盘 out/sp04_colorblind.json。Viénot 矩阵与 core/src/sp04/colorblind.ts 同源。
"""

from __future__ import annotations

import json
import math
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
SRGB_THRESHOLD, SRGB_DIVISOR, SRGB_OFFSET, SRGB_SCALE, SRGB_GAMMA = 0.04045, 12.92, 0.055, 1.055, 2.4
LUMA_R, LUMA_G, LUMA_B = 0.2126, 0.7152, 0.0722

# Viénot 1999:linear RGB → LMS 与 LMS → linear RGB(逆矩阵),两语言同源
RGB2LMS = ((0.15514, 0.54312, -0.03286), (-0.15514, 0.45684, 0.03286), (0.0, 0.0, 0.01608))
LMS2RGB = ((2.944812906607, -3.500977991936, 13.172182147147),
           (1.000040001600, 1.000040001600, 0.0), (0.0, 0.0, 62.189054726368))
# Ottosson OKLab 正变换(用于可区分性判据)
OK_L = (0.4122214708, 0.5363325363, 0.0514459929)
OK_M = (0.2119034982, 0.6806995451, 0.1073969566)
OK_S = (0.0883024619, 0.2817188376, 0.6299787005)
OK_L_ = (0.2104542553, 0.7936177850, -0.0040720468)
OK_A_ = (1.9779984951, -2.4285922050, 0.4505937099)
OK_B_ = (0.0259040371, 0.7827717662, -0.8086757660)


def srgb_to_linear(c: int) -> float:
    s = c / 255
    return s / SRGB_DIVISOR if s <= SRGB_THRESHOLD else ((s + SRGB_OFFSET) / SRGB_SCALE) ** SRGB_GAMMA


def linear_to_srgb_channel(v: float) -> int:
    s = v * SRGB_DIVISOR if v <= 0.0031308 else SRGB_SCALE * (v ** (1 / SRGB_GAMMA)) - SRGB_OFFSET
    return max(0, min(255, round(s * 255)))


def luminance(rgb) -> float:
    return LUMA_R * srgb_to_linear(rgb[0]) + LUMA_G * srgb_to_linear(rgb[1]) + LUMA_B * srgb_to_linear(rgb[2])


def contrast10(a, b) -> int:
    la, lb = luminance(a), luminance(b)
    hi, lo = max(la, lb), min(la, lb)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def cbrt(x: float) -> float:
    return math.copysign(abs(x) ** (1.0 / 3.0), x)


def oklab(rgb):
    r, g, b = (srgb_to_linear(c) for c in rgb)
    l, m, s = cbrt(OK_L[0] * r + OK_L[1] * g + OK_L[2] * b), cbrt(OK_M[0] * r + OK_M[1] * g + OK_M[2] * b), cbrt(OK_S[0] * r + OK_S[1] * g + OK_S[2] * b)
    return (OK_L_[0] * l + OK_L_[1] * m + OK_L_[2] * s,
            OK_A_[0] * l + OK_A_[1] * m + OK_A_[2] * s,
            OK_B_[0] * l + OK_B_[1] * m + OK_B_[2] * s)


def oklab_distance(p, q) -> float:
    a, b = oklab(p), oklab(q)
    return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))


def simulate(rgb, kind: str) -> list:
    """Viénot–Brettel 二色觉模拟,返回 sRGB 8-bit 整数三元组。"""
    lin = [srgb_to_linear(c) for c in rgb]
    L = RGB2LMS[0][0] * lin[0] + RGB2LMS[0][1] * lin[1] + RGB2LMS[0][2] * lin[2]
    M = RGB2LMS[1][0] * lin[0] + RGB2LMS[1][1] * lin[1] + RGB2LMS[1][2] * lin[2]
    S = RGB2LMS[2][0] * lin[0] + RGB2LMS[2][1] * lin[1] + RGB2LMS[2][2] * lin[2]
    if kind == "protan":
        L = 2.02344 * M - 2.52581 * S
    elif kind == "deutan":
        M = 0.494207 * L + 1.24827 * S
    elif kind == "tritan":
        S = -0.395913 * L + 0.801109 * M
    else:
        raise ValueError(kind)
    return [linear_to_srgb_channel(LMS2RGB[0][0] * L + LMS2RGB[0][1] * M + LMS2RGB[0][2] * S),
            linear_to_srgb_channel(LMS2RGB[1][0] * L + LMS2RGB[1][1] * M + LMS2RGB[1][2] * S),
            linear_to_srgb_channel(LMS2RGB[2][0] * L + LMS2RGB[2][1] * M + LMS2RGB[2][2] * S)]


def judge(a, b, kind, thr):
    d = oklab_distance(simulate(a, kind), simulate(b, kind))
    return {"distance": round(d, 12), "distinguishable": d >= thr, "simA": simulate(a, kind), "simB": simulate(b, kind)}


def main() -> int:
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    cfg = json.load(open(os.path.join(ROOT, "config/sp04_colorblind.json"), encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)
    tokens, thr = spec["tokens"], cfg["oklabThreshold"]
    simulated = {k: {n: simulate(v, k) for n, v in tokens.items()} for k in cfg["types"]}

    pair_rows = []
    for a, b in cfg["pairs"]:
        base = oklab_distance(tokens[a], tokens[b])
        pair_rows.append({"pair": f"{a}/{b}", "baseDistance": round(base, 12), "baseDistinguishable": base >= thr,
                          "perType": {k: judge(tokens[a], tokens[b], k, thr) for k in cfg["types"]}})

    iso = cfg["isoLuminance"]
    ia, ib = iso["a"], iso["b"]
    base = oklab_distance(ia, ib)
    iso_row = {"pair": "isoRed/isoGreen", "label": iso["label"], "a": ia, "b": ib,
               "baseDistance": round(base, 12), "baseDistinguishable": base >= thr,
               "perType": {k: judge(ia, ib, k, thr) for k in cfg["types"]}}

    floor = spec["contrastFloor10"]
    contrast_rows = [{"pair": f"{a}/{b}", "contrast10": contrast10(tokens[a], tokens[b]), "floor10": floor,
                      "pass": contrast10(tokens[a], tokens[b]) >= floor} for a, b in cfg["contrastPairs"]]

    summary = {"source": "sim/sp04_colorblind.py", "specVersion": spec["specVersion"], "tokenCount": len(tokens),
               "oklabThreshold": thr, "types": cfg["types"], "typeLabels": cfg["typeLabels"], "simulated": simulated,
               "pairs": pair_rows, "isoLuminance": iso_row, "contrast": contrast_rows, "contrastFloor10": floor}
    with open(os.path.join(OUT, "sp04_colorblind.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    iso_collapsed = [t for t in cfg["types"] if iso_row["baseDistinguishable"] and not iso_row["perType"][t]["distinguishable"]]
    print(f"isoRed/isoGreen base={iso_row['baseDistance']:.4f} "
          f"deutan={iso_row['perType']['deutan']['distance']:.4f} collapsed={iso_collapsed}")
    print(f"ok/danger base={pair_rows[0]['baseDistance']:.4f} deutan={pair_rows[0]['perType']['deutan']['distance']:.4f}")
    print(f"contrastFloor10={floor} failing={[r['pair'] for r in contrast_rows if not r['pass']]}")
    print(f"danger/surface contrast10={contrast_rows[0]['contrast10']}")
    print("wrote out/sp04_colorblind.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑输出:

isoRed/isoGreen base=0.3755 deutan=0.0656 collapsed=['deutan']
ok/danger base=0.3638 deutan=0.1369
contrastFloor10=45 failing=['danger/surface']
danger/surface contrast10=31
wrote out/sp04_colorblind.json

三条结论:① 等亮度红/绿会塌陷——isoRed/isoGreen 正常距离 0.3755(远超阈值 0.08),绿色盲下只剩 0.0656,掉到阈值以下,两色在绿色盲眼里几乎合并,这正是「红=危险、绿=安全」为什么危险。② 亮度差能救命——ok/danger(项目的绿与红)正常 0.3638,绿色盲下 0.1369,虽掉了一半但仍在阈值以上,因为它俩亮度本来就不同(deutan 只破坏红绿通道、不破坏亮度),这是色盲友好配色最实用的一条:别只靠色相区分,拉开亮度。③ 门禁抓到一个真实违例——danger/surface 的对比度 ×10 只有 31,低于下限 45,failing=['danger/surface'];这不是我们造的,而是共享真源里 danger 压在 surface 上的真实规格问题,门禁的意义就在于它会拦下真东西。

4.3.4 P 层:TS 内核与跨语言对照

P 层 TS 内核复用 core/src/sp00/spec.ts 的 sRGB 常数与 luminance/contrast10,自实现同一套 Viénot 矩阵。模拟后的 RGB 8-bit 整数是整数类,两语言逐位一致。

// file: core/src/sp04/colorblind.ts
// tier: P
// verify: node --test 'core/test/sp04_*.test.ts'
// 第 4.3 节 P 层内核:Viénot–Brettel 二色觉模拟的 TS 侧。**与 sim/sp04_colorblind.py 同源**,
// 自己实现矩阵(本机无 colour 库),输出 sRGB 8-bit 整数三元组。
// 复用 core/src/sp00/spec.ts 的 srgbToLinear / luminance / contrast10。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { srgbToLinear, luminance, contrast10, SRGB_DIVISOR, SRGB_SCALE, SRGB_OFFSET, SRGB_GAMMA } from "../sp00/spec.ts";

export type RGB = number[];
export type Dichromacy = "protan" | "deutan" | "tritan";

// Viénot 1999:linear RGB → LMS 与 LMS → linear RGB(逆),两语言同源
const RGB2LMS = [
  [0.15514, 0.54312, -0.03286],
  [-0.15514, 0.45684, 0.03286],
  [0.0, 0.0, 0.01608],
];
const LMS2RGB = [
  [2.944812906607, -3.500977991936, 13.172182147147],
  [1.0000400016, 1.0000400016, 0.0],
  [0.0, 0.0, 62.189054726368],
];

function linearToSrgbChannel(v: number): number {
  const s = v <= 0.0031308 ? v * SRGB_DIVISOR : SRGB_SCALE * Math.pow(v, 1 / SRGB_GAMMA) - SRGB_OFFSET;
  return Math.max(0, Math.min(255, Math.round(s * 255)));
}

/** Viénot–Brettel 二色觉模拟,返回 sRGB 8-bit 整数三元组。 */
export function simulateDichromacy(rgb: RGB, kind: Dichromacy): RGB {
  const lin = rgb.map((c) => srgbToLinear(c));
  let L = RGB2LMS[0][0] * lin[0] + RGB2LMS[0][1] * lin[1] + RGB2LMS[0][2] * lin[2];
  let M = RGB2LMS[1][0] * lin[0] + RGB2LMS[1][1] * lin[1] + RGB2LMS[1][2] * lin[2];
  let S = RGB2LMS[2][0] * lin[0] + RGB2LMS[2][1] * lin[1] + RGB2LMS[2][2] * lin[2];
  if (kind === "protan") L = 2.02344 * M - 2.52581 * S;
  else if (kind === "deutan") M = 0.494207 * L + 1.24827 * S;
  else if (kind === "tritan") S = -0.395913 * L + 0.801109 * M;
  const r = LMS2RGB[0][0] * L + LMS2RGB[0][1] * M + LMS2RGB[0][2] * S;
  const g = LMS2RGB[1][0] * L + LMS2RGB[1][1] * M + LMS2RGB[1][2] * S;
  const b = LMS2RGB[2][0] * L + LMS2RGB[2][1] * M + LMS2RGB[2][2] * S;
  return [linearToSrgbChannel(r), linearToSrgbChannel(g), linearToSrgbChannel(b)];
}

export function contrast10Of(a: RGB, b: RGB): number {
  return contrast10(a, b);
}

export { luminance };

P 层测试读 Python 落盘的 out/sp04_colorblind.json:模拟后的 RGB 是整数类,assert.deepEqual 逐位一致;同时锁死两条判据——等亮度红/绿在绿色盲下塌陷、danger/surface 低于对比度下限。

// file: core/test/sp04_colorblind.test.ts
// tier: P
// verify: node --test 'core/test/sp04_*.test.ts'
// 第 4.3 节跨语言对照:读 sim/sp04_colorblind.py 落盘的 out/sp04_colorblind.json,断言
// 「模拟后的 RGB 8-bit 整数逐位一致、对比度 ×10 逐位一致」,并锁死两条判据:
// ① 等亮度红/绿在绿色盲下塌陷;② danger/surface 低于对比度下限(真实规格违例)。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { oklabDistance } from "../src/sp00/spec.ts";
import { simulateDichromacy, contrast10Of } from "../src/sp04/colorblind.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp04_colorblind.json"), "utf8"));
const TYPES: ("protan" | "deutan" | "tritan")[] = ["protan", "deutan", "tritan"];

test("二色觉模拟:8 个 token × 3 型,RGB 8-bit 整数逐位一致", () => {
  let checked = 0;
  for (const [name, rgb] of Object.entries(spec.tokens) as [string, number[]][]) {
    for (const kind of TYPES) {
      const mine = simulateDichromacy(rgb, kind);
      const theirs = py.simulated[kind][name] as number[];
      assert.deepEqual(mine, theirs, `${name}/${kind}`);
      checked += 1;
    }
  }
  assert.equal(checked, Object.keys(spec.tokens).length * 3);
});

test("对比度 ×10:整数类逐位一致,并判定 danger/surface 是否过下限(含真实违例)", () => {
  for (const row of py.contrast) {
    const [a, b] = row.pair.split("/");
    assert.equal(contrast10Of(spec.tokens[a], spec.tokens[b]), row.contrast10, row.pair);
    assert.equal(row.pass, row.contrast10 >= spec.contrastFloor10);
  }
  assert.equal(py.contrast.find((r: { pair: string }) => r.pair === "danger/surface").contrast10, 31);
});

test("等亮度红/绿:正常可区分,绿色盲下塌陷到阈值以下", () => {
  const iso = py.isoLuminance;
  assert.ok(iso.baseDistance >= py.oklabThreshold, `正常距离 ${iso.baseDistance}`);
  const d = oklabDistance(simulateDichromacy(iso.a, "deutan"), simulateDichromacy(iso.b, "deutan"));
  assert.ok(d < py.oklabThreshold, `绿色盲下 ${d} 应低于阈值 ${py.oklabThreshold}`);
  assert.ok(Math.abs(d - iso.perType.deutan.distance) <= 1e-9);
});

test("token 对 ok/danger:亮度差把红绿从塌陷里救回来(模拟后仍可区分)", () => {
  assert.ok(py.pairs.find((r: { pair: string }) => r.pair === "ok/danger").baseDistinguishable);
  const d = oklabDistance(simulateDichromacy(spec.tokens.ok, "deutan"), simulateDichromacy(spec.tokens.danger, "deutan"));
  assert.ok(d >= py.oklabThreshold, `deutan 距离 ${d} 应仍高于阈值`);
});

本机跑 node --test 'core/test/sp04_*.test.ts',本节相关的 4 条全绿(整卷共 14 条,见 4.1):

✔ 二色觉模拟:8 个 token × 3 型,RGB 8-bit 整数逐位一致
✔ 对比度 ×10:整数类逐位一致,并判定 danger/surface 是否过下限(含真实违例)
✔ 等亮度红/绿:正常可区分,绿色盲下塌陷到阈值以下
✔ token 对 ok/danger:亮度差把红绿从塌陷里救回来(模拟后仍可区分)
ℹ pass 4 / ℹ fail 0

跨语言摘要(digest):模拟后的 RGB 8-bit 整数 24/24 逐位一致、0 处不符;对比度 ×10 4/4 逐位一致。这两类是整数类,两语言必须逐位一致;而可区分性用的 OKLab 距离是派生量,只按容差 1e-9 断言(等亮度对绿色盲距离实测偏差 < 1e-9)。三种量级——整数逐位、派生量容差、布尔序关系——在这一节里全齐了。

小结

  • 红绿二色觉占男性约 8%,「红=危险、绿=安全」对他们是失效的;门禁两条:可区分性(模拟后 OKLab 距离 ≥ 0.08)与对比度下限(×10 ≥ 45)。
  • 实测:等亮度红/绿在绿色盲下从 0.3755 塌到 0.0656;而 ok/danger 靠亮度差在绿色盲下仍有 0.1369——拉开亮度是色盲友好配色的第一手段。门禁还抓到真实违例 danger/surface 对比度 ×10 只有 31 < 45。
  • 跨语言:模拟 RGB 与对比度 ×10 是整数类、逐位一致;OKLab 距离是派生量、容差内一致。

边界与收口:真显示设备核对、真色觉异常用户测试都需要真实硬件与真实被试,命令行不可复现(标 X);本节模拟只是算法级近似(Viénot–Brettel 1999 对二色觉精确、对异常三色觉是近似),不代替真实用户测试。第 4 章到此收口:sRGB 与线性空间定了「怎么算光」,OKLab/OKLCH 给了「色相差多远」的尺子,色盲安全与对比度下限把尺子变成能拦下真问题的硬门禁。下一章转入命名与资产组织。

阅读导航:上一节:4.2 OKLab/OKLCH:感知均匀的色彩距离 · 下一节:5.1 命名规则:前缀/类型/变体/版本 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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