15.3 风格迁移的边界与代价
讲清了哪些字段能跟着走、哪些必须重写。收尾这一章要回答的是:一套视觉规范做完了,怎么验收?本节把验收做成一张可跑的清单——8 项判据,命名、token 数、对比度、图集装箱率、mip 字节、最近邻距离,全部写成整数比较。判据一律取自只读真源 config/visual_spec.json 与 config/sp16_themes.json,本节不另造阈值。
16.1.1 一张清单,两类项
清单里的每一项分两类:static(与主题无关,如资产命名、图集装箱率、mip 字节)和 per-theme(随主题变,如 token 数、对比度、可区分度)。每项还带一个 blocking 标记——false 的项只告警不阻断,适合「应该达到但不必卡死」的软指标。判定值全部是整数:对比度与风格距离是 ×10 的整数,装箱率是千分比整数,字节数也是整数。没有浮点,所以清单在 Python 与 TypeScript 两侧能逐位一致。清单本身也落成静态文件:
{
"project": "stardust-inn",
"specVersion": 1,
"note": "跨风格验收清单(卷三 16.1)。同一张清单在 6 个主题包上各跑一遍:清单项分 static(与主题无关,如命名与预算)与 per-theme(随主题变,如对比度与可区分度)两类。每项只写整数判据,故跨语言逐位一致;blocking=false 的项只告警不阻断。",
"contrastFloor10": 45,
"distinguishableFloor10": 800,
"atlasPermille": 144,
"mipChainBytes": 5592404,
"pairCount": 15,
"assetIds": ["ui_icon_coin_v2", "char_innkeeper_idle", "tile_floor_wood_v1", "fx_steam_puff", "prop_lamp_round_v3"],
"idPattern": "^[a-z][a-z0-9]*_[a-z0-9_]+$",
"reservedWords": ["new", "class", "default", "null", "delete", "void"],
"items": [
{ "id": "name.prefix", "category": "naming", "kind": "static", "tier": "P", "blocking": true, "criterion": "所有资产 id 匹配前缀模式" },
{ "id": "name.reserved", "category": "naming", "kind": "static", "tier": "P", "blocking": true, "criterion": "资产 id 不含引擎保留字" },
{ "id": "tokens.count", "category": "color", "kind": "per-theme", "tier": "P", "blocking": true, "criterion": "token 数 == 8" },
{ "id": "contrast.floor", "category": "color", "kind": "per-theme", "tier": "S", "blocking": true, "criterion": "min contrast10 >= 45" },
{ "id": "atlas.permille", "category": "atlas", "kind": "static", "tier": "S", "blocking": false, "criterion": "atlas 装箱率千分比 == 144" },
{ "id": "budget.mip", "category": "budget", "kind": "static", "tier": "S", "blocking": true, "criterion": "mipChainBytes == 5592404" },
{ "id": "distinct.floor","category": "style", "kind": "per-theme", "tier": "S", "blocking": true, "criterion": "最近邻风格距离 > 800" },
{ "id": "distinct.pairs","category": "style", "kind": "static", "tier": "S", "blocking": false, "criterion": "两两距离对数 == 15" }
]
}
16.1.2 S 层:清单在 6 个主题上各跑一遍
sim/st16_acceptance.py 把这张清单在 6 个主题包上各跑一遍。它只做整数运算:对比度、风格向量、装箱率、mip 链字节各有一个纯函数,evaluate() 按 id 分派到对应判据,main() 逐主题逐项求值并累计违例。落盘 out/st16_acceptance.json 供 P 层对照:
#!/usr/bin/env python3
# file: sim/st16_acceptance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st16_acceptance.py
"""卷三 16.1 跨风格验收清单(S 层)。把 config/st16_acceptance.json 的同一张清单在 6 个
主题包上各跑一遍:清单项分 static(与主题无关)与 per-theme(随主题变)两类。判据只写
整数比较(对比度 ×10、风格距离 ×10、千分比、字节),故跨语言逐位一致。落
out/st16_acceptance.json 供 P 层对照。"""
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"]
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 contrast10(a, b):
def f(c):
return 0.2126 * srgb_to_linear(c[0]) + 0.7152 * srgb_to_linear(c[1]) + 0.0722 * srgb_to_linear(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_permille(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 evaluate(item_id, theme, ctx):
"""返回 (value_str, pass)。ctx 里带 cfg/spec/nearest/atlas/mip/pairs。"""
cfg, spec, near = ctx["cfg"], ctx["spec"], ctx["nearest"]
if item_id == "name.prefix":
pat = re.compile(cfg["idPattern"])
ok = all(pat.match(a) for a in cfg["assetIds"])
return f"matched={sum(1 for a in cfg['assetIds'] if pat.match(a))}/{len(cfg['assetIds'])}", ok
if item_id == "name.reserved":
hits = [a for a in cfg["assetIds"] for r in cfg["reservedWords"] if r in a.split("_")]
return f"hits={len(hits)}", len(hits) == 0
if item_id == "tokens.count":
return f"count={len(theme['tokens'])}", len(theme["tokens"]) == 8
if item_id == "contrast.floor":
mn = min(contrast10(theme["tokens"][a], theme["tokens"][b]) for a, b in spec["contrastPairs"])
return f"min={mn}", mn >= spec["contrastFloor10"]
if item_id == "atlas.permille":
return f"pm={ctx['atlas']}", ctx["atlas"] == cfg["atlasPermille"]
if item_id == "budget.mip":
return f"bytes={ctx['mip']}", ctx["mip"] == cfg["mipChainBytes"]
if item_id == "distinct.floor":
return f"nearest={near}", near > cfg["distinguishableFloor10"]
if item_id == "distinct.pairs":
return f"pairs={ctx['pairs']}", ctx["pairs"] == cfg["pairCount"]
return "unknown", False
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st16_acceptance.json"), encoding="utf-8"))
themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
w = themes["weights"]
names = [t["id"] for t in themes["themes"]]
vecs = {t["id"]: style_vector(t) for t in themes["themes"]}
pairs = [(names[i], names[j]) for i in range(len(names)) for j in range(i + 1, len(names))]
ctx_base = {"cfg": cfg, "spec": spec, "atlas": atlas_permille(themes["atlas"]),
"mip": mip_bytes(spec["budget"]["baseTexture"], spec["budget"]["bytesPerPixel"]),
"pairs": len(pairs)}
records, violations = [], []
for t in themes["themes"]:
near = min(dist10(vecs[t["id"]], vecs[o], w) for o in vecs if o != t["id"])
ctx = dict(ctx_base, nearest=near)
checks = []
for it in cfg["items"]:
val, ok = evaluate(it["id"], t, ctx)
blocking = bool(it["blocking"])
checks.append({"id": it["id"], "value": val, "pass": ok, "blocking": blocking})
if not ok and blocking:
violations.append(f"{t['id']}:{it['id']}:{val}")
mn = min(contrast10(t["tokens"][a], t["tokens"][b]) for a, b in spec["contrastPairs"])
records.append({"id": t["id"], "nearest": near, "minContrast10": mn, "checks": checks,
"pass": all(c["pass"] for c in checks),
"passCount": sum(1 for c in checks if c["pass"])})
total = len(records) * len(cfg["items"])
passed = sum(r["passCount"] for r in records)
parts = [f"{r['id']}:{','.join(str(c['value']) for c in r['checks'])}" for r in records]
report = {"project": cfg["project"], "order": names, "themeCount": len(names),
"itemCount": len(cfg["items"]), "checkCount": total, "passCount": passed,
"categories": sorted({it["category"] for it in cfg["items"]}),
"themes": records, "violations": sorted(violations),
"digest": hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]}
with open(os.path.join(ROOT, "out/st16_acceptance.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']} items={report['itemCount']} "
f"checks={total} pass={passed} violations={len(report['violations'])}")
for r in records:
print(f" {r['id']:<12} pass={r['passCount']}/{len(cfg['items'])} "
f"minContrast10={r['minContrast10']:<4} nearest={r['nearest']}")
print(f"digest={report['digest']}")
print("wrote out/st16_acceptance.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st16_acceptance.py:
themes=6 items=8 checks=48 pass=48 violations=0
pixel pass=8/8 minContrast10=45 nearest=1337
lowpoly pass=8/8 minContrast10=51 nearest=1002
handdrawn pass=8/8 minContrast10=46 nearest=1468
voxel pass=8/8 minContrast10=48 nearest=1468
retro-cyber pass=8/8 minContrast10=55 nearest=1337
cozy-clean pass=8/8 minContrast10=46 nearest=1002
digest=facdc68d1a8417a5
wrote out/st16_acceptance.json
16.1.3 P 层:同一张清单的 TS 内核
同一张清单在 TypeScript 侧由 core/src/st16/acceptance.ts 实现。它不重写对比度与风格向量,而是从 sp00/spec.ts、st01/style.ts 复用同源常数,所以两侧的 value 串逐字相同:
// file: core/src/st16/acceptance.ts
// tier: P
// verify: node --test 'core/test/st16_*.test.ts'
// 卷三 16.1 跨风格验收清单的 P 层内核。与 sim/st16_acceptance.py 同一套规则:同一张清单在
// 6 个主题包上各跑一遍,清单项分 static / per-theme 两类,判据只写整数比较。对比度复用
// sp00/spec.ts 的同源常数,风格向量复用 st01/style.ts,故与 Python 逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { contrast10, mipChainBytes } from "../sp00/spec.ts";
import { styleVector, dist10, FULL_DIMS, ROLES } from "../st01/style.ts";
export type Palette = Record<string, number[]>;
/** 图集装箱率千分比(整数地板除)。 */
export function atlasPermille(at: any): number {
const cols = Math.floor(at.width / at.cell);
const rows = Math.floor(at.height / at.cell);
const placed = Math.min(at.icons, cols * rows);
return Math.floor((placed * at.cell * at.cell * 1000) / (at.width * at.height));
}
/** 最近邻风格距离(×10)。 */
export function nearestDistance(id: string, vecs: Record<string, number[]>, weights: number[]): number {
let best = Number.POSITIVE_INFINITY;
for (const other of Object.keys(vecs)) {
if (other === id) continue;
best = Math.min(best, dist10(vecs[id], vecs[other], weights, FULL_DIMS));
}
return best;
}
/** 单条清单项的求值,与 Python 的 evaluate() 逐条对应,返回 [value, pass]。 */
export function evaluate(itemId: string, theme: any, ctx: any): [string, boolean] {
const cfg = ctx.cfg;
const spec = ctx.spec;
switch (itemId) {
case "name.prefix": {
const pat = new RegExp(cfg.idPattern);
const n = cfg.assetIds.filter((a: string) => pat.test(a)).length;
return [`matched=${n}/${cfg.assetIds.length}`, n === cfg.assetIds.length];
}
case "name.reserved": {
const hits = cfg.assetIds.filter((a: string) =>
a.split("_").some((seg: string) => cfg.reservedWords.includes(seg)));
return [`hits=${hits.length}`, hits.length === 0];
}
case "tokens.count":
return [`count=${Object.keys(theme.tokens).length}`, Object.keys(theme.tokens).length === 8];
case "contrast.floor": {
const mn = Math.min(...spec.contrastPairs.map(([a, b]: string[]) => contrast10(theme.tokens[a], theme.tokens[b])));
return [`min=${mn}`, mn >= spec.contrastFloor10];
}
case "atlas.permille":
return [`pm=${ctx.atlas}`, ctx.atlas === cfg.atlasPermille];
case "budget.mip":
return [`bytes=${ctx.mip}`, ctx.mip === cfg.mipChainBytes];
case "distinct.floor":
return [`nearest=${ctx.nearest}`, ctx.nearest > cfg.distinguishableFloor10];
case "distinct.pairs":
return [`pairs=${ctx.pairs}`, ctx.pairs === cfg.pairCount];
default:
return ["unknown", false];
}
}
/** 同一张清单在单个主题上的完整结果。 */
export function runChecklist(theme: any, cfg: any, spec: any, vecs: Record<string, number[]>, weights: number[], pairCount: number): any {
const ctx = {
cfg, spec, vecs,
atlas: atlasPermille(spec.atlas),
mip: mipChainBytes(spec.budget.baseTexture, spec.budget.bytesPerPixel),
pairs: pairCount,
nearest: nearestDistance(theme.id, vecs, weights),
};
const checks = cfg.items.map((it: any) => {
const [value, pass] = evaluate(it.id, theme, ctx);
return { id: it.id, value, pass, blocking: !!it.blocking };
});
return { id: theme.id, nearest: ctx.nearest, checks, pass: checks.every((c: any) => c.pass) };
}
/** 与 Python 同式的摘要串:逐主题按清单顺序拼 value。 */
export function canonical(records: any[]): string {
return records.map((r) => `${r.id}:${r.checks.map((c: any) => c.value).join(",")}`).join(";");
}
export { styleVector, ROLES };
core/test/st16_acceptance.test.ts 读 Python 落盘做跨语言对照,并构造四条负例(对比度塌陷、与 pixel 同向量、非法 id 前缀、含保留字)。实跑 node --test 'core/test/st16_*.test.ts'(此处只摘本节 3 条;三节合计 10 条):
✔ 跨风格验收清单:6 主题 × 8 项逐项与 Python 一致且全过 (1.575459ms)
✔ 摘要串 + sha256 指纹跨语言逐位一致 (0.513625ms)
✔ 负例:对比度塌陷、与 pixel 同向量、非法 id、保留字都必须 fail (0.239959ms)
ℹ tests 10
ℹ pass 10
ℹ fail 0
16.1.4 边界与取舍
- 「好不好看」不进清单。清单只判整数账(命名、token 数、对比度、装箱率、字节、距离),审美判断仍需人工评审,标 X。
- 软指标只告警,阈值不可移植。
blocking=false的atlas.permille与distinct.pairs越界只提示不阻断;45、800、144 都是本机 6 主题的相对量级,换一批主题必须重跑本节脚本重定基线。
阅读导航:上一节:15.3 风格迁移的边界与代价 · 下一节:16.2 三卷总收官:一份规范的完整回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。