#!/usr/bin/env python3 # -*- coding: utf-8 -*- """B2 色板检查器(圣经 §8.3①挂点实现)。 ⚠⚠ 口径纪律(**勿改回**)——两处极易写错: 1. **48 色上限不是「每张 PNG 的色数」**。§4.1 的「全作色板硬上限 48 色」指 **设计色板 token**(UI 语义色/墨色/材质色那套),不是像素画的独立色数。 像素画单张另有**32 色冻结**口径,由 standee_asset_check 管(像素域)。 本器口径:单张色数**只报读数不判红**;48 只在 --tokens 模式下校验色板表。 2. **近色 ≥8/255 是「新色入板双门」,不是存量两两互查**。§4.1 原文: 「**新色**入板须同时过两道门……②与既有任一色的 RGB 三通道差 ≥8/255」。 它管的是**新增色 vs 既有色板**。对存量色两两互查会把 AI 生成的明暗渐变 全部打成近色(实测 drider 22 组 / 巨蜘蛛 82 组)⇒ 噪声淹没真问题。 本器口径:近色**只对新增色集**判(新增色 vs 基线色板)。 另:必须排除全透明像素 (0,0,0,0)(同standee_asset_check 的坑)。 用法: # 主用法:扫目录,新增色相对 --baseline 判定 python tools/palette_check.py <目录> --baseline <基线目录> # 无基线:只出读数(单张色数/ 近色对计数),不判红 python tools/palette_check.py <目录> 退出码: 0 通过 / 1 有红灯(仅新增色违规或超单张上限时) """ import os import sys from PIL import Image # §4.1 硬上限(**设计色板 token 口径**,非像素画单张色数) TOKEN_MAX = 48 NEAR_TOL = 8 # §4.1 新色双门:与既有任一色三通道差 ≥8/255 # 像素画单张色数(像素域32 色冻结)——由 standee_asset_check 执法, # 本器只作读数提示,避免两处口径打架 PIXEL_COLOR_SOFT_MAX = 32 def palette_of(path): """返回 (不透明像素唯一色集合, 像素数)。排除全透明。""" im = Image.open(path).convert("RGBA") px = im.load() w, h = im.size cols = set() n = 0 for y in range(h): for x in range(w): c = px[x, y] if c[3] == 0: continue cols.add(c[:3]) n += 1 return cols, n def nearest_to(col, pool, tol): """返回 col 在 pool 中最近的色及其差值(差 < tol 算近色)。""" best, bd = None, 999 for c in pool: d = max(abs(col[k] - c[k]) for k in range(3)) if d < bd: best, bd = c, d return best, bd def hexs(c): return "#%02X%02X%02X" % c def scan(d, baseline_cols, report_only): files = [] for dirpath, _, names in os.walk(d): for fn in sorted(names): if fn.lower().endswith(".png"): files.append(os.path.join(dirpath, fn)) files.sort() print("=== %s(%d 张 PNG)===" % (d, len(files))) n_fail = 0 all_new = set() for p in files: cols, npx = palette_of(p) rel = os.path.relpath(p, d) notes = [] msgs = [] over = len(cols) > PIXEL_COLOR_SOFT_MAX notes.append("色%d%s" % (len(cols), "(>32 像素域冻结)" if over else "")) new = set() if baseline_cols is not None: new = cols - baseline_cols if new: # §4.1 新色双门②:新增色须与既有任一色差 ≥8/255 bad = [] for c in sorted(new): near, d3 = nearest_to(c, baseline_cols, 999) if d3 < NEAR_TOL: bad.append((c, near, d3)) all_new |= new notes.append("新增%d" % len(new)) for c, near, d3 in bad[:5]: msgs.append("新增色 %s 与既有 %s 三通道差 %d < %d" "(§4.1 新色双门②,须走换色合并)" % (hexs(c), hexs(near), d3, NEAR_TOL)) if len(bad) > 5: msgs.append("…… 另有 %d 个新增色同样不过门" % (len(bad) - 5)) else: notes.append("新增0") line = "FAIL " if msgs else ("WARN " if (report_only and over) else "PASS ") print("%s %-46s %s" % (line, rel, " ".join(notes))) for m in msgs: print(" └─ %s" % m) n_fail += 1 if msgs else 0 print("小计:%d 张,%d 张有红灯\n" % (len(files), n_fail)) return n_fail, len(files), all_new def main(): args = sys.argv[1:] baseline = args[args.index("--baseline") + 1] if "--baseline" in args else None dirs = [a for a in args if not a.startswith("--") and os.path.isdir(a) and a != baseline] if not dirs: print(__doc__) sys.exit(2) baseline_cols = None if baseline: baseline_cols = set() for dirpath, _, names in os.walk(baseline): for fn in names: if fn.lower().endswith(".png"): cols, _ = palette_of(os.path.join(dirpath, fn)) baseline_cols |= cols print("基线色板:%s,唯一色 %d" % (baseline, len(baseline_cols))) total_fail = total_files = 0 total_new = set() report_only = baseline_cols is None for d in dirs: f, n, new = scan(d, baseline_cols, report_only) total_fail += f total_files += n total_new |= new print("色板机检完成:%d 张 PNG,%d 张有红灯,累计新增色 %d" % (total_files, total_fail, len(total_new))) if report_only: print("提示:未给 --baseline ⇒ 本次为只读模式(不判红)。" "『色数>32』标 WARN 属像素域提示,最终执法在 standee_asset_check。") sys.exit(1 if total_fail else 0) if __name__ == "__main__": main()