2.2 调色板量化与抗锯齿替代
上一节 2.1 分辨率、像素密度与调色板上限 定下 8 个角色 token 与调色板上限;但美术画的是连续色,最终要塞进这 8 个色。塞进去会亏多少,就是本节要算的整数色差账。像素风的技法细节见 《独立游戏像素美术风格指南》 ,本节只算账:量化误差与抗锯齿替代。
量化用的距离是 colorDistInt = (3|dr| + 6|dg| + |db|) // 10——权重 3/6/1 逼近 luma 的 2126/7152/722,末位地板除,是纯整数,跨语言逐位一致。最近邻量化把每个源色映射到距离最小的调色板色;有序抖动则允许在 2×2 块内用两种调色板色交替,人眼平均后得到比单色更接近源色的中间调。
下面这段是 S 层脚本,读同一份 config/st02_pixel.json,造 16 级色阶条,分别算最近邻与抖动的总误差,并给出抗锯齿替代的色数账。
#!/usr/bin/env python3
# file: sim/st02_quantize.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st02_quantize.py
"""2.2 调色板量化与抗锯齿替代(S 层,像素风)。
读 config/st02_pixel.json 与 sp16_themes.json 的 pixel 主题,算「整数色差账」:
① 造一条 16 级色阶条(bg → accent,整数插值),逐级映射到最近调色板色(加权 L1 整数距离),
记总误差;再逐级找**最优两色对**(取整数均值后比距离),记抖动后的总误差——两者的差就是
有序抖动相对最近邻量化的收益;
② 抗锯齿替代:自由混色能造 P(P+1)/2 色,而有序抖动(Bayer)只用调色板内两色,新色数恒为 0。
距离 `colorDistInt = (3|dr| + 6|dg| + |db|) // 10` 是纯整数(权重 3/6/1 逼近 luma),跨语言
逐位一致。PIL 生成「源色阶 / 抖动结果」对照 PNG 并回读。落 out/st02_quantize.json + svg。
"""
import hashlib
import json
import os
import sys
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
ORDER = ("bg", "surface", "ink", "muted", "accent", "ok", "warn", "danger")
BAYER2 = ((0, 2), (3, 1)) # 2×2 有序抖动矩阵
def color_dist_int(a, b):
"""加权 L1 整数距离:权重 3/6/1 逼近 luma,末位地板除。逐位一致。"""
return (3 * abs(a[0] - b[0]) + 6 * abs(a[1] - b[1]) + 1 * abs(a[2] - b[2])) // 10
def nearest(s, palette):
"""最近调色板色:距离相同取下标小者(确定性)。返回 (下标, 距离)。"""
best = None
for i, p in enumerate(palette):
d = color_dist_int(s, p)
if best is None or d < best[1]:
best = (i, d)
return best
def best_pair(s, palette):
"""最优两色对:枚举单色与所有无序对(取整数均值),返回 (下标 i, 下标 j, 距离)。"""
cands = [(i, i, palette[i]) for i in range(len(palette))]
for i in range(len(palette)):
for j in range(i + 1, len(palette)):
cands.append((i, j, [(palette[i][k] + palette[j][k]) // 2 for k in range(3)]))
best = None
for i, j, avg in cands:
key = (color_dist_int(s, avg), i, j)
if best is None or key < best[0]:
best = (key, (i, j))
return best[1][0], best[1][1], best[0][0]
def ramp(a, b, n):
"""整数线性插值:第 i 级 = a + (b − a) × i // (n − 1)。"""
return [[a[k] + (b[k] - a[k]) * i // (n - 1) for k in range(3)] for i in range(n)]
def gen_quantize(src, pairs, palette, path, size):
"""上排源色阶、下排抖动结果(2×2 Bayer 在调色板内两色间交替,不引入新色)。"""
n, cell = len(src), 16
w, h = size
im = Image.new("RGB", (w, h), (0, 0, 0))
d = ImageDraw.Draw(im)
for i, s in enumerate(src):
d.rectangle([i * cell, 0, (i + 1) * cell - 1, h // 2 - 1], fill=tuple(s))
for i, (pi, pj) in enumerate(pairs):
for y in range(h // 2, h):
for x in range(i * cell, (i + 1) * cell):
c = palette[pi] if BAYER2[(y - h // 2) % 2][x % 2] < 2 else palette[pj]
im.putpixel((x, y), tuple(c))
im.save(path)
def write_svg(rows, path):
"""最近邻 vs 抖动:每级两根条,条长 = 误差 × 12,整数。"""
W, H = 420, 24 * len(rows) + 28
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#1a1c2c"})
for i, r in enumerate(rows):
y = 10 + i * 24
ET.SubElement(svg, "text", {"x": "8", "y": str(y + 10), "fill": "#f0f0e8",
"font-family": "monospace", "font-size": "10"}).text = f"L{i:02d}"
ET.SubElement(svg, "rect", {"x": "48", "y": str(y), "width": str(r["err1"] * 12),
"height": "8", "fill": "#ff6b6e"})
ET.SubElement(svg, "rect", {"x": "48", "y": str(y + 10), "width": str(r["err2"] * 12),
"height": "8", "fill": "#6edc8c"})
tree = ET.ElementTree(svg)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st02_pixel.json"), encoding="utf-8"))
themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
theme = next(t for t in themes["themes"] if t["id"] == cfg["theme"])
palette = [theme["tokens"][k] for k in ORDER]
q = cfg["quantize"]
src = ramp(theme["tokens"][q["fromToken"]], theme["tokens"][q["toToken"]], q["gradientSteps"])
rows, pairs = [], []
for s in src:
ni, err1 = nearest(s, palette)
pi, pj, err2 = best_pair(s, palette)
rows.append({"src": s, "nearest": ni, "err1": err1, "pair": [pi, pj], "err2": err2})
pairs.append((pi, pj))
total1, total2, n = sum(r["err1"] for r in rows), sum(r["err2"] for r in rows), len(rows)
p = len(palette)
aa_colors, dither_new = p * (p + 1) // 2, 0
gen = next(g for g in cfg["generated"] if g["id"] == "pixel_quantize")
png = os.path.join(os.path.dirname(ROOT), gen["file"])
os.makedirs(os.path.dirname(png), exist_ok=True)
gen_quantize(src, pairs, palette, png, (gen["width"], gen["height"]))
with Image.open(png) as im:
info = {"size": list(im.size), "mode": im.mode,
"colors": len(set(im.convert("RGB").get_flattened_data()))}
canon = json.dumps({"totalErr1": total1, "totalErr2": total2,
"aaColors": aa_colors, "ditherNewColors": dither_new}, separators=(",", ":"))
report = {"project": cfg["project"], "theme": cfg["theme"], "paletteSize": p, "gradientSteps": n,
"totalErr1": total1, "totalErr2": total2,
"meanErr1X1000": total1 * 1000 // n, "meanErr2X1000": total2 * 1000 // n,
"ditherGain": total1 - total2, "aaColors": aa_colors,
"ditherPairs": p * (p - 1) // 2, "ditherNewColors": dither_new, "levels": rows,
"png": info, "canonical": canon,
"digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "st02_quantize.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
write_svg(rows, os.path.join(out, "st02_quantize.svg"))
print(f"paletteSize={p} gradientSteps={n}")
print(f"nearest: totalErr={total1} meanX1000={total1 * 1000 // n}")
print(f"dither : totalErr={total2} meanX1000={total2 * 1000 // n} gain={total1 - total2}")
print(f"aaColors={aa_colors} ditherPairs={p * (p - 1) // 2} ditherNewColors={dither_new}")
print(f"png {info['size'][0]}x{info['size'][1]} {info['mode']} colors={info['colors']}")
print(f"digest={report['digest']}")
print("wrote out/st02_quantize.json out/st02_quantize.svg out/gen/st02_quantize.png")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st02_quantize.py:
paletteSize=8 gradientSteps=16
nearest: totalErr=381 meanX1000=23812
dither : totalErr=141 meanX1000=8812 gain=240
aaColors=36 ditherPairs=28 ditherNewColors=0
png 256x64 RGB colors=22
digest=dbdf338e47fea1d5
wrote out/st02_quantize.json out/st02_quantize.svg out/gen/st02_quantize.png
读出来:最近邻量化的总误差是 381(每级均摊 23812 千分比),换成最优两色对抖动后降到 141(8812),收益 240——抖动不是玄学,是能被整数距离量出来的。另一笔账更关键:自由混色(抗锯齿)能造出 P(P+1)/2 = 36 种颜色,直接撑爆 8 色调色板;有序抖动只用调色板内两色,新色数恒为 0。这就是像素风用抖动不用抗锯齿的硬理由。
下面这段是 P 层 TypeScript 内核 core/src/st02/quantize.ts,与 S 脚本逐条对应:整数距离、最近邻、最优两色对、色阶条与抗锯齿色数。
// file: core/src/st02/quantize.ts
// tier: P
// verify: node --test 'core/test/st02_*.test.ts'
// 2.2 调色板量化与抗锯齿替代的 TS 内核:加权 L1 整数距离、最近邻量化、最优两色对抖动。
// 距离度量 `colorDistInt = (3|dr| + 6|dg| + |db|) // 10` 是纯整数(权重 3/6/1 逼近 luma),
// 跨语言逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 装饰器)。
export type RGB = number[];
/** 加权 L1 整数距离:权重 3/6/1 逼近 luma,末位地板除。逐位一致。 */
export function colorDistInt(a: RGB, b: RGB): number {
return Math.floor(
(3 * Math.abs(a[0] - b[0]) + 6 * Math.abs(a[1] - b[1]) + 1 * Math.abs(a[2] - b[2])) / 10,
);
}
/** 最近调色板色:距离相同取下标小者(确定性)。返回 [下标, 距离]。 */
export function nearest(s: RGB, palette: RGB[]): number[] {
let best: number[] | null = null;
for (let i = 0; i < palette.length; i++) {
const d = colorDistInt(s, palette[i]);
if (best === null || d < best[1]) best = [i, d];
}
return best as number[];
}
/** 最优两色对:枚举单色与所有无序对(取整数均值),返回 [下标 i, 下标 j, 距离]。 */
export function bestPair(s: RGB, palette: RGB[]): number[] {
const cands: number[][] = [];
for (let i = 0; i < palette.length; i++) cands.push([i, i, palette[i][0], palette[i][1], palette[i][2]]);
for (let i = 0; i < palette.length; i++) {
for (let j = i + 1; j < palette.length; j++) {
cands.push([i, j,
Math.floor((palette[i][0] + palette[j][0]) / 2),
Math.floor((palette[i][1] + palette[j][1]) / 2),
Math.floor((palette[i][2] + palette[j][2]) / 2)]);
}
}
let best: number[] | null = null;
for (const c of cands) {
const d = colorDistInt(s, [c[2], c[3], c[4]]);
if (best === null || d < best[0] || (d === best[0] && (c[0] < best[1] || (c[0] === best[1] && c[1] < best[2])))) {
best = [d, c[0], c[1]];
}
}
const b = best as number[];
return [b[1], b[2], b[0]];
}
/** 整数线性插值:第 i 级 = a + (b − a) × i // (n − 1)。 */
export function ramp(a: RGB, b: RGB, n: number): RGB[] {
const out: RGB[] = [];
for (let i = 0; i < n; i++) {
out.push([0, 1, 2].map((k) => a[k] + Math.floor(((b[k] - a[k]) * i) / (n - 1))));
}
return out;
}
/** 抗锯齿替代:自由混色可造色数 = P(P+1)/2;抖动只用调色板内两色,新色数恒为 0。 */
export function aaColors(p: number): number {
return Math.floor((p * (p + 1)) / 2);
}
配套测试 core/test/st02_quantize.test.ts 读 Python 落盘的 out/st02_quantize.json,跑 node --test 'core/test/st02_quantize.test.ts':
✔ 整数距离:权重 3/6/1、末位地板除,逐位一致 (0.458541ms)
✔ 最近邻量化总误差:与 Python 逐位一致 (0.118583ms)
✔ 抖动总误差:最优两色对严格优于最近邻 (0.345625ms)
✔ 抗锯齿替代:自由混色 36 色,抖动新色数 0 (0.069541ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.4505ms)
✔ 负例:抖动若引入调色板外的新色,账就不成立 (0.225792ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
六条全绿:最近邻总误差与抖动总误差都与 Python 逐位一致,canonical 摘要的 sha256 指纹为 dbdf338e47fea1d5,且抖动严格优于最近邻。负例「抖动若引入调色板外的新色」守住「新色数必须为 0」这条底线——一旦抖动偷偷造了新色,账就不成立。测试全文见工作区 core/test/st02_quantize.test.ts。
自检表
| 检查项 | 合格标准 |
|---|---|
| 距离是整数 | 权重 3/6/1、末位地板除,无浮点 |
| 抖动有收益 | 两色对总误差严格小于最近邻 |
| 不越调色板 | 抖动结果只用调色板内两色,新色数为 0 |
| 边界清晰 | 真实观感标 X,不写成「实测」 |
调色板与量化都定下后,最后一个整数约束是画布本身:逻辑分辨率能切成多少 tile,一套 tileset 占多少字节。
阅读导航:上一节:2.1 分辨率、像素密度与调色板上限 · 下一节:2.3 像素 tile 与 tile 预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。