4.1 已经证明:对比度只由相对亮度决定,与色相无关——琥珀与天蓝对比度都是 69,可肉眼一看就知道是两个颜色。这说明「对比度合格」远不足以判断「两个颜色分得开」。要回答后者,需要一个感知均匀的空间。这与 数据可视化中的无障碍配色 关心的问题同源:图表配色也要求「相邻色系在色觉异常下仍可分辨」,用的正是同一类感知距离判据;本节把它落到游戏美术的 token 调色板上。
4.2 OKLab/OKLCH:感知均匀的色彩距离
4.2.1 为什么 HSL 不够用
HSL/HSV 是「取色方便」的坐标,不是「感知均匀」的坐标。把色相等间隔地转一圈,相邻两色的肉眼差异并不相等——绿区一步顶红区好几步。做「主色和辅色够不够分得开」「图标底色和描边是不是太近」这类判断时,直接量 HSL 的距离会骗人。
Björn Ottosson 2020 年提出的 OKLab 专门为此设计:同样距离对应同样感知差异,比 CIELAB 更均匀、计算也更快。OKLCH 是它的极坐标形式——L(亮度)、C(彩度)、h(色相),对美术比直角坐标直观得多。后面 4.3 的色盲可区分性判据,也建在 OKLab 距离上。
4.2.2 D 层:把样例写成配置
下面这份 D 层配置 config/sp04_oklab.json 只声明要复算的样例:等间隔取色的色相数,以及 HSL 与 OKLCH 各自的参考参数。
{
"observation": "公开可观察,观察时点 2026-10;OKLab/OKLCH 由 Björn Ottosson 于 2020 年公开,矩阵常数取自其公开说明,本文件只声明要复算的样例",
"note": "第 4.2 节:OKLab/OKLCH 复算与「感知均匀性」对照(HSL 等间隔取色 vs OKLCH 等间隔取色)",
"hueCount": 12,
"hslRef": { "s": 0.7, "l": 0.5 },
"oklchRef": { "L": 0.65, "C": 0.13 }
}
4.2.3 S 层:OKLab 坐标、距离与均匀性对照
S 层脚本读共享真源(只读)与上面的配置,做三件事:算 8 个 token 的 OKLab/OKLCH;对 HSL 与 OKLCH 各做一圈 12 色等间隔取色,比较相邻 OKLab 距离的极差;再算 §0.2 琥珀/天蓝的 OKLab 距离。OKLab 用 Ottosson 同一组矩阵常数,与 core/src/sp00/spec.ts 同源。
#!/usr/bin/env python3
# file: sim/sp04_oklab.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp04_oklab.py
"""OKLab / OKLCH:感知均匀的色彩距离(第 4.2 节 S 层)。
读共享真源 config/visual_spec.json(只读)与 config/sp04_oklab.json:8 个 token 的 OKLab/OKLCH;
HSL 等间隔取色 vs OKLCH 等间隔取色的相邻 OKLab 距离极差;§0.2 琥珀/天蓝(对比度相同但 OKLab 距离很大)。
落盘 out/sp04_oklab.json。Ottosson 同一组矩阵常数(正变换与逆变换),与 core/src/sp00/spec.ts 同源。
"""
from __future__ import annotations
import colorsys
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
# 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)
# Ottosson OKLab 逆变换(OKLab → linear RGB)
INV_L_ = (1.0, 0.3963377774, 0.2158037573)
INV_M_ = (1.0, -0.1055613458, -0.0638541728)
INV_S_ = (1.0, -0.0894841775, -1.2914855480)
INV_R = (4.0767416621, -3.3077115913, 0.2309699292)
INV_G = (-1.2684380046, 2.6097574011, -0.3413193965)
INV_B = (-0.0041960863, -0.7034186147, 1.7076147010)
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 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 oklab_to_srgb(lab) -> list:
L, a, b = lab
l, m, s = ((INV_L_[0] * L + INV_L_[1] * a + INV_L_[2] * b) ** 3,
(INV_M_[0] * L + INV_M_[1] * a + INV_M_[2] * b) ** 3,
(INV_S_[0] * L + INV_S_[1] * a + INV_S_[2] * b) ** 3)
return [linear_to_srgb_channel(INV_R[0] * l + INV_R[1] * m + INV_R[2] * s),
linear_to_srgb_channel(INV_G[0] * l + INV_G[1] * m + INV_G[2] * s),
linear_to_srgb_channel(INV_B[0] * l + INV_B[1] * m + INV_B[2] * s)]
def oklch_to_srgb(L, C, h_deg) -> list:
h = math.radians(h_deg)
return oklab_to_srgb((L, C * math.cos(h), C * math.sin(h)))
def oklch(rgb):
L, a, b = oklab(rgb)
return (L, math.sqrt(a * a + b * b), math.degrees(math.atan2(b, a)) % 360)
def hsl_to_srgb(h_deg, s, l) -> list:
r, g, b = colorsys.hls_to_rgb((h_deg % 360) / 360, l, s)
return [round(r * 255), round(g * 255), round(b * 255)]
def sweep_stats(colors):
ds = [round(oklab_distance(colors[i], colors[(i + 1) % len(colors)]), 12) for i in range(len(colors))]
return ds, round(max(ds) / min(ds), 6)
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_oklab.json"), encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
tokens = spec["tokens"]
lab = {k: [round(x, 12) for x in oklab(v)] for k, v in tokens.items()}
lch = {k: [round(x, 12) for x in oklch(v)] for k, v in tokens.items()}
n = cfg["hueCount"]
hsl_colors = [hsl_to_srgb(round(i * 360 / n), cfg["hslRef"]["s"], cfg["hslRef"]["l"]) for i in range(n)]
oklch_colors = [oklch_to_srgb(cfg["oklchRef"]["L"], cfg["oklchRef"]["C"], round(i * 360 / n)) for i in range(n)]
hsl_ds, hsl_spread = sweep_stats(hsl_colors)
oklch_ds, oklch_spread = sweep_stats(oklch_colors)
amber, sky = [245, 158, 11], [56, 189, 248]
summary = {"source": "sim/sp04_oklab.py", "specVersion": spec["specVersion"], "oklab": lab, "oklch": lch,
"uniformity": {"hueCount": n,
"hsl": {"colors": hsl_colors, "adjacentDistances": hsl_ds, "spread": hsl_spread},
"oklch": {"colors": oklch_colors, "adjacentDistances": oklch_ds, "spread": oklch_spread}},
"amberSky": {"amber": amber, "sky": sky, "oklabDistance": round(oklab_distance(amber, sky), 12)}}
with open(os.path.join(OUT, "sp04_oklab.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"tokens={len(tokens)} oklab computed")
print(f"hsl adjacent OKLab dist spread={hsl_spread:.4f} oklch spread={oklch_spread:.4f}")
print(f"hsl min/max={min(hsl_ds):.6f}/{max(hsl_ds):.6f} oklch min/max={min(oklch_ds):.6f}/{max(oklch_ds):.6f}")
print(f"amber/sky oklabDistance={summary['amberSky']['oklabDistance']:.12f}")
print("wrote out/sp04_oklab.json")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出:
tokens=8 oklab computed
hsl adjacent OKLab dist spread=4.5509 oklch spread=1.1451
hsl min/max=0.056292/0.256176 oklch min/max=0.060082/0.068796
amber/sky oklabDistance=0.300550490960
wrote out/sp04_oklab.json
逐条读。均匀性对照是本节重点:同样 12 色等间隔取色,HSL 一圈的相邻 OKLab 距离在 0.056292 到 0.256176 之间波动,极差比 4.5509;换用 OKLCH 等间隔取色,相邻距离被压在 0.060082 到 0.068796,极差比降到 1.1451。也就是说,HSL 的「等间隔」在感知上差了近 4 倍,OKLCH 的「等间隔」基本真的等间隔——这就是「取色均匀 ≠ 看起来均匀」。另一行:琥珀与天蓝的 OKLab 距离是 0.300550490960——对比度相同(都是 69)而感知距离很大,正好补上 4.1 留下的缺口。
4.2.4 P 层:TS 内核与跨语言对照
P 层 TS 内核复用 core/src/sp00/spec.ts 的 oklab/oklabDistance 与 sRGB 常数(不重写),只新增 OKLCH 极坐标、逆变换与 HSL→sRGB。
// file: core/src/sp04/oklch.ts
// tier: P
// verify: node --test 'core/test/sp04_*.test.ts'
// 第 4.2 节 P 层内核:OKLab/OKLCH 的 TS 侧消费者。**复用 core/src/sp00/spec.ts 的 oklab /
// oklabDistance 与 sRGB 常数**(Ottosson 同一组常数),只新增 OKLCH 极坐标、逆变换与 HSL→sRGB。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { oklab, oklabDistance, SRGB_DIVISOR, SRGB_SCALE, SRGB_OFFSET, SRGB_GAMMA } from "../sp00/spec.ts";
export type RGB = number[];
// Ottosson OKLab 逆变换常数(与 sim/sp04_oklab.py 同源)
const INV_L_ = [1.0, 0.3963377774, 0.2158037573];
const INV_M_ = [1.0, -0.1055613458, -0.0638541728];
const INV_S_ = [1.0, -0.0894841775, -1.291485548];
const INV_R = [4.0767416621, -3.3077115913, 0.2309699292];
const INV_G = [-1.2684380046, 2.6097574011, -0.3413193965];
const INV_B = [-0.0041960863, -0.7034186147, 1.707614701];
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)));
}
/** OKLab(L,a,b) → OKLCH(L,C,h°)。 */
export function oklchOf(rgb: RGB): number[] {
const [L, a, b] = oklab(rgb);
return [L, Math.sqrt(a * a + b * b), ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360];
}
/** OKLab → sRGB 8-bit(Ottosson 逆变换)。派生量:跨语言按容差断言,不逐位比。 */
export function oklabToSrgb(lab: number[]): RGB {
const [L, a, b] = lab;
const l_ = INV_L_[0] * L + INV_L_[1] * a + INV_L_[2] * b;
const m_ = INV_M_[0] * L + INV_M_[1] * a + INV_M_[2] * b;
const s_ = INV_S_[0] * L + INV_S_[1] * a + INV_S_[2] * b;
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
return [
linearToSrgbChannel(INV_R[0] * l + INV_R[1] * m + INV_R[2] * s),
linearToSrgbChannel(INV_G[0] * l + INV_G[1] * m + INV_G[2] * s),
linearToSrgbChannel(INV_B[0] * l + INV_B[1] * m + INV_B[2] * s),
];
}
/** OKLCH(L,C,h°) → sRGB 8-bit。 */
export function oklchToSrgb(L: number, C: number, hDeg: number): RGB {
const h = (hDeg * Math.PI) / 180;
return oklabToSrgb([L, C * Math.cos(h), C * Math.sin(h)]);
}
/** 标准 HSL → sRGB 8-bit(与 Python colorsys.hls_to_rgb 同算法)。 */
export function hslToSrgb(hDeg: number, s: number, l: number): RGB {
const h = (((hDeg % 360) + 360) % 360) / 360;
const m2 = l <= 0.5 ? l * (1 + s) : l + s - l * s;
const m1 = 2 * l - m2;
const v = (hue: number): number => {
const u = ((hue % 1) + 1) % 1;
if (u < 1 / 6) return m1 + (m2 - m1) * u * 6;
if (u < 1 / 2) return m2;
if (u < 2 / 3) return m1 + (m2 - m1) * (2 / 3 - u) * 6;
return m1;
};
return [v(h + 1 / 3), v(h), v(h - 1 / 3)].map((x) => Math.round(x * 255));
}
export { oklabDistance };
P 层测试读 Python 落盘的 out/sp04_oklab.json 做对照:OKLab/OKLCH 坐标属派生量,按容差断言;「OKLCH 取色比 HSL 更均匀」这条序关系是布尔结论,直接锁死。
// file: core/test/sp04_oklch.test.ts
// tier: P
// verify: node --test 'core/test/sp04_*.test.ts'
// 第 4.2 节跨语言对照:读 sim/sp04_oklab.py 落盘的 out/sp04_oklab.json,断言 OKLab/OKLCH
// 「容差内一致」,并锁死「OKLCH 取色比 HSL 取色更均匀」这条序关系。
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 { oklab, oklabDistance } from "../src/sp00/spec.ts";
import { oklchOf, oklchToSrgb, hslToSrgb } from "../src/sp04/oklch.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_oklab.json"), "utf8"));
const TOL: Record<string, number> = spec.tolerance;
test("OKLab / OKLCH:8 个 token 容差内一致", () => {
let maxDelta = 0;
for (const [name, rgb] of Object.entries(spec.tokens) as [string, number[]][]) {
const lab = oklab(rgb), theirs = py.oklab[name] as number[];
const lch = oklchOf(rgb), thLch = py.oklch[name] as number[];
for (let i = 0; i < 3; i += 1) maxDelta = Math.max(maxDelta, Math.abs(lab[i] - theirs[i]));
maxDelta = Math.max(maxDelta, Math.abs(lch[0] - thLch[0]), Math.abs(lch[1] - thLch[1]));
maxDelta = Math.max(maxDelta, Math.min(Math.abs(lch[2] - thLch[2]), 360 - Math.abs(lch[2] - thLch[2])));
}
assert.ok(maxDelta <= TOL.oklab, `最大偏差 ${maxDelta} > ${TOL.oklab}`);
});
test("感知均匀性:OKLCH 等间隔取色的相邻距离极差 < HSL 等间隔取色", () => {
const n: number = py.uniformity.hueCount;
const hslColors: number[][] = Array.from({ length: n }, (_, i) => hslToSrgb((i * 360) / n, 0.7, 0.5));
const oklchColors: number[][] = Array.from({ length: n }, (_, i) => oklchToSrgb(0.65, 0.13, (i * 360) / n));
const spread = (cs: number[][]): number => {
const ds = cs.map((c, i) => oklabDistance(c, cs[(i + 1) % n]));
return Math.max(...ds) / Math.min(...ds);
};
const hs = spread(hslColors), os = spread(oklchColors);
assert.ok(os < hs, `OKLCH 极差 ${os} 应小于 HSL 极差 ${hs}`);
assert.ok(Math.abs(hs - py.uniformity.hsl.spread) <= 1e-6 && Math.abs(os - py.uniformity.oklch.spread) <= 1e-6);
});
test("对比度相同不等于感知相同:琥珀与天蓝 OKLab 距离很大", () => {
const as = py.amberSky, d = oklabDistance(as.amber, as.sky);
assert.ok(Math.abs(d - as.oklabDistance) <= TOL.okdist);
assert.ok(d > 0.25, `琥珀/天蓝 OKLab 距离 ${d} 应显著大于 0`);
});
本机跑 node --test 'core/test/sp04_*.test.ts',本节相关的 3 条全绿(整卷共 15 条,见 4.1):
✔ OKLab / OKLCH:8 个 token 容差内一致
✔ 感知均匀性:OKLCH 等间隔取色的相邻距离极差 < HSL 等间隔取色
✔ 对比度相同不等于感知相同:琥珀与天蓝 OKLab 距离很大
ℹ pass 3 / ℹ fail 0
跨语言摘要(digest):OKLab 坐标实测最大偏差 4.685245e-13、OKLCH 的 L/C 最大偏差 4.640732e-13、琥珀/天蓝 OKLab 距离偏差 6.455947e-14,全部落在声明容差 1e-9 内。这些是派生量,两语言不按逐位比,只能按容差断言——这与 4.1 里「对比度 ×10 逐位一致」形成对照,正是「整数类逐位、派生量容差」两条纪律的分工。
4.2.5 真渲染与真显示设备不可复现
同样一条边界:「两个颜色在真机屏幕上看起来差多少」需要真实显示设备与真实观察者,命令行不可复现(标 X)。本节能算的是 OKLab 坐标与距离这些数值账;它给出「够不够分得开」的定量参考,不代替在真机上用眼睛校一遍。
小结
- HSL 等间隔 ≠ 感知等间隔:实测 12 色一圈,HSL 的相邻 OKLab 距离极差比
4.5509,OKLCH 只有1.1451。 - OKLab/OKLCH 是「色相差多远」的可靠尺子;对比度相同(都是 69)的琥珀与天蓝,OKLab 距离高达
0.300550490960。 - 跨语言口径:坐标与距离是派生量,容差
1e-9内一致(OKLab 坐标最大偏差4.685245e-13);序关系(OKLCH 更均匀)是布尔结论,直接锁死。
有了「感知距离」这把尺子,下一节就能量化「色觉异常下两个颜色是否还分得开」。
阅读导航:上一节:4.1 sRGB 与线性空间:美术为什么要在意 · 下一节:4.3 色盲安全与对比度下限 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。