上一节的四维区间只覆盖色彩,回答不了「这两套主题到底有多像」。这一节把区间升级成风格向量:五维、每维归一化到千分比,再用加权 L1 算出六套主题两两之间的距离。站内《数据可视化中的视觉编码与感知》 从图表口径讲过「人眼怎么区分两个编码」,本节取的是游戏美术口径:不是讨论哪种编码更易读,而是给六套美术主题算一张同源可区分的距离矩阵——同一份规范实例化出来的六套主题,必须两两可分。上一节2.1 从「治愈」「硬核」到可测参数 负责「单套主题是否达标」,本节负责「多套主题是否互相打架」。
五维风格向量
在 2.1 的四维之上,本节重新组织成五维,每维都归一化到 0–1000 千分比:
| 维度 | 来源 | 归一化 | 类别 |
|---|---|---|---|
lumaMedianPermille | 四色 Rec.709 整数亮度中位数 | /255×1000 | 整数类,逐位一致 |
satMeanPermille | 四色 HSV 饱和度均值 | 本身即千分比 | 整数类,逐位一致 |
hueSpanPermille | 非灰谐色相跨度(整数度) | /360×1000 | 整数类,逐位一致 |
strokePermille | 描边占比 | 设计声明 | 整数类,逐位一致 |
detailPermille | 细节密度 | 设计声明 | 整数类,逐位一致 |
前三维从 sRGB 8-bit 整数真源算出来;后两维参考板给不出,只能由美术声明(strokePermille 是描边像素占 tile 边长的千分比,detailPermille 是有效细节像素占图块面积的千分比)。把声明值与算出来的值放进同一个向量,前提是它们都在同一个 0–1000 刻度上——这正是「归一化」的意义。
风格距离与色彩距离:两类量,两种口径
两个量必须分开,否则跨语言断言会当场翻车:
- 风格距离 = 五维加权 L1 的整数均值,权重
[2,2,1,3,3](明度、饱和各 2,色相跨度 1,描边、细节各 3)。全部整数运算、地板除,所以跨语言逐位一致。 - 色彩距离 = 两套调色板「均值色」的 OKLab 欧氏距离。OKLab 要过
pow与三次方根,浮点末位不可能跨语言相等,所以只能按声明容差断言(本套spec.tolerance为1e-9)。
配置在 config/sp02_style.json:六个主题包的四色真源与 2.1 的 config/sp02_adjectives.json 一致,本节在此之上加了 weights、dims、distinguishableFloorPermille(同源可区分下限,本套取 80),以及每个主题的 strokePermille / detailPermille(描边/细节)——依次为 pixel 620/780、lowpoly 120/210、handdrawn 480/520、voxel 300/640、retro-cyber 540/700、cozy-clean 150/260。
S 层:向量与距离矩阵
下面这段是 S 层脚本 sim/sp02_style_distance.py。它算五维向量、加权 L1 距离矩阵与 OKLab 色彩距离,落盘 out/sp02_style.json 供 P 层对照,并画一张距离条形图 out/sp02_style_map.svg。
#!/usr/bin/env python3
# file: sim/sp02_style_distance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp02_style_distance.py
"""风格向量与风格距离(S 层)。
风格向量五维、各归一化到 0-1000 千分比的整数:明度中位数 / 饱和度均值 / 色相跨度 /
描边占比 / 细节密度。**风格距离**=五维加权 L1 的整数均值(逐位一致);**色彩距离**=
两套调色板「均值色」的 OKLab 欧氏距离(浮点派生量,按 1e-9 容差断言)。
产出 out/sp02_style.json + .csv + .svg。
"""
import hashlib, itertools, json, math, os, sys
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/sp02_style.json")
OUT = os.path.join(ROOT, "out")
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):
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
def oklab(c):
r, g, b = (srgb_to_linear(x) for x in c)
l_, m_, s_ = (abs(v) ** (1 / 3) * (1 if v >= 0 else -1) for v in (
OK_L[0] * r + OK_L[1] * g + OK_L[2] * b,
OK_M[0] * r + OK_M[1] * g + OK_M[2] * b,
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 luma709(c):
return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000
def median_int(xs):
s = sorted(xs)
n = len(s)
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) // 2
def sat_permille(c):
mx, mn = max(c), min(c)
return 0 if mx == 0 else (mx - mn) * 1000 // mx
def hue_deg(c):
"""整数度色相(地板除,与全书整数纪律一致);灰返回 None。"""
mx, mn = max(c), min(c)
d = mx - mn
if d == 0:
return None
if mx == c[0]:
h = 60 * (c[1] - c[2]) / d
elif mx == c[1]:
h = 60 * ((c[2] - c[0]) / d + 2)
else:
h = 60 * ((c[0] - c[1]) / d + 4)
return int(h % 360)
def style_vector(theme):
cols = [theme["palette"][k] for k in sorted(theme["palette"])]
hues = [h for h in (hue_deg(c) for c in cols) if h is not None]
span = (max(hues) - min(hues)) if len(hues) >= 2 else 0
return [median_int([luma709(c) for c in cols]) * 1000 // 255,
sum(sat_permille(c) for c in cols) // len(cols), span * 1000 // 360,
int(theme["strokePermille"]), int(theme["detailPermille"])]
def style_distance(a, b, weights):
"""加权 L1 的整数均值 → 整数类,逐位一致。"""
return sum(w * abs(x - y) for w, x, y in zip(weights, a, b)) // sum(weights)
def oklab_mean_distance(pa, pb):
ma = [sum(oklab(pa[k])[i] for k in sorted(pa)) / len(pa) for i in range(3)]
mb = [sum(oklab(pb[k])[i] for k in sorted(pb)) / len(pb) for i in range(3)]
return math.sqrt(sum((ma[i] - mb[i]) ** 2 for i in range(3)))
def digest(vectors, matrix):
parts = [f"{n}:{','.join(str(x) for x in vectors[n])}" for n in sorted(vectors)]
parts += [f"{a}>{b}:{d}" for a, b, d in matrix]
return hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]
def write_svg(rows, floor, path):
"""15 对风格距离的条形图:橙=高于下限,红=低于下限。"""
W, H = 520, 24 + len(rows) * 22
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}", "role": "img"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#1f2933"})
for i, (a, b, d) in enumerate(rows):
y = 12 + i * 22
ET.SubElement(svg, "text", {"x": "8", "y": str(y + 12), "fill": "#eceff1",
"font-family": "monospace", "font-size": "11"}).text = f"{a[:9]}/{b[:9]}"
ET.SubElement(svg, "rect", {"x": "190", "y": str(y + 2), "width": str(min(d, 450)), "height": "12",
"fill": "#f59e0b" if d > floor else "#ef4444"})
ET.SubElement(svg, "text", {"x": str(196 + min(d, 450)), "y": str(y + 12), "fill": "#94a3b8",
"font-family": "monospace", "font-size": "11"}).text = str(d)
tree = ET.ElementTree(svg)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
def main():
spec = json.load(open(SPEC, encoding="utf-8"))
weights, floor = spec["weights"], spec["distinguishableFloorPermille"]
os.makedirs(OUT, exist_ok=True)
vectors = {n: style_vector(t) for n, t in spec["themes"].items()}
names = sorted(vectors)
matrix = [(a, b, style_distance(vectors[a], vectors[b], weights))
for a, b in itertools.combinations(names, 2)]
okd = [{"pair": f"{a}/{b}", "d": round(oklab_mean_distance(spec["themes"][a]["palette"],
spec["themes"][b]["palette"]), 12)}
for a, b in itertools.combinations(names, 2)]
minpair = min(matrix, key=lambda r: r[2])
summary = {"game": spec["game"], "specVersion": spec["version"], "dims": spec["dims"],
"weights": weights, "distinguishableFloorPermille": floor,
"vectors": {n: vectors[n] for n in names},
"matrix": [{"a": a, "b": b, "d": d} for a, b, d in matrix],
"minPair": {"a": minpair[0], "b": minpair[1], "d": minpair[2]},
"oklabMeanDistance": okd,
"violating": [[a, b, d] for a, b, d in matrix if d <= floor],
"digest": digest(vectors, matrix)}
with open(os.path.join(OUT, "sp02_style.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
write_svg(matrix, floor, os.path.join(OUT, "sp02_style_map.svg"))
for n in names:
print(f"{n:12s} vec={vectors[n]}")
print(f"pairs={len(matrix)} minPair={minpair[0]}/{minpair[1]} d={minpair[2]} floor={floor} "
f"violating={len(summary['violating'])}")
print(f"oklabMeanDistance min={min(o['d'] for o in okd):.6f} max={max(o['d'] for o in okd):.6f}")
print(f"digest={summary['digest']}")
print("wrote out/sp02_style.json out/sp02_style_map.svg")
if __name__ == "__main__":
sys.exit(main())
实测输出
/opt/homebrew/bin/python3 sim/sp02_style_distance.py 在本机的输出(同机相对量级,绝对数值不可移植):
cozy-clean vec=[772, 238, 44, 150, 260]
handdrawn vec=[733, 235, 66, 480, 520]
lowpoly vec=[643, 329, 511, 120, 210]
pixel vec=[568, 377, 533, 620, 780]
retro-cyber vec=[278, 591, 450, 540, 700]
voxel vec=[494, 331, 280, 300, 640]
pairs=15 minPair=cozy-clean/lowpoly d=104 floor=80 violating=0
oklabMeanDistance min=0.029133 max=0.266656
digest=f1bf01bcc26ec8af
P 层:TS 侧复算
P 层内核 core/src/sp02/vector.ts 复算同一套向量与距离,oklab 直接复用 sp00/spec.ts,保证转换常数与 Python 同源:
// file: core/src/sp02/vector.ts
// tier: P
// verify: node --test 'core/test/sp02_vector.test.ts'
// 风格向量的 TS 侧内核。五维全部归一化到 0-1000 千分比且是整数 → 风格距离(加权 L1 的
// 整数均值)跨语言**逐位一致**。色彩距离用 sp00 的 oklab 复算「均值色」的欧氏距离,
// 属浮点派生量,跨语言按 spec.tolerance(1e-9)断言。只允许可擦除 TS 语法。
import { oklab } from "../sp00/spec.ts";
export type RGB = number[];
export type Palette = Record<string, RGB>;
export function luma709(rgb: RGB): number {
return Math.floor((2126 * rgb[0] + 7152 * rgb[1] + 722 * rgb[2]) / 10000);
}
export function medianInt(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
const n = s.length;
return n % 2 === 1 ? s[(n - 1) / 2] : Math.floor((s[n / 2 - 1] + s[n / 2]) / 2);
}
export function satPermille(rgb: RGB): number {
const mx = Math.max(...rgb);
const mn = Math.min(...rgb);
return mx === 0 ? 0 : Math.floor(((mx - mn) * 1000) / mx);
}
export function meanInt(xs: number[]): number {
return Math.floor(xs.reduce((a, b) => a + b, 0) / xs.length);
}
/** 整数度色相(地板除);灰(max==min)返回 null。 */
export function hueDeg(rgb: RGB): number | null {
const mx = Math.max(...rgb);
const mn = Math.min(...rgb);
const d = mx - mn;
if (d === 0) return null;
let h = 0;
if (mx === rgb[0]) h = (60 * (rgb[1] - rgb[2])) / d;
else if (mx === rgb[1]) h = 60 * ((rgb[2] - rgb[0]) / d + 2);
else h = 60 * ((rgb[0] - rgb[1]) / d + 4);
// JS 的 % 对负数返回负数(Python 返回非负),先归一到 [0,360) 再地板除,两语言才一致
return Math.floor(((h % 360) + 360) % 360);
}
/** 调色板 + 两项设计声明 → 五维整数风格向量。 */
export function styleVector(palette: Palette, strokePermille: number, detailPermille: number): number[] {
const cols = Object.keys(palette).sort().map((k) => palette[k]);
const hues = cols.map(hueDeg).filter((h): h is number => h !== null);
const span = hues.length >= 2 ? Math.max(...hues) - Math.min(...hues) : 0;
return [
Math.floor((medianInt(cols.map(luma709)) * 1000) / 255),
meanInt(cols.map(satPermille)),
Math.floor((span * 1000) / 360),
strokePermille,
detailPermille,
];
}
/** 加权 L1 的整数均值 → 整数类,逐位一致。 */
export function styleDistance(a: number[], b: number[], weights: number[]): number {
let acc = 0;
let wsum = 0;
for (let i = 0; i < weights.length; i += 1) {
acc += weights[i] * Math.abs(a[i] - b[i]);
wsum += weights[i];
}
return Math.floor(acc / wsum);
}
/** 调色板「均值色」的 OKLab(浮点派生量)。 */
export function meanOklab(palette: Palette): number[] {
const os = Object.keys(palette).sort().map((k) => oklab(palette[k]));
return [0, 1, 2].map((i) => os.reduce((a, o) => a + o[i], 0) / os.length);
}
/** 两套调色板均值色的 OKLab 欧氏距离(浮点派生量,按容差断言)。 */
export function oklabMeanDistance(pa: Palette, pb: Palette): number {
const a = meanOklab(pa);
const b = meanOklab(pb);
return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2);
}
$ node --test 'core/test/sp02_vector.test.ts'
oklabMeanDistance 实测最大偏差 = 4.994893387788579e-13
✔ 五维风格向量:整数与 Python 逐位一致
✔ 风格距离矩阵:整数与 Python 逐位一致
✔ OKLab 均值色距离:容差内一致(打印实测最大偏差)
✔ 同源可区分:任意两主题距离都高于下限
✔ sha256 指纹跨语言一致
✔ 负例:下限真的能拦住一对太像的主题
ℹ tests 6 ℹ pass 6 ℹ fail 0
这里要严格区分两种说法:五维向量与 15 对风格距离是整数类,逐位一致;OKLab 色彩距离是浮点派生量,容差内一致,实测最大偏差 4.99e-13(声明容差 1e-9,与全书基线的 4.9e-13 同量级)。绝不能写「两侧颜色完全相同」——那不是事实,pow 的末位就是不一样。
同源可区分:下限与最接近的一对
violating=0 说明 15 对距离全部高于下限 80。但最接近的一对是 cozy-clean 与 lowpoly(d=104),只比下限高 24——这是一条真实的设计告警:两套主题在饱和度上就差 91 点,真正的区分落在描边(150 vs 120)与细节(260 vs 210)上。
色彩距离更值得看:oklabMeanDistance 最小的一对是 cozy-clean/handdrawn(0.029133),两套都是高明度暖调,均值色几乎重合。它们仍然「可区分」,完全靠描边与细节(色相跨度 44 vs 66,声明值 150/260 vs 480/520)。这正是五维向量比三维色彩向量强的地方——也是 2.3 的参考板只能做色彩最近邻、必须补人工标注的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 向量可复算 | 五维全部整数,归一化到同一刻度,无浮点 |
| 距离分两类 | 风格距离走整数、色彩距离走容差,口径写清 |
| 同源可区分 | 任意两主题距离 > 下限,越界必须能断言失败 |
| 有负例 | 把两套主题调成几乎一样,下限必须拦住 |
| 说法诚实 | 整数写「逐位一致」,色彩写「容差内一致,实测最大偏差 4.99e-13」 |
阅读导航:上一节:2.1 从「治愈」「硬核」到可测参数 · 下一节:2.3 参考板与视觉调研的结构化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。