#!/usr/bin/env python3 # -*- coding: utf-8 -*- """站桩素材机检(B1 硬闸升级)。 升级依据=art-bible §8.3③ 挂点规格(审计 §07-2/3): - 逐键校验档高(逐键断言 STANDEE_TIER_H),宽度按「源宽×2 ≤ 站桩盒宽 150」 物理约束校验; - 像素域色数 ≤32(**排除全透明像素**,见下方口径注); - 无非整数倍缩放痕迹(alpha 二值 + 无中间 α); - 描边亮度达标(亮描边暖白族,character-design §10硬规则「禁黑描边」)。 ⚠ 色数口径坑(勿改回naive 统计): naive `len(set(所有像素))` 会把全透明 (0,0,0,0) 计成一个色 ⇒ 报33 假红灯。 正确口径 = 只统计 alpha>0 像素的唯一 RGB。机检必须如此,否则天天误报。 档高表= tools/pixelize_candidates.py 的 STANDEE_TIER_H(定值单点纪律 §8:本表 不在检查器里复写一份,改由 import 读,避免两处数字漂移)。 用法: python tools/standee_asset_check.py [角色素材目录] 默认 game/assets/art/character/ --json 输出机器可读格式 退出码: 0 全过 / 1 有红灯 """ import json import os import sys from PIL import Image sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: from pixelize_candidates import STANDEE_TIER_H, STANDEE_SLOT_H except ImportError: # pragma: no cover STANDEE_TIER_H = { "goblin": 36, "giant_spider": 38, "duergar": 46, "hook_horror": 58, "bugbear": 62, "ogre_chief": 74, "drider": 90, "duergar_king": 94, } STANDEE_SLOT_H = 48 ENEMY_KEYS = ("bugbear", "drider", "duergar", "duergar_king", "giant_spider", "goblin", "hook_horror", "ogre_chief") REQUIRED = ["character_enemy_%s.png" % k for k in ENEMY_KEYS] + \ ["character_hero_portrait.png", "character_hero_attack.png", "character_hero_hit.png"] # ── 契约常量(全部有出处,禁复写第二份) ────────────────────────────── COLOR_MAX = 32 # 圣经 §4.1/fixer 32 色冻结;机检口径=alpha>0 唯一 RGB DISPLAY_SCALE = 2 # standee_body.gd:237 _portrait_sprite.scale = (2,2) BOX_W = 150 # standee_body.gd:29 var box := Vector2(150, 176) SRC_W_MAX = BOX_W // DISPLAY_SCALE # =75,源宽上限(×2 后须塞进盒宽) ALPHA_BIN = {0, 255} # 二值 alpha 域 # 描边亮度阈值(实测标定): # 敌人暖白边占比实测 54.1%–84.3%,主角 portrait/hit 实测 0.4%(无暖白描边,见下)。 # 敌人闸取 40%(低于实测下限 54.1% 留余量,高于噪声);主角**豁免**—— # character-design §10要求亮描边,但现役三态主角实为暗边,属已知缺口(暂无修复安排), # 机检只报读数不判红,避免闸永久红。 OUTLINE_WARM_L_MIN = 150 # 暖白 = 亮度 ≥150 且低饱和 OUTLINE_WARM_S_MAX = 0.35 OUTLINE_MIN_ENEMY = 40.0 # 敌人:暖白描边像素占轮廓像素 ≥40% HERO_OUTLINE_EXEMPT = ("portrait", "attack", "hit") def _lum(c): return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] def _sat(c): mx, mn = max(c[:3]), min(c[:3]) return 0.0 if mx == 0 else (mx - mn) / float(mx) def expected_tier_h(fn): """按文件名返回应有档高。""" if fn.startswith("character_enemy_") and fn.endswith(".png"): key = fn[len("character_enemy_"):-len(".png")] return STANDEE_TIER_H.get(key) return STANDEE_SLOT_H # 主角三态同档 def check(path, fn): """返回 (problems, info)。info 含机检读数。""" problems = [] im = Image.open(path).convert("RGBA") w, h = im.size px = im.load() # 断言①逐键档高 tier = expected_tier_h(fn) if tier is not None and h != tier: problems.append("档高应 %d(§8.2 逐键定档),实得 %d" % (tier, h)) # 断言② 宽度物理约束:源宽 ×2 须塞进站桩盒宽 150 if w > SRC_W_MAX: problems.append("源宽 %d ×2=%d 超站桩盒宽 %d(§8.2 高定宽随但不得溢出盒)" % (w, w * DISPLAY_SCALE, BOX_W)) if w < 1: problems.append("源宽 %d 非法" % w) colors = set() alphas = set() edges = [] bottom_opaque = 0 for y in range(h): for x in range(w): c = px[x, y] alphas.add(c[3]) if c[3] == 0: continue colors.add(c[:3]) # 口径:只收不透明像素的 RGB if y == h - 1: bottom_opaque += 1 nb = ((x > 0 and px[x - 1, y][3] == 0) or (x < w - 1 and px[x + 1, y][3] == 0) or (y > 0 and px[x, y - 1][3] == 0) or (y < h - 1 and px[x, y + 1][3] == 0)) if nb: edges.append(c) # 断言③ 底中锚(脚贴画布底缘) if bottom_opaque < 1: problems.append("底行无不透明像素(底中锚失守——脚未贴画布底缘)") # 断言④ 色数 ≤32(alpha>0 口径) if len(colors) > COLOR_MAX: problems.append("色数 %d > %d(口径=alpha>0 唯一 RGB)" % (len(colors), COLOR_MAX)) # 断言⑤ 无非整数倍缩放痕迹:alpha 二值(双线性/混合会留中间 α) if not alphas <= ALPHA_BIN: problems.append("alpha 非二值(插值/非整数倍缩放痕迹:%s)" % sorted(alphas - ALPHA_BIN)[:5]) # 断言⑥ 描边亮度(亮描边暖白族,禁黑描边) warm_pct = 0.0 dark_pct = 0.0 if edges: warm = sum(1 for c in edges if _lum(c) >= OUTLINE_WARM_L_MIN and _sat(c) <= OUTLINE_WARM_S_MAX) dark = sum(1 for c in edges if _lum(c) < 60) warm_pct = warm * 100.0 / len(edges) dark_pct = dark * 100.0 / len(edges) is_hero = any(t in fn for t in HERO_OUTLINE_EXEMPT) if not is_hero and warm_pct < OUTLINE_MIN_ENEMY: problems.append("暖白描边占比 %.1f%% < %d%%(亮描边纪律,character-design §10)" % (warm_pct, OUTLINE_MIN_ENEMY)) info = { "size": [w, h], "tier_h": tier, "colors": len(colors), "bottom_opaque": bottom_opaque, "edge_px": len(edges), "outline_warm_pct": round(warm_pct, 1), "outline_dark_pct": round(dark_pct, 1), "outline_exempt": any(t in fn for t in HERO_OUTLINE_EXEMPT), } return problems, info def main(): argv = [a for a in sys.argv[1:] if not a.startswith("--")] as_json = "--json" in sys.argv root = argv[0] if argv else os.path.join("game", "assets", "art", "character") missing, results, n_fail = [], [], 0 for f in REQUIRED: p = os.path.join(root, f) if not os.path.isfile(p): missing.append(f) n_fail += 1 continue problems, info = check(p, f) results.append({"file": f, "problems": problems, "info": info}) n_fail += 1 if problems else 0 if as_json: print(json.dumps({"root": root, "missing": missing, "results": results, "fail": n_fail}, ensure_ascii=False, indent=2)) sys.exit(1 if n_fail else 0) for f in missing: print("MISS %-36s 缺位" % f) for r in results: i = r["info"] if r["problems"]: for pr in r["problems"]: print("FAIL %-36s %s" % (r["file"], pr)) else: print("PASS %-36s %dx%d 档高%d 色数%d 暖白边%.1f%%%s" % (r["file"], i["size"][0], i["size"][1], i["tier_h"], i["colors"], i["outline_warm_pct"], "(豁免)" if i["outline_exempt"] else "")) print("机检完成:%d 件必需,%d 件未过" % (len(REQUIRED), n_fail)) if not as_json: print("提示:主角三态暖白描边占比实测≈0.4%(暗边),属已知缺口," "本闸豁免不判红——如需修另立主角描边素材批。") sys.exit(1 if n_fail else 0) if __name__ == "__main__": main()