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 命名规则:前缀/类型/变体/版本 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。