Files
gplan-2/tools/standee_asset_check.py

203 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()