3.3 跨场景色彩一致性

同一个 token 在五个场景里必须长得一样。本节把《星屑旅店》五个场景的 19 个元素按 key 分组,算出跨场景一致性千分比 800、抓出两处色相漂移,并用 B 层把容差阈值从 0.002 扫到 0.5,说明软告警可调而硬漂移不可糊弄,最后给出可复算的一致性检查。

同一个 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 与线性空间:美术为什么要在意 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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