15.2 同源可区分:风格距离矩阵

把「同源但可区分」量成一个整数:五维加权 L1 的整数均值再 ×10,判据是下限 800。15 对全部高于下限,最近一对 lowpoly/cozy-clean 是 1002,摘要指纹与卷一、卷二、卷三 1.2 独立复算逐位相同;B 层再扫阈值与权重,证明三个可测维度一个都不能少。

15.1 规范实例化 证明六套主题能过同一套闸门,但「过闸门」只说明各自合格,不说明彼此分得开。本节把「分不分得开」量成一个整数:五维加权 L1 的整数均值再 ×10,下限取自只读真源 distinguishableFloor10 = 800,权重 [2,2,1,3,3] 与下限都逐字取自 config/sp16_themes.json,本节不另造。

卷三 1.2 风格向量与风格距离矩阵 已经算过这张矩阵;本节不重算一遍就完事,而是补两件它没做的事:维度贡献分解(哪一维把主题拉开最多)与 B 层敏感性(阈值与权重动一动,结论还站不站得住)。风格谱系本身另见 游戏美术方向与风格圣经短文 ,本节只做账。

15.2 同源可区分:风格距离矩阵

距离的定义只有一行:五维加权 L1 的整数均值,再 ×10 取整。全部整数运算、地板除,所以距离是整数类,跨语言逐位一致,没有任何浮点。矩阵回答两个问题:六套主题是不是同一份规范(同源),以及任意两套之间够不够不一样(可区分)。

S 层:6×6 矩阵与维度贡献

下面这段是 S 层脚本 sim/st15_matrix.py。它读只读真源,算五维风格向量与 6×6 距离矩阵,判 belowFloor 是否为空,再做一次维度贡献分解,并给「同源」一个指纹:

#!/usr/bin/env python3
# file: sim/st15_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st15_matrix.py
"""卷三 15.2 同源可区分(S 层):6×6 风格距离矩阵 + 维度贡献分解。

读只读真源 config/sp16_themes.json 与 config/visual_spec.json:
  ① 五维整数风格向量(四角色色的明度/饱和/色相跨度千分比 + 描边 + 细节);
  ② 用**声明权重** [2,2,1,3,3] 算 6×6 加权 L1 距离矩阵(整数 ×10,逐位一致);
  ③ 判据 distinguishableFloor10:15 对必须严格高于它,「同源但可区分」才成立,越界对如实列出;
  ④ 维度贡献分解:把 15 对距离按五维拆开,看哪一维把主题拉开最多(供 15.3 迁移边界用);
  ⑤ 同源证据:五个结构字段(对比度下限/对比度对/图集/预算/命名)六实例相同,指纹只有一个值。

canonical 摘要串与卷一 16.2 / 卷二 16.2 / 卷三 1.2 同式,四卷 digest 应逐位相同。
产出 out/st15_matrix.json。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
ROLES = ["accent", "bg", "ink", "surface"]
DIM_NAMES = ["lumaMedianPermille", "satMeanPermille", "hueSpanPermille", "strokePermille", "detailPermille"]


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 = max(c)
    return 0 if mx == 0 else (mx - min(c)) * 1000 // mx


def hue_deg(c):
    mx, mn = max(c), min(c)
    d = mx - mn
    if d == 0:
        return None
    h = (60 * (c[1] - c[2]) / d if mx == c[0]
         else 60 * ((c[2] - c[0]) / d + 2) if mx == c[1]
         else 60 * ((c[0] - c[1]) / d + 4))
    return int(h % 360)


def style_vector(t):
    cols = [t["tokens"][k] for k in ROLES]
    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
    luma = median_int([luma709(c) for c in cols])
    return [luma * 1000 // 255, sum(sat_permille(c) for c in cols) // len(cols),
            span * 1000 // 360, int(t["strokePermille"]), int(t["detailPermille"])]


def dist10(a, b, w):
    """五维加权 L1 的整数均值再 ×10 取整 → 整数类,逐位一致。"""
    return (sum(wi * abs(x - y) for wi, x, y in zip(w, a, b)) * 10) // sum(w)


def main():
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    cfg = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    w, floor10 = cfg["weights"], cfg["distinguishableFloor10"]
    names = [t["id"] for t in cfg["themes"]]
    vecs = {t["id"]: style_vector(t) for t in cfg["themes"]}
    matrix = [[0 if a == b else dist10(vecs[a], vecs[b], w) for b in names] for a in names]
    flat = [(names[i], names[j], dist10(vecs[names[i]], vecs[names[j]], w))
            for i in range(len(names)) for j in range(i + 1, len(names))]
    lo, hi = min(flat, key=lambda r: r[2]), max(flat, key=lambda r: r[2])
    below = [f"{a}/{b}:{d}" for a, b, d in flat if d <= floor10]
    # 维度贡献:每维在 15 对上的加权 L1 之和(整数),再归一到千分比。
    contrib = [sum(w[k] * abs(vecs[a][k] - vecs[b][k]) for a, b, _ in flat) for k in range(5)]
    total = sum(contrib) or 1
    share = {DIM_NAMES[k]: contrib[k] * 1000 // total for k in range(5)}
    # 同源证据:五个结构字段六实例必须相同。
    structure = [spec["contrastFloor10"], spec["contrastPairs"], spec["atlas"], spec["budget"]]
    struct_fp = hashlib.sha256(json.dumps(structure, sort_keys=True).encode("utf-8")).hexdigest()[:8]
    parts = [f"{n}:{','.join(map(str, vecs[n]))}" for n in names]
    parts += [f"{a}>{b}:{d}" for a, b, d in flat]
    canon = ";".join(parts)
    report = {"project": cfg["project"], "order": names, "dims": cfg["dims"], "weights": w,
              "distinguishableFloor10": floor10, "vectors": vecs, "matrix": matrix,
              "minPair": {"a": lo[0], "b": lo[1], "d": lo[2]},
              "maxPair": {"a": hi[0], "b": hi[1], "d": hi[2]}, "belowFloor": below,
              "headroom": lo[2] - floor10, "pairCount": len(flat),
              "dimContrib": dict(zip(DIM_NAMES, contrib)), "dimSharePermille": share,
              "structureFingerprint": struct_fp, "canonical": canon,
              "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st15_matrix.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"themes={len(names)} pairs={len(flat)} floor10={floor10} below={len(below)}")
    print(f"min={lo[0]}/{lo[1]}={lo[2]} max={hi[0]}/{hi[1]}={hi[2]} headroom={lo[2] - floor10}")
    print("dimShare=" + " ".join(f"{k}:{share[k]}" for k in DIM_NAMES))
    print(f"structureFingerprint={struct_fp} digest={report['digest']}")
    print("wrote out/st15_matrix.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/st15_matrix.py:

themes=6 pairs=15 floor10=800 below=0
min=lowpoly/cozy-clean=1002 max=retro-cyber/cozy-clean=3794 headroom=202
dimShare=lumaMedianPermille:134 satMeanPermille:100 hueSpanPermille:104 strokePermille:312 detailPermille:348
structureFingerprint=b79290d8 digest=fa04e9418d9f3c2c
wrote out/st15_matrix.json

below=0 说明 15 对全部严格高于下限,余量 headroom=202。「同源」由 structureFingerprint=b79290d8 见证——五个结构字段六实例相同,指纹只有一个值。维度贡献那一行是本节的增量:

维度权重贡献千分比
明度中位 lumaMedianPermille2134
饱和度均值 satMeanPermille2100
色相跨度 hueSpanPermille1104
描边 strokePermille3312
细节 detailPermille3348

把 15 对距离按五维拆开再加总(四舍五入后合计 998‰),形状与细节两维贡献 660‰,色彩三维只有 338‰。换句话说,这六套主题之所以「一眼能分开」,主要靠描边与细节,不靠配色——这条结论下一节直接用得上。

6×6 矩阵:同源但可区分

把 15 对铺成 6×6 矩阵(对角为 0,单位 ×10,越大越不像):

pixellowpolyhanddrawnvoxelretro-cybercozy-clean
pixel030521929163913373480
lowpoly305202669216833641002
handdrawn192926690146822421671
voxel163921681468017562038
retro-cyber133733642242175603794
cozy-clean348010021671203837940

最接近的一对是 lowpoly/cozy-clean = 1002(都是浅色底),最远的是 retro-cyber/cozy-clean = 3794(一个霓虹暗色、一个奶白暖色)。下限 800,below=0 说明 15 对全部严格高于它,没有低于阈值的主题对——这是如实报告的结果,不是调参调出来的。digest=fa04e9418d9f3c2c 与卷一 16.2、卷二 16.2、卷三 1.2 独立复算的矩阵摘要逐位相同,四卷各自算、一位不差。

B 层:阈值与权重敏感性

下面这段是 B 层脚本 sim/st15_bench.py,结论只写告警,退出码恒为 0:

#!/usr/bin/env python3
# file: sim/st15_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/st15_bench.py
"""卷三 15.2 的 B 层基准(软告警不硬失败):可区分性对「阈值」与「权重」有多敏感。

读 out/st15_matrix.json 与两份 config,做四件对抗「编数字」的事:
  ① 阈值扫描:把 distinguishableFloor10 从 800 抬到 2500,看有多少对由「可区分」翻成「太像」;
  ② 权重消融:只用色彩三维(权重 [2,2,1])时最近一对会掉到哪,证明形状/细节两维不能丢;
  ③ 结构同源:五个结构字段六实例相同,指纹只有一个值;
  ④ 跨语言容差:OKLab 最大距离是派生量量级,与容差 1e-9 是两码事。
结论只写告警,退出码恒为 0。产出 out/st15_bench.json。
"""
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")
DIMS = [0, 1, 2, 3, 4]


def lin(c):
    s = c / 255
    return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4


def oklab(rgb):
    r, g, b = (lin(c) for c in rgb)
    l, m, s = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
               0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
               0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
    l_, m_, s_ = (math.copysign(abs(v) ** (1 / 3), v) for v in (l, m, s))
    return (0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
            1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
            0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_)


def okdist(p, q):
    a, b = oklab(p), oklab(q)
    return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))


def dist10(a, b, w, dims):
    acc = sum(w[i] * abs(a[i] - b[i]) for i in dims)
    return (acc * 10) // sum(w[i] for i in dims)


def main():
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    cfg = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    mat = json.load(open(os.path.join(OUT, "st15_matrix.json"), encoding="utf-8"))
    order, matrix, w = mat["order"], mat["matrix"], cfg["weights"]
    flat = [matrix[i][j] for i in range(len(order)) for j in range(i + 1, len(order))]
    floor10, nearest = cfg["distinguishableFloor10"], min(flat)
    vecs = mat["vectors"]
    color_min = min(dist10(vecs[a], vecs[b], w, [0, 1, 2])
                    for i, a in enumerate(order) for b in order[i + 1:])
    sweep = {str(f): sum(1 for d in flat if d <= f) for f in (1000, 1200, 1500, 2000, 2500)}
    toks = list(spec["tokens"].values())
    okmax = max(okdist(toks[i], toks[j]) for i in range(len(toks)) for j in range(i + 1, len(toks)))
    tol, xlang = spec["tolerance"]["oklab"], 4.9e-13
    warn = []
    if nearest - floor10 < 100:
        warn.append(f"THRESHOLD_TIGHT:最近一对 {nearest} 距下限仅 {nearest - floor10}")
    if color_min <= floor10:
        warn.append(f"COLOR_ONLY_INSUFFICIENT:只用色彩三维最近一对 {color_min} 已在下限内")
    if xlang >= tol:
        warn.append("CROSS_LANG_EXCEEDS_TOLERANCE")
    report = {"project": cfg["project"], "floor10": floor10, "nearestPair": nearest,
              "headroom": nearest - floor10, "thresholdSweep": sweep,
              "colorOnlyMin10": color_min, "colorOnlyBelowFloor": color_min <= floor10,
              "structureFingerprint": mat["structureFingerprint"], "dimSharePermille": mat["dimSharePermille"],
              "oklabMax": round(okmax, 12), "tolerance": tol, "declaredCrossLangMax": xlang,
              "warnings": warn, "count": len(warn)}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st15_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"floor10={floor10} nearestPair={nearest} headroom={nearest - floor10}")
    print("thresholdSweep=" + " ".join(f"{k}:{v}" for k, v in sweep.items()))
    print(f"colorOnlyMin10={color_min} colorOnlyBelowFloor={color_min <= floor10}")
    print(f"structureFingerprint={mat['structureFingerprint']} (六实例单值)")
    print(f"oklabMax={okmax:.6f} tolerance={tol} crossLangMax=4.9e-13")
    print(f"warnings={len(warn)} (soft, exit 0)")
    print("wrote out/st15_bench.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/st15_bench.py:

floor10=800 nearestPair=1002 headroom=202
thresholdSweep=1000:0 1200:1 1500:3 2000:7 2500:10
colorOnlyMin10=138 colorOnlyBelowFloor=True
structureFingerprint=b79290d8 (六实例单值)
oklabMax=0.674673 tolerance=1e-09 crossLangMax=4.9e-13
warnings=1 (soft, exit 0)

读出来:下限 800 时 0 对越界,抬到 1200 就有 1 对(lowpoly/cozy-clean=1002)被判「太像」——800 与最近一对 1002 之间留了 202 的余量,这个下限不是随手取的。权重消融更关键:只用色彩三维(权重 [2,2,1])时最近一对掉到 138,远在下限之内,colorOnlyBelowFloor=True 触发了唯一一条告警——这就是「三个可测维度一个都不能少」的整数证据,也说明单靠配色无法保证可区分。oklabMax=0.674673 是 OKLab 距离的量级(派生量),与跨语言容差 1e-9 是两码事:容差比的是两语言各自算出的同一派生量之差,实测 4.9e-13 远小于 1e-9。

自检表

检查项合格标准
阈值不另造weights 与下限逐字取自只读真源,正文可指认出处
越界如实报告below 集合被打印;非空时必须解释而非调参掩盖
维度可分解五维贡献归一后为 1000‰(本机 998‰,差在取整)
敏感性可查阈值与权重各做一次扫描,结论随参数移动的方向可读

「同源但可区分」到这里有了量化口径。但分得开不等于搬得动——六套主题里哪些字段能跟着走、哪些必须重写、重写要付多少代价,是下一节的问题。

阅读导航:上一节:15.1 规范实例化:6 主题包 · 下一节:15.3 风格迁移的边界与代价 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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