参考板(moodboard)最常见的失败不是「不好看」,而是没法校验:图散在聊天记录里、不知道谁的、不知道能不能用、也没人说得清它到底在支撑哪套主题。这一节把参考板变成一张有 schema 的表:每条参考有来源、授权、取色真源与用途,用整数校验、用最近邻归主题、用缺口清单说话。上一节2.2 风格向量与风格距离 给了五维向量;参考板只有色彩信息,所以本节只在三个色彩维度上做最近邻,并把「哪些维度它给不出」明确写出来。
参考板的三种失败
| 失败 | 症状 | 结构化之后 |
|---|---|---|
| 无授权 | 图很好看,但不知道能不能进资产集 | license 字段 + shippable 判定 |
| 无覆盖 | 攒了 40 张图,某一套主题一张都没有 | coverage / gaps 自动算出来 |
| 无归属 | 说不清这张图是给「治愈」还是「清新」用的 | 最近邻归属 + margin 标出模糊条目 |
三件事都不需要美术直觉,只需要把每条参考的取色真源(3–5 个 sRGB 8-bit 整数三元组)录进去。
条目 schema(D 层)
每条参考六个必填字段:id / kind / license / source / colors / intent。
kind ∈ {concept, photo, render, competitor, ui}——参考的性质,决定它能怎么用;license ∈ {own, original, cc0, cc-by, licensed}——只有前四个shippable,能直接进资产集;licensed与competitor只能做视觉参考,不得入库;colors必须是 3–5 个 0–255 整数三元组——这是唯一的取色真源,hex 只是显示形态;intent是「这条参考想支撑什么」,一句话,不许写「好看」。
注意这里没有「风格标签」字段。标签靠人工打会立刻腐化(同一张图两个人打两个标签);本节的做法是用取色真源算出归属,标签只是计算结果。
S 层:校验 + 最近邻 + 缺口
下面这段是 S 层脚本 sim/sp02_moodboard.py。它先做 schema 校验(字段、枚举、颜色区间、id 唯一),再把每条参考的三维色彩向量对六套主题做加权 L1 最近邻,算覆盖、缺口、模糊条目与色相桶覆盖,并用 PIL 生成拼版图 out/gen/sp02_moodboard.png——同一份规范既是校验输入,又是生成输入。
#!/usr/bin/env python3
# file: sim/sp02_moodboard.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp02_moodboard.py
"""参考板结构化(S 层):schema 校验 + 最近邻归属 + 覆盖缺口 + PIL 拼版图。
参考只有色彩信息,所以在三个色彩维度(明度中位数 / 饱和度均值 / 色相跨度千分比)上对
6 个主题包做加权 L1 最近邻,并用 margin(第一与第二名之差)标出归属模糊的条目。
产出 out/sp02_moodboard.json + .csv + out/gen/sp02_moodboard.png。
"""
import csv
import json
import os
import sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/sp02_moodboard.json")
OUT = os.path.join(ROOT, "out")
GEN = os.path.join(OUT, "gen")
W3 = [2, 2, 1] # 明度 / 饱和度 / 色相跨度(描边与细节参考板给不出)
MARGIN = 40 # 第一、二名距离差 ≤ MARGIN 即判「归属模糊」
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):
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 color_vector(colors):
hues = [h for h in (hue_deg(c) for c in colors) if h is not None]
span = (max(hues) - min(hues)) if len(hues) >= 2 else 0
return [median_int([luma709(c) for c in colors]) * 1000 // 255,
sum(sat_permille(c) for c in colors) // len(colors), span * 1000 // 360]
def l1(a, b):
return sum(w * abs(x - y) for w, x, y in zip(W3, a, b)) // sum(W3)
def validate(spec):
errs, seen = [], set()
for e in spec["entries"]:
tag = e.get("id", "?")
for f in ("id", "kind", "license", "source", "colors", "intent"):
if f not in e:
errs.append(f"{tag}: 缺字段 {f}")
if e.get("id") in seen:
errs.append(f"{tag}: id 重复")
seen.add(e.get("id"))
if e.get("kind") not in spec["kindEnum"]:
errs.append(f"{tag}: kind 非法 {e.get('kind')!r}")
if e.get("license") not in spec["licenseEnum"]:
errs.append(f"{tag}: license 非法 {e.get('license')!r}")
cols = e.get("colors")
if not isinstance(cols, list) or not (3 <= len(cols) <= 5):
errs.append(f"{tag}: colors 必须是 3-5 个三元组")
continue
for c in cols:
if not (isinstance(c, list) and len(c) == 3 and all(isinstance(x, int) and 0 <= x <= 255 for x in c)):
errs.append(f"{tag}: 颜色 {c!r} 不是 0-255 整数三元组")
return errs
def gen_sheet(entries, path):
cell, gap, per_row = 48, 8, 4
rows = (len(entries) + per_row - 1) // per_row
im = Image.new("RGB", (per_row * (3 * cell + gap), rows * (cell + gap)), (31, 41, 51))
for i, e in enumerate(entries):
ox, oy = (i % per_row) * (3 * cell + gap), (i // per_row) * (cell + gap)
for j, c in enumerate(e["colors"][:3]):
for y in range(cell):
for x in range(cell):
im.putpixel((ox + j * cell + x, oy + y), tuple(c))
im.save(path)
def main():
spec = json.load(open(SPEC, encoding="utf-8"))
os.makedirs(GEN, exist_ok=True)
errs = validate(spec)
if errs:
for e in errs:
print(f"FAIL {e}")
return 1
tvec = {n: color_vector([p[k] for k in sorted(p)]) for n, p in spec["themes"].items()}
shippable = set(spec["shippableLicenses"])
rows, buckets = [], set()
for e in spec["entries"]:
v = color_vector(e["colors"])
dist = {n: l1(v, tvec[n]) for n in tvec}
ranked = sorted(sorted(dist), key=lambda n: dist[n])
for c in e["colors"]:
h = hue_deg(c)
if h is not None:
buckets.add(h // 30)
rows.append({"id": e["id"], "kind": e["kind"], "license": e["license"], "source": e["source"],
"vector": v, "nearestTheme": ranked[0], "runnerUp": ranked[1], "distance": dist[ranked[0]],
"margin": dist[ranked[1]] - dist[ranked[0]], "ambiguous": dist[ranked[1]] - dist[ranked[0]] <= MARGIN,
"shippable": e["license"] in shippable, "intent": e["intent"]})
coverage = {n: sorted(r["id"] for r in rows if r["nearestTheme"] == n) for n in sorted(tvec)}
summary = {"game": spec["game"], "specVersion": spec["version"], "entryCount": len(rows),
"entries": rows, "themeColorVectors": tvec, "colorWeights": W3, "coverage": coverage,
"gaps": sorted(n for n in coverage if not coverage[n]),
"ambiguous": sorted(r["id"] for r in rows if r["ambiguous"]), "ambiguousMarginPermille": MARGIN,
"shippableCount": sum(1 for r in rows if r["shippable"]),
"nonShippable": sorted(r["id"] for r in rows if not r["shippable"]),
"hueBucketsCovered": len(buckets), "schemaErrors": errs}
with open(os.path.join(OUT, "sp02_moodboard.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
with open(os.path.join(OUT, "sp02_moodboard.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "kind", "license", "shippable", "nearestTheme", "distance", "margin", "source"])
for r in rows:
w.writerow([r["id"], r["kind"], r["license"], str(r["shippable"]).lower(), r["nearestTheme"],
r["distance"], r["margin"], r["source"]])
gen_sheet(spec["entries"], os.path.join(GEN, "sp02_moodboard.png"))
for r in rows:
print(f"{r['id']} {r['kind']:10s} {r['license']:8s} shippable={str(r['shippable']):5s} "
f"-> {r['nearestTheme']:11s} d={r['distance']:3d} margin={r['margin']:3d} vec={r['vector']}"
+ (" <-- 归属模糊" if r["ambiguous"] else ""))
print(f"coverage={json.dumps(coverage, ensure_ascii=False, sort_keys=True)}")
print(f"gaps={summary['gaps']} ambiguous={summary['ambiguous']} "
f"shippable={summary['shippableCount']}/{len(rows)} nonShippable={summary['nonShippable']} "
f"hueBuckets={len(buckets)}/12")
print("wrote out/sp02_moodboard.json out/sp02_moodboard.csv out/gen/sp02_moodboard.png")
if __name__ == "__main__":
sys.exit(main())
条目与「一处生成」的声明放在 config/sp02_moodboard.json:
{
"version": 1,
"game": "stardust-inn",
"observedAt": "2026-10",
"note": "参考板(moodboard)结构化条目。每条:id/kind/license/source/colors/intent。kind ∈ concept|photo|render|competitor|ui;license ∈ own|original|cc0|cc-by|licensed。colors 是 3-5 个 sRGB 8-bit 整数三元组(取色真源)。shippable = license ∈ {own, original, cc0, cc-by}:只有 shippable 的参考才能直接进资产集,licensed/competitor 只能做视觉参考。nearestTheme 由 colors 的 3 个色彩维度(明度中位数/饱和度均值/色相跨度)对 6 个主题包做最近邻得到;描边与细节维度参考板给不出,须人工标注。",
"kindEnum": ["concept", "photo", "render", "competitor", "ui"],
"licenseEnum": ["own", "original", "cc0", "cc-by", "licensed"],
"shippableLicenses": ["own", "original", "cc0", "cc-by"],
"themes": {
"pixel": { "bg": [26, 28, 44], "mid": [64, 80, 120], "ink": [240, 240, 232], "accent": [255, 209, 102] },
"lowpoly": { "bg": [140, 180, 210], "mid": [90, 140, 110], "ink": [245, 245, 240], "accent": [230, 140, 90] },
"handdrawn": { "bg": [250, 246, 238], "mid": [232, 220, 200], "ink": [70, 60, 52], "accent": [220, 140, 110] },
"voxel": { "bg": [40, 34, 52], "mid": [88, 72, 110], "ink": [230, 220, 240], "accent": [90, 200, 180] },
"retro-cyber": { "bg": [12, 14, 24], "mid": [40, 30, 70], "ink": [120, 255, 240], "accent": [255, 60, 140] },
"cozy-clean": { "bg": [245, 240, 230], "mid": [224, 214, 199], "ink": [92, 80, 66], "accent": [240, 168, 120] }
},
"entries": [
{ "id": "mb-01", "kind": "concept", "license": "cc0", "source": "公共领域绘本扫描", "colors": [[248, 242, 232], [226, 216, 200], [96, 84, 70]], "intent": "治愈系日间室内" },
{ "id": "mb-02", "kind": "ui", "license": "own", "source": "自绘 UI 情绪板", "colors": [[252, 248, 244], [238, 228, 214], [150, 120, 100]], "intent": "柔和高明度按钮与卡片" },
{ "id": "mb-03", "kind": "concept", "license": "cc0", "source": "公共领域水彩插画", "colors": [[249, 244, 232], [230, 216, 192], [74, 62, 50]], "intent": "手绘纸感与暖灰线" },
{ "id": "mb-04", "kind": "photo", "license": "cc-by", "source": "CC-BY 手账摄影", "colors": [[244, 238, 224], [222, 204, 178], [88, 70, 56]], "intent": "纸张纹理与米色过渡" },
{ "id": "mb-05", "kind": "render", "license": "cc0", "source": "公共领域像素素材包", "colors": [[36, 38, 58], [120, 140, 180], [250, 214, 110]], "intent": "深底高饱和像素场景" },
{ "id": "mb-06", "kind": "render", "license": "cc-by", "source": "CC-BY 低多边形场景渲染", "colors": [[150, 186, 214], [96, 146, 116], [236, 150, 96]], "intent": "明亮低面数户外配色" },
{ "id": "mb-07", "kind": "concept", "license": "licensed", "source": "已购霓虹概念图", "colors": [[16, 18, 30], [46, 34, 78], [255, 72, 150]], "intent": "暗底霓虹冷调" },
{ "id": "mb-08", "kind": "competitor", "license": "licensed", "source": "竞品截图(仅参考,不得入库)", "colors": [[80, 72, 100], [130, 110, 160], [160, 220, 200]], "intent": "体素方块的冷紫青配色" }
],
"generated": [
{ "id": "moodboard_sheet", "file": "prism/out/gen/sp02_moodboard.png", "width": 608, "height": 112, "mode": "RGB" }
]
}
实测输出
/opt/homebrew/bin/python3 sim/sp02_moodboard.py 在本机的输出(同机相对量级,绝对数值不可移植):
mb-01 concept cc0 shippable=True -> cozy-clean d= 71 margin= 19 vec=[847, 149, 13] <-- 归属模糊
mb-02 ui own shippable=True -> cozy-clean d= 86 margin= 19 vec=[898, 154, 30] <-- 归属模糊
mb-03 concept cc0 shippable=True -> cozy-clean d= 54 margin= 19 vec=[850, 185, 33] <-- 归属模糊
mb-04 photo cc-by shippable=True -> cozy-clean d= 22 margin= 18 vec=[803, 214, 44] <-- 归属模糊
mb-05 render cc0 shippable=True -> pixel d= 30 margin= 52 vec=[541, 424, 527]
mb-06 render cc-by shippable=True -> lowpoly d= 33 margin= 15 vec=[643, 411, 508] <-- 归属模糊
mb-07 concept licensed shippable=False -> retro-cyber d= 86 margin=152 vec=[152, 582, 286]
mb-08 competitor licensed shippable=False -> voxel d= 33 margin= 95 vec=[458, 288, 288]
gaps=['handdrawn'] ambiguous=['mb-01', 'mb-02', 'mb-03', 'mb-04', 'mb-06']
shippable=6/8 nonShippable=['mb-07', 'mb-08'] hueBuckets=8/12
四点结论:
gaps=['handdrawn']:8 条参考里没有一条落在 handdrawn 上。这不是巧合——mb-03、mb-04本来按意图是手绘参考,却被最近邻判到了cozy-clean。参考板的价值就在于把这种「以为覆盖了、其实没有」暴露出来。ambiguous有 5 条:margin(第一、二名距离之差)≤ 40 就判模糊。mb-01~mb-04的 margin 都只有 18–19,说明在色彩维度上cozy-clean与handdrawn几乎不可分——这与 2.2 算出的「两者 OKLab 均值色距离最小(0.029133)」完全吻合。补法很明确:给这几条加人工标注的strokePermille/detailPermille,把归属拉到五维空间里做。shippable=6/8:mb-07(已购)与mb-08(竞品截图)都不可入库。它们能进参考板,但不能进资产集——这条规则必须由脚本判,不能靠人记。hueBuckets=8/12:8 条参考只覆盖了 12 个 30° 色相桶里的 8 个,缺失的那 4 个桶正好对应 handdrawn 的暖灰与 voxel 的冷紫青过渡区。
拼版图与「一处生成」
脚本按 config 里声明的尺寸生成 out/gen/sp02_moodboard.png(608×112、RGB、8 行 3 色块)。这张图不是给人「看灵感」的,而是规范的产物:同一个 config 既生成它,又是它的验收标准——prism_rungen.py 会回读这张 PNG,断言尺寸、模式与声明一致。这就是本套「一份视觉规范,三处消费、一处生成」里的那一处「生成」。
缺口怎么补
| 缺口 | 补法 |
|---|---|
handdrawn 无参考 | 补 2–3 条手绘/水彩参考,取色真源必须重录 |
| 暖色条目归属模糊 | 加 strokePermille / detailPermille 人工标注,改到五维最近邻 |
| 色相桶缺 4 个 | 定向补参考,优先补 handdrawn 与 voxel 的过渡色 |
licensed / competitor 混入 | 由 shippable 判定拦住,禁止进资产集 |
X 层:素材的真实授权状态、竞品截图能否引用、以及「这张参考到底像不像我们想要的」的审美判断,需真实法务与真实画师,命令行不可复现。脚本只能校验 license 字段的取值与 shippable 规则,判不了授权文件本身。
自检表
| 检查项 | 合格标准 |
|---|---|
| 条目有 schema | 六字段齐全,kind / license 在枚举内 |
| 颜色是真源 | colors 是 3–5 个 0–255 整数三元组,不是 hex 字符串 |
| 覆盖可算 | coverage / gaps 由脚本算出,不靠人点 |
| 模糊可见 | margin 标出归属模糊的条目,不静默归类 |
| 可入库性 | shippable 由 license 判定,licensed / competitor 不得入库 |
| 生成可验 | 拼版图的尺寸与模式在 config 里声明,可回读断言 |
阅读导航:上一节:2.2 风格向量与风格距离 · 下一节:3.1 主色/辅色/强调色与明度梯度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。