2.2 风格向量与风格距离

上一节把形容词变成四个整数区间,这一节把区间升级成向量:五维、每维归一化到千分比,再用加权 L1 算出六套主题两两之间的风格距离,并用 OKLab 欧氏距离单独量色彩距离。同一份规范要能在六套主题里都成立,靠的就是这张「同源可区分」的距离矩阵。

上一节的四维区间只覆盖色彩,回答不了「这两套主题到底有多像」。这一节把区间升级成风格向量:五维、每维归一化到千分比,再用加权 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 参考板与视觉调研的结构化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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