212 lines
8.8 KiB
Python
212 lines
8.8 KiB
Python
#!/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()
|