Files
gplan-2/tools/standee_asset_check.py
T
DKChan b163341915 feat(tools): B段机检三件落地 + 逐键定档硬闸升级(立绘定档批)
B1 standee_asset_check 硬闸升级(圣经 §8.3③ 挂点规格落地):
- 逐键断言档高(原「高恒48」退役)——档高表改为 import pixelize_candidates.
  STANDEE_TIER_H,守定值单点纪律,检查器内不复写第二份数字;
- 宽度约束由虚设区间「1..75」改为物理约束「源宽×2 ≤ 站桩盒宽150」
  (standee_body.gd:29 box.x=150 / :237 scale=2.0);
- 色数 ≤32,**排除全透明像素**——naive set统计会把 (0,0,0,0) 计成一色
  ⇒ 报33 假红灯(2026-10-07 实踩),口径已固化进docstring;
- 新增描边亮度断言(character-design §10 亮描边暖白族/禁黑描边),
  阈值 40% 由 _tmp_probe_outline.py 实测标定(敌人实测 54.1–84.3%);
  主角三态实测≈0.4% 属已知缺口,本闸豁免不判红,另立素材批修。
- 负向验证:喂旧 48 档 ⇒ 8 件红灯(逐键档高+2 件描边不足),闸确有效。
- 新增 --json 输出。

B2 palette_check 新建:§4.1 新色双门执法器。**两处口径订正写进 docstring**:
① 48 色上限治的是设计色板 token,不是每张 PNG 色数(初版误套⇒12/12 假红);
② ≥8/255 近色门只管「新增色 vs 既有色板」,不是存量两两互查
  (初版误查存量⇒ drider22 组/巨蜘蛛 82 组噪声)。无 --baseline 时只读不判红。

B3 path_check 新建:asset-path-map 三层核对(目录/文件/res://引用/退役命名)。
口径订正:① res:// 根 = game/ 目录(project.godot 处),非仓库根;
② printf 路径模板(terrain_%s_%d)非字面路径,跳过;
③ 退役命名只扫代码+素材清单,且命中行含「退役/更正/改判」等记述词则豁免
  (初版全仓扫 .md ⇒ active.md/记忆文件里的历史记述全被误判,64 假红);
④ 素材环境三态区分(full/partial/empty)——worktree 素材靠人工拷贝同步,
  partial 态缺项判「未同步」而非「悬空」(character/ 已拷、fx|board|ui 未拷)。
- 首跑读数:主区 1 处真问题 = art-bible §8.5 参考核验条仍指废路径
  production/mockups/(v3.9 批更正了 4 处,此条漏网)——本批已修。

A0/A1 实测读数归档:八敌新档逐键命中 STANDEE_TIER_H(36/38/46/58/62/74/90/94),
主角三态 md5 与旧档逐字节相同(主角档零漂移实证)。
2026-10-07 18:39:31 +08:00

203 lines
8.3 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 硬闸升级 · 立绘定档批 2026-10-07)。
升级依据=art-bible §8.3③ 挂点规格(审计 §07-2/3):
- 逐键校验档高(**原「宽 1..75」虚设区间退役**——宽度改为「源宽×2 ≤ 站桩盒宽 150」
的物理约束,档高改为逐键断言 STANDEE_TIER_H);
- 像素域色数 ≤32(**排除全透明像素**,见下方口径注);
- 无非整数倍缩放痕迹(alpha 二值 + 无中间 α);
- 描边亮度达标(亮描边暖白族,character-design §10硬规则「禁黑描边」)。
⚠ 色数口径坑(2026-10-07 实踩,勿改回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 域
# 描边亮度阈值(2026-10-07 _tmp_probe_outline.py 实测标定):
# 敌人暖白边占比实测 54.1%–84.3%,主角 portrait/hit 实测 0.4%(无暖白描边,见下)。
# 敌人闸取 40%(低于实测下限 54.1% 留余量,高于噪声);主角**豁免**——
# character-design §10要求亮描边,但现役三态主角实为暗边,属已知缺口,
# 不在本批(美术素材批 A 范围)修,机检只报读数不判红,避免闸永久红。
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()
# 断言①逐键档高(原「高恒 48」已随定档批退役)
tier = expected_tier_h(fn)
if tier is not None and h != tier:
problems.append("档高应 %d(§8.2 逐键定档),实得 %d" % (tier, h))
# 断言② 宽度物理约束:源宽 ×2 须塞进站桩盒宽 150(替代旧虚设区间 1..75)
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()