同一个 token 在五个场景里必须长得一样——这句话听起来是废话,做起来却最容易翻车。原因很朴素:一个 UI 设计师在夜景里手动吸了一个「更亮的橙色」,代码里就多了一个 #2dd4bf,而没人知道它和主色 accent 本该是同一个。本节把「跨场景一致性」变成一个可算的数:按元素 key 分组,同一 key 在不同场景必须解析到同一个颜色。分工先写清:色彩空间与 HDR 的完整原理在 色彩管理与 HDR 管线
,本节只用它的相对亮度与 OKLab 口径,不重推原理;3.1 给的对比度下限、3.2 给的三层 token 表,是本节的两个输入。
场景表:元素 → token 引用
下面这段是 D 层配置,即《星屑旅店》五个场景的元素表。多数元素只写 token;少数写了 literal(硬编码 hex),这是要抓的漂移:lamp 抄了 #f59e0b(与主色恰好相同),price-tag 抄了 #f0a010(近似但不相同),夜景的 wall 抄了 #1f2833、sign 抄了 #2dd4bf(换了色相)。
{
"version": 1,
"project": "stardust-inn",
"note": "5 个场景的元素 → token 引用表。element 只允许引用 semantic token;若给出 literal(hex),则视为硬编码,需与它声明的 token 解析值做跨场景一致性比对。driftToleranceOklab 是「近似漂移」的 OKLab 距离上限。",
"canonical": "semantic",
"driftToleranceOklab": 0.02,
"scenes": [
{ "id": "lobby", "cn": "旅店大堂", "elements": [
{ "key": "wall", "token": "bg" }, { "key": "counter", "token": "surface" },
{ "key": "sign", "token": "accent" }, { "key": "hint", "token": "muted" } ]},
{ "id": "guestroom", "cn": "客房", "elements": [
{ "key": "wall", "token": "bg" }, { "key": "bed", "token": "surface" },
{ "key": "lamp", "token": "accent", "literal": "#f59e0b" }, { "key": "hint", "token": "muted" } ]},
{ "id": "shop", "cn": "商店", "elements": [
{ "key": "wall", "token": "bg" }, { "key": "counter", "token": "surface" },
{ "key": "price-tag", "token": "accent", "literal": "#f0a010" }, { "key": "sold-out", "token": "danger" } ]},
{ "id": "garden", "cn": "花园", "elements": [
{ "key": "wall", "token": "bg" }, { "key": "ground", "token": "surface" },
{ "key": "leaf", "token": "ok" }, { "key": "hint", "token": "muted" } ]},
{ "id": "night", "cn": "夜景", "elements": [
{ "key": "wall", "token": "bg", "literal": "#1f2833" },
{ "key": "sign", "token": "accent", "literal": "#2dd4bf" }, { "key": "hint", "token": "muted" } ]}
]
}
元素内漂移与跨场景一致性(S 层)
下面这段是 S 层脚本:先算每个元素的元素内漂移(硬编码值离它声明的 token 有多远),再按 key 分组算跨场景一致性千分比。
#!/usr/bin/env python3
# file: sim/sp03_consistency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp03_consistency.py
"""跨场景色彩一致性:读 config/visual_spec.json(色值真源)与 config/sp03_scenes.json,
对 5 个场景的每个元素做两笔账:
1. 元素内漂移:若元素带 literal(硬编码 hex),与它声明的 token 解析值比 OKLab 距离,
分 clean(无硬编码)/ exact(同色)/ soft(容差内)/ hard(越界);
2. 跨场景一致性:按元素 key 分组,同一 key 在不同场景必须解析到同一颜色;
统计不一致的 key 数与一致性千分比(整数地板除)。
产出 out/sp03_consistency.json。计数与千分比为整数类(逐位一致);
OKLab 距离为色彩派生量(按 spec.tolerance 容差一致)。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
from consume import oklab_distance # noqa: E402
SPEC = os.path.join(ROOT, "config/visual_spec.json")
SCENES = os.path.join(ROOT, "config/sp03_scenes.json")
OUT = os.path.join(ROOT, "out")
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
def hex_to_rgb(h):
s = h.lstrip("#")
return [int(s[i:i + 2], 16) for i in (0, 2, 4)]
def rgb_to_hex(rgb):
return "#" + "".join(f"{c:02x}" for c in rgb)
def main() -> int:
spec, cfg = load(SPEC), load(SCENES)
tokens, tol = spec["tokens"], cfg["driftToleranceOklab"]
elements, by_key = [], {}
for sc in cfg["scenes"]:
for el in sc["elements"]:
canonical = tokens[el["token"]]
lit = el.get("literal")
if lit is None:
kind, d = "clean", 0.0
else:
d = oklab_distance(hex_to_rgb(lit), canonical)
kind = "exact" if d == 0.0 else ("soft" if d <= tol else "hard")
actual = hex_to_rgb(lit) if lit else canonical
rec = {"scene": sc["id"], "key": el["key"], "token": el["token"], "literal": lit,
"actualHex": rgb_to_hex(actual), "canonicalHex": rgb_to_hex(canonical),
"kind": kind, "distance": round(d, 12)}
elements.append(rec)
by_key.setdefault(el["key"], []).append(rec)
counts = {k: sum(1 for e in elements if e["kind"] == k)
for k in ("clean", "exact", "soft", "hard")}
groups = []
for key, els in sorted(by_key.items()):
colors = sorted({e["actualHex"] for e in els})
groups.append({"key": key, "scenes": len(els), "distinctColors": len(colors),
"colors": colors, "consistent": len(colors) == 1})
consistent_keys = sum(1 for g in groups if g["consistent"])
summary = {
"project": spec["project"], "sceneCount": len(cfg["scenes"]),
"elementCount": len(elements), "driftToleranceOklab": tol, "counts": counts,
"elements": elements, "keyGroups": groups, "totalKeys": len(groups),
"consistentKeys": consistent_keys,
"inconsistentKeys": [g["key"] for g in groups if not g["consistent"]],
"consistencyPermille": (consistent_keys * 1000) // len(groups) if groups else 0,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp03_consistency.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"scenes={len(cfg['scenes'])} elements={len(elements)} counts={counts}")
print(f"keys={len(groups)} consistent={consistent_keys} "
f"inconsistent={summary['inconsistentKeys']} permille={summary['consistencyPermille']}")
print("wrote out/sp03_consistency.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
scenes=5 elements=19 counts={'clean': 15, 'exact': 1, 'soft': 2, 'hard': 1}
keys=10 consistent=8 inconsistent=['sign', 'wall'] permille=800
wrote out/sp03_consistency.json
第一行:19 个元素里 15 个干净(无硬编码)、1 个 exact、2 个 soft、1 个 hard。第二行是跨场景账:10 个元素 key 里 8 个在所有场景颜色一致,2 个不一致(sign、wall),一致性千分比 800。sign 在大堂用 accent(#f59e0b)、在夜景用 #2dd4bf——同一个「招牌」在两个场景是两个色相;wall 在大堂用 bg(#1f2933)、在夜景用 #1f2833——差一点点,但确实是两个值。
把一致性写成 TS 内核(P 层)
下面这段是 P 层 TS 内核,复用 core/src/sp00/spec.ts 的 oklabDistance 做同一套分类与千分比计算。
// file: core/src/sp03/consistency.ts
// tier: P
// verify: node --test 'core/test/sp03_*.test.ts'
// 跨场景一致性的 TS 侧校验器。复用 sp00/spec.ts 的 oklabDistance,不重写转换常数。
// 计数与千分比是整数类(逐位一致);OKLab 距离是色彩派生量(容差内一致)。
import { oklabDistance } from "../sp00/spec.ts";
export function hexToRgb(hex: string): number[] {
const s = hex.replace("#", "");
return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16));
}
export function rgbToHex(rgb: number[]): string {
return "#" + rgb.map((c) => c.toString(16).padStart(2, "0")).join("");
}
/** 元素内漂移分类:clean / exact / soft / hard。 */
export function classify(literal: string | null, canonical: number[], tol: number): string {
if (literal === null) return "clean";
const d = oklabDistance(hexToRgb(literal), canonical);
if (d === 0) return "exact";
return d <= tol ? "soft" : "hard";
}
/** 跨场景一致性千分比:每个 key 的颜色集合大小为 1 才算一致。整数地板除。 */
export function consistencyPermille(keys: string[][]): number {
const total = keys.length;
if (total === 0) return 0;
let ok = 0;
for (const colors of keys) if (new Set(colors).size === 1) ok += 1;
return Math.floor((ok * 1000) / total);
}
配套测试读 Python 落盘的 out/sp03_consistency.json 做跨语言对照:
// file: core/test/sp03_consistency.test.ts
// tier: P
// verify: node --test 'core/test/sp03_*.test.ts'
// 跨语言对照:读 out/sp03_consistency.json,断言漂移分类与一致性千分比
// 「整数类逐位一致」、OKLab 距离「容差内一致」。
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 { hexToRgb, classify, consistencyPermille } from "../src/sp03/consistency.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp03_consistency.json"), "utf8"));
test("元素内漂移分类与 Python 逐位一致", () => {
assert.equal(py.elements.length, py.elementCount);
for (const e of py.elements) {
assert.equal(classify(e.literal, spec.tokens[e.token], py.driftToleranceOklab), e.kind, `${e.scene}/${e.key}`);
}
});
test("OKLab 距离容差内一致;千分比整数类逐位一致", () => {
let maxDelta = 0;
for (const e of py.elements) {
if (e.literal === null) continue;
maxDelta = Math.max(maxDelta, Math.abs(oklabDistance(hexToRgb(e.literal), spec.tokens[e.token]) - e.distance));
}
assert.ok(maxDelta <= spec.tolerance.okdist, `最大偏差 ${maxDelta}`);
const keys = py.keyGroups.map((g: { colors: string[] }) => g.colors);
assert.equal(consistencyPermille(keys), py.consistencyPermille);
assert.equal(py.consistentKeys + py.inconsistentKeys.length, py.totalKeys);
});
test("负例:色相漂移判 hard,同色判 exact,无硬编码判 clean", () => {
const amber = spec.tokens.accent;
assert.equal(classify("#f59e0b", amber, py.driftToleranceOklab), "exact");
assert.equal(classify("#2dd4bf", amber, py.driftToleranceOklab), "hard");
assert.equal(classify(null, amber, py.driftToleranceOklab), "clean");
});
✔ 元素内漂移分类与 Python 逐位一致
✔ OKLab 距离容差内一致;千分比整数类逐位一致
✔ 负例:色相漂移判 hard,同色判 exact,无硬编码判 clean
ℹ tests 3 / pass 3 / fail 0
阈值敏感度:这个下限该定多少(B 层)
「近似漂移」的判定依赖阈值 driftToleranceOklab。阈值是拍的,所以要有 B 层对抗「编数字」——把它扫一遍,看结论怎么动。
#!/usr/bin/env python3
# file: sim/sp03_consistency_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/sp03_consistency_bench.py
"""跨场景一致性的容差敏感度基准(B 层,软告警不硬失败)。
把「近似漂移」的 OKLab 距离上限从 0.002 扫到 0.500,看 hard(越界)与 soft(被吸收)
各有多少条。结论依赖阈值,故只给敏感度区间,不给单一「正确值」。
产出 out/sp03_consistency_bench.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
from consume import oklab_distance # noqa: E402
from sp03_consistency import hex_to_rgb, load, SPEC, SCENES, OUT # noqa: E402
SWEEP = (2, 5, 10, 20, 50, 100, 500)
def main() -> int:
spec, cfg = load(SPEC), load(SCENES)
pairs = [(f"{sc['id']}/{el['key']}",
oklab_distance(hex_to_rgb(el["literal"]), spec["tokens"][el["token"]]))
for sc in cfg["scenes"] for el in sc["elements"] if "literal" in el]
sweep = [{"tol": t / 1000.0,
"hard": sum(1 for _, d in pairs if d > t / 1000.0),
"soft": sum(1 for _, d in pairs if 0 < d <= t / 1000.0),
"exact": sum(1 for _, d in pairs if d == 0.0)} for t in SWEEP]
summary = {"project": spec["project"], "literalCount": len(pairs),
"pairs": [{"ref": r, "d": round(d, 12)} for r, d in pairs], "sweep": sweep,
"hardZeroAtTol": next((s["tol"] for s in sweep if s["hard"] == 0), None),
"note": "软告警:阈值选择改变结论,故只给敏感度区间,不给单一正确值。"}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp03_consistency_bench.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
for s in sweep:
print(f"tol={s['tol']:.3f} hard={s['hard']} soft={s['soft']} exact={s['exact']}")
print(f"literalCount={len(pairs)} hardZeroAtTol={summary['hardZeroAtTol']}")
print("wrote out/sp03_consistency_bench.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
tol=0.002 hard=3 soft=0 exact=1
tol=0.005 hard=2 soft=1 exact=1
tol=0.010 hard=1 soft=2 exact=1
tol=0.020 hard=1 soft=2 exact=1
tol=0.050 hard=1 soft=2 exact=1
tol=0.100 hard=1 soft=2 exact=1
tol=0.500 hard=0 soft=3 exact=1
literalCount=4 hardZeroAtTol=0.5
wrote out/sp03_consistency_bench.json
读法:阈值从 0.002 抬到 0.020,hard 从 3 掉到 1,说明 #f0a010 与 #1f2833 这两条「近似漂移」确实在阈值附近游走,属于软告警。但 #2dd4bf 那条硬漂移要把阈值一路抬到 0.5(正常容差 0.02 的 25 倍)才吸收得掉——这不是阈值问题,是真的换了色相。B 层因此只给区间、不给单一「正确值」:软告警可调,硬漂移不可糊弄。
一句话收束:跨场景一致性不是「肉眼看像不像」,而是「同一 key 的解析值是否逐位相同」——整数类逐位一致,色彩派生量(这里只有 OKLab 距离)容差内一致,实测最大偏差 4.9e-13。下一节进入色彩空间本身。
阅读导航:上一节:3.2 色彩 token 化:从色卡到可引用变量 · 下一节:4.1 sRGB 与线性空间:美术为什么要在意 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。