16.2 三卷总收官:一份规范的完整回放

三卷总收官:把卷一 ch16 的规范装配、卷二 ch16 的四道闸门、卷三 ch16 的 8 项验收清单串成一条链,在 6 个主题包上各跑一遍,6×(2+4+8)=84 项判定全过。再独立复算 6×6 距离矩阵,摘要命中跨卷锚点 fa04e9418d9f3c2c;B 层扫阈值证明下限 800 不能松。

前三卷各自收官:卷一在 16.2 双案例回放:《星屑旅店》6 主题的规范装配 里装配了规范,卷二在 16.2 双案例回放:6 主题走同一条管线 里把规范灌进管线,卷三在 16.1 跨风格验收清单 里给出了验收判据。本节是三卷总收官:把这三段串成一条链,在 6 个主题包上各跑一遍——不是各说各话,而是同一份 config/visual_spec.json 依次过三关。全部公式索引见卷一 附录 A:视觉规范公式、参数与数据集索引 与卷二 附录 A:管线 schema、规则表与公式索引 。

16.2.1 一条链,三段判定

链分三段,每段判定的位数不同:规范装配(卷一 ch16)判 2 位——token 是否齐 8 个、5 对对比度是否都达下限;管线回放(卷二 ch16)判 4 位——前 2 位加上图集装箱率与可区分度;跨风格验收(卷三 ch16)判 8 位——16.1 那张清单。6 个主题 × (2+4+8) = 84 项判定,全过才算这条链成立。本节的 S 层脚本与 TS 内核同步实现,摘要指纹必须逐位相同。

16.2.2 S 层:三卷收官链在 6 主题上各跑一遍

sim/st16_replay.py 是这条链的 S 层。它把三段判定写成三个 bits_* 函数,main() 逐主题串起来;顺带独立复算 6×6 风格距离矩阵,其 canonical 摘要应为跨卷锚点 fa04e9418d9f3c2c(卷一 16.2、卷二 16.2 与卷三 1.2 共享):

#!/usr/bin/env python3
# file: sim/st16_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st16_replay.py
"""卷三 16.2 三卷总收官:一份规范的完整回放(S 层)。把三卷各自的收官口径串成一条链,在 6
个主题包上各跑一遍:卷一 ch16(tokens+对比度)、卷二 ch16(tokens/contrast/atlas/distinct
四道闸门)、卷三 ch16(16.1 的 8 项验收清单),6×(2+4+8)=84 项判定必须全过。再独立复算
6×6 风格距离矩阵,其 canonical 摘要应为跨卷锚点 fa04e9418d9f3c2c。落 out/st16_replay.json。"""
import hashlib
import json
import os
import re
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROLES = ["accent", "bg", "ink", "surface"]
ANCHOR = "fa04e9418d9f3c2c"


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


def contrast10(a, b):
    def f(c):
        return 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2])
    hi, lo = sorted((f(a), f(b)), reverse=True)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


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
    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(t["strokePermille"]), int(t["detailPermille"])]


def dist10(a, b, w):
    return (sum(wi * abs(x - y) for wi, x, y in zip(w, a, b)) * 10) // sum(w)


def atlas_pm(at):
    cols, rows = at["width"] // at["cell"], at["height"] // at["cell"]
    placed = min(at["icons"], cols * rows)
    return (placed * at["cell"] * at["cell"] * 1000) // (at["width"] * at["height"])


def mip_bytes(base, bpp):
    total, n = 0, base
    while True:
        total += (n * n * bpp + 3) // 4 * 4
        if n == 1:
            return total
        n //= 2


def bits_spec(t, spec):
    mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
    return [len(t["tokens"]) == 8, mn >= spec["contrastFloor10"]]


def bits_pipeline(t, spec, near, cfg, pm):
    return bits_spec(t, spec) + [pm == atlas_pm(cfg["atlas"]), near > cfg["distinguishableFloor10"]]


def bits_accept(t, acc, spec, near, pm, mipb, pairs):
    pat = re.compile(acc["idPattern"])
    mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
    return [all(pat.match(a) for a in acc["assetIds"]),
            not any(r in a.split("_") for a in acc["assetIds"] for r in acc["reservedWords"]),
            len(t["tokens"]) == 8, mn >= spec["contrastFloor10"], pm == acc["atlasPermille"],
            mipb == acc["mipChainBytes"], near > acc["distinguishableFloor10"], pairs == acc["pairCount"]]


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"))
    acc = json.load(open(os.path.join(ROOT, "config/st16_acceptance.json"), encoding="utf-8"))
    w = cfg["weights"]
    names = [t["id"] for t in cfg["themes"]]
    vecs = {t["id"]: style_vector(t) for t in cfg["themes"]}
    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))]
    pm = atlas_pm(cfg["atlas"])
    mipb = mip_bytes(spec["budget"]["baseTexture"], spec["budget"]["bytesPerPixel"])
    records, violations = [], []
    for t in cfg["themes"]:
        near = min(dist10(vecs[t["id"]], vecs[o], w) for o in vecs if o != t["id"])
        stages = [("spec-assembly", bits_spec(t, spec)), ("pipeline", bits_pipeline(t, spec, near, cfg, pm)),
                  ("acceptance", bits_accept(t, acc, spec, near, pm, mipb, len(flat)))]
        for name, bits in stages:
            if not all(bits):
                violations.append(f"{t['id']}:{name}")
        records.append({"id": t["id"], "near": near,
                        "stages": [{"name": n, "bits": b} for n, b in stages],
                        "pass": all(all(b) for _, b in stages),
                        "checkCount": sum(len(b) for _, b in stages),
                        "passCount": sum(sum(b) for _, b in stages)})
    canon = ";".join([f"{n}:{','.join(map(str, vecs[n]))}" for n in names]
                     + [f"{a}>{b}:{d}" for a, b, d in flat])
    matrix_digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
    report = {"project": cfg["project"], "order": names, "themeCount": len(names), "stageCount": 3,
              "checkCount": sum(r["checkCount"] for r in records),
              "passCount": sum(r["passCount"] for r in records),
              "matrixDigest": matrix_digest, "anchorDigest": ANCHOR, "anchorOk": matrix_digest == ANCHOR,
              "minPair": min(flat, key=lambda r: r[2])[2], "maxPair": max(flat, key=lambda r: r[2])[2],
              "themes": records, "violations": sorted(violations),
              "digest": hashlib.sha256("|".join(
                  f"{r['id']}:{''.join(str(int(b)) for st in r['stages'] for b in st['bits'])}"
                  for r in records).encode("utf-8")).hexdigest()[:16]}
    with open(os.path.join(ROOT, "out/st16_replay.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={report['themeCount']} stages={report['stageCount']} checks={report['checkCount']} "
          f"pass={report['passCount']} violations={len(report['violations'])}")
    for r in records:
        bits = " ".join(f"{st['name']}=" + "".join(str(int(b)) for b in st["bits"]) for st in r["stages"])
        print(f"  {r['id']:<12} {bits} pass={r['pass']}")
    print(f"matrixDigest={matrix_digest} anchor={ANCHOR} ok={report['anchorOk']}")
    print(f"digest={report['digest']}")
    print("wrote out/st16_replay.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/st16_replay.py:

themes=6 stages=3 checks=84 pass=84 violations=0
  pixel        spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
  lowpoly      spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
  handdrawn    spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
  voxel        spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
  retro-cyber  spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
  cozy-clean   spec-assembly=11 pipeline=1111 acceptance=11111111 pass=True
matrixDigest=fa04e9418d9f3c2c anchor=fa04e9418d9f3c2c ok=True
digest=3e4d7308130787ad
wrote out/st16_replay.json

84 项判定全过。关键是最后一行:矩阵摘要 fa04e9418d9f3c2c 命中跨卷锚点——它与卷一 16.2、卷二 16.2、卷三 1.2 各自独立复算的结果逐位相同,说明三卷用的确实是同一份规范。

16.2.3 B 层:阈值与容差的敏感性

sim/st16_bench.py 是软告警的 B 层,做三件对抗「编数字」的事:预算漂移(6 主题的 stroke/detail 跨度)、阈值扫描(把可区分下限从 600 扫到 2500,看有几对被判太像)、跨语言容差(声明的 4.9e-13 必须小于配置容差 1e-9):

#!/usr/bin/env python3
# file: sim/st16_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/st16_bench.py
"""卷三 16.2 的 B 层基准(软告警不硬失败):读 out/st16_replay.json 与两份 config,算三件对抗
「编数字」的事——主题切换后的预算漂移、可区分阈值的敏感性、跨语言色彩容差。退出码恒为 0。"""
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROLES = ["accent", "bg", "ink", "surface"]


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
    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(t["strokePermille"]), int(t["detailPermille"])]


def atlas_pm(at):
    cols, rows = at["width"] // at["cell"], at["height"] // at["cell"]
    placed = min(at["icons"], cols * rows)
    return (placed * at["cell"] * at["cell"] * 1000) // (at["width"] * at["height"])


def mip_bytes(base, bpp):
    total, n = 0, base
    while True:
        total += (n * n * bpp + 3) // 4 * 4
        if n == 1:
            return total
        n //= 2


def main() -> int:
    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"))
    replay = json.load(open(os.path.join(ROOT, "out/st16_replay.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"]}
    flat = [(sum(wi * abs(x - y) for wi, x, y in zip(w, vecs[names[i]], vecs[names[j]])) * 10) // sum(w)
            for i in range(len(names)) for j in range(i + 1, len(names))]
    stroke = [t["strokePermille"] for t in cfg["themes"]]
    detail = [t["detailPermille"] for t in cfg["themes"]]
    budget = {"atlasPermille": atlas_pm(cfg["atlas"]), "atlasDrift": 0,
              "mipChainBytes": mip_bytes(spec["budget"]["baseTexture"], spec["budget"]["bytesPerPixel"]),
              "strokeSpan": max(stroke) - min(stroke), "detailSpan": max(detail) - min(detail)}
    sweep = {str(f): sum(1 for d in flat if d <= f) for f in (600, 800, 1000, 1200, 1500, 2000, 2500)}
    nearest, tol, xlang = min(flat), spec["tolerance"]["oklab"], 4.9e-13
    warn = []
    if nearest - floor10 < 100:
        warn.append(f"THRESHOLD_TIGHT:最近一对 {nearest} 距下限仅 {nearest - floor10}")
    if xlang >= tol:
        warn.append("CROSS_LANG_EXCEEDS_TOLERANCE")
    report = {"project": cfg["project"], "budget": budget, "thresholdSweep": sweep,
              "nearestPair": nearest, "floor10": floor10, "headroom": nearest - floor10,
              "tolerance": tol, "declaredCrossLangMax": xlang, "anchorOk": replay["anchorOk"],
              "warnings": warn, "count": len(warn)}
    with open(os.path.join(ROOT, "out/st16_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"atlasPermille={budget['atlasPermille']} mipChainBytes={budget['mipChainBytes']} "
          f"strokeSpan={budget['strokeSpan']} detailSpan={budget['detailSpan']}")
    print(f"nearestPair={nearest} floor10={floor10} headroom={nearest - floor10}")
    print("thresholdSweep=" + " ".join(f"{k}:{v}" for k, v in sweep.items()))
    print(f"tolerance={tol} crossLangMax={xlang} warnings={len(warn)} (soft, exit 0)")
    print("wrote out/st16_bench.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/st16_bench.py:

atlasPermille=144 mipChainBytes=5592404 strokeSpan=500 detailSpan=570
nearestPair=1002 floor10=800 headroom=202
thresholdSweep=600:0 800:0 1000:0 1200:1 1500:3 2000:7 2500:10
tolerance=1e-09 crossLangMax=4.9e-13 warnings=0 (soft, exit 0)
wrote out/st16_bench.json

扫描说明下限 800 不能松:扫到 1000 时还没有一对被判太像,但一到 1200 就冒出 1 对、1500 有 3 对——最近的一对 1002 只比 800 高 202,余量并不宽裕。容差方面,色彩是 sRGB 8-bit 整数三元组(事实来源),线性化/OKLab/ΔE 都是派生量,跨语言最大偏差实测 4.9e-13,落在 1e-9 容差之内,故颜色写「容差内一致」而非逐位相等;整数类判据(对比度、距离、千分比、字节)才是逐位一致。

16.2.4 边界与取舍

阅读导航:上一节:16.1 跨风格验收清单 · 下一节:16.3 交付物与后续 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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