Files
gplan-2/tools/standee_asset_check.py
T

212 lines
8.8 KiB
Python
Raw 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.2 站桩立绘显示档 = 三档绝对高):
- 逐键校验源高(逐件断言 STANDEE_SRC_H——入包件 = 原稿紧裁原样,禁降采样);
- 显示宽按「round(源宽 × 档高 / 源高) ≤ 站桩盒宽 150」校验
(食人魔酋长 157 超宽 8px 为已接受项,登记例外表逐字校验);
- 像素域色数 ≤32(**排除全透明像素**,见下方口径注);
- 无非整数倍缩放痕迹(alpha 二值 + 无中间 α);
- 描边亮度达标(亮描边暖白族,character-design §10硬规则「禁黑描边」)。
⚠ 色数口径坑(勿改回naive 统计):
naive `len(set(所有像素))` 会把全透明 (0,0,0,0) 计成一个色 ⇒ 报33 假红灯。
正确口径 = 只统计 alpha>0 像素的唯一 RGB。机检必须如此,否则天天误报。
表单点 = tools/pixelize_candidates.py 的 STANDEE_DISPLAY_H / STANDEE_SRC_H /
STANDEE_SLOT_H(定值单点纪律 §8:本件不在检查器里复写一份,改由 import 读;
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__)))
from pixelize_candidates import STANDEE_DISPLAY_H, STANDEE_SRC_H, STANDEE_SLOT_H
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
BOX_W = 150 # standee_body.gd:29 var box := Vector2(150, 176)
# 显示宽例外(已接受项——art-bible §8.2 槽位条):食人魔酋长显示宽 157 超桩盒 150,
# 逐字校验 = 实算宽必须恰等于登记值,源稿一变即红。
DISPLAY_W_EXCEPT = {"character_enemy_ogre_chief.png": 157}
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 src_key_of(fn):
"""按文件名返回 STANDEE_SRC_H 键(敌=键名;主角三态同键 hero)。"""
if fn.startswith("character_enemy_") and fn.endswith(".png"):
return fn[len("character_enemy_"):-len(".png")]
return "hero" # 主角三态同源稿高
def display_h_of(fn):
"""按文件名返回显示档高(敌=逐键显示档;主角=中型档 144)。"""
key = src_key_of(fn)
if key == "hero":
return STANDEE_SLOT_H
return STANDEE_DISPLAY_H.get(key)
def check(path, fn):
"""返回 (problems, info)。info 含机检读数。"""
problems = []
im = Image.open(path).convert("RGBA")
w, h = im.size
px = im.load()
# 断言① 逐件源高 = 原稿表值(禁降采样——旧降采样稿必红)
src_h = STANDEE_SRC_H.get(src_key_of(fn))
if src_h is not None and h != src_h:
problems.append("源高应 %d(原稿表值——禁降采样),实得 %d" % (src_h, h))
# 断言② 显示宽物理约束:round(源宽 × 档高 / 源高) ≤ 盒宽 150(酋长 157 例外逐字校验)
disp_h = display_h_of(fn)
disp_w = -1
if disp_h is not None and src_h:
disp_w = int(round(w * disp_h / float(src_h)))
if disp_w > BOX_W and DISPLAY_W_EXCEPT.get(fn) != disp_w:
problems.append("显示宽 %d 超站桩盒宽 %d(§8.2 高定宽随;例外表未登记)"
% (disp_w, 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],
"src_h": src_h,
"display_h": disp_h,
"display_w": disp_w,
"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 显示%dx%d 色数%d 暖白边%.1f%%%s"
% (r["file"], i["size"][0], i["size"][1], i["src_h"],
i["display_w"], i["display_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()