- ui_assembler.gd: 副标题 label 两行删除(标题与分隔线之间;局部变量无外部依赖,VBox 回流) - shot_probe.gd: menu_sub 文字锚/自检腿退役(menu_title 保留)+ 菜单段头部历史注记换代 - shot_checker.py: 菜单·副标题 box/presence 两断言腿同撤(_meta 记录端随 pair 列表自断),不重锚 - golden: menu_boot.png 基线换代——触发原因=副标题件删除画面少一行字(呈现类批内换代,Q6 口径;提交说明强制成因即本条) - 文档四面同步: 01-核心循环 节标题改「一场战斗的流程」(口号句无承载价值,正文已释流程);HUD框架 双舞台段删口号句保留因果论点并句;教学引导 P0 最小集合候选删「归因提示」项;07 ㉗ 目检判据删副标题字样 - 验证: shot_probe 问题=0;render_gate 全绿(shot_checker PASS / 棋盘三屏 golden OK / 菜单 golden changed=0.00% / 残留 Godot=0);改动面 7 文件(含基线 PNG)
180 lines
8.6 KiB
Python
180 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
r"""shot_checker.py — 整屏样张闸机检(迭代 9 卡⑤/T8 2026-09-30;B 档断言骨架扩全)。
|
||
|
||
用法: python tools/shot_checker.py <截图目录> [屏清单...]
|
||
默认屏清单 = shot_menu.png shot_camp.png shot_level.png shot_battle.png(同目录 shot_meta.json
|
||
= shot_probe 输出的控件实测几何,为文字框/明度带取样坐标源——分辨率档位自适应,禁硬编码像素)。
|
||
|
||
断言(整屏样张闸 §1.4 四项的机检可判面):
|
||
A 屏级骨架: PNG 存在/尺寸=工程档/灰度 std≥8/唯一色数≥64(渲染真实发生);
|
||
B 文字对比度: 已知文字框〔主墨/暖白 文字簇 vs 背景簇 p2/p98 极性无关〕WCAG ≥4.5:1
|
||
(菜单副标题/战斗 C 区面板/结算明细提示——圣经 v3.5 §7.2 全局文字硬指标机检面;
|
||
原「菜单·帮助文」腿随载体玩法速览牌退役同撤——迭代 19 卡① S1 裁 2026-10-08);
|
||
C 明度三档带: 战斗屏 信息面(C 区面板均值) > 世界中(棋盘区均值) > 世界底(舞台无光柱区均值)
|
||
(§2 明度分层——层间灰度可分);
|
||
D 文字禁压纹理: 各文字框背景主簇纯度 ≥0.5(±12 灰度带占比——文字下垫平滑容器非 busy 纹理);
|
||
F 文字在判据: 菜单文字框内与中位灰度差 >60 的像素占比在 [2%, 55%] 带内(极性无关——亮字暗底/
|
||
暗字亮底同判;对比度只判反差不判「字在」,被埋菜单坐标处有反差即过线的缺口由本判据补,
|
||
provisional 阈值由 2026-10-15 实测定);
|
||
E 焦点预算〔首焦唯一〕: 语义面无法机检 → 编入段闸实机目检清单(T15 人闸判定)。
|
||
退出码: 0 = 全过 / 1 = 有失败。
|
||
"""
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from PIL import Image
|
||
|
||
EXPECTED_SHORT = 1280 # 工程基准档短边(1920 档 = 1.5×——尺寸断言按短边判档)
|
||
|
||
|
||
def _srgb_lin(c: float) -> float:
|
||
c /= 255.0
|
||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||
|
||
|
||
def wcag(l1: float, l2: float) -> float:
|
||
a, b = _srgb_lin(l1), _srgb_lin(l2)
|
||
hi, lo = max(a, b), min(a, b)
|
||
return (hi + 0.05) / (lo + 0.05)
|
||
|
||
|
||
def box_of(meta: dict, key: str, pad: int = 4) -> tuple:
|
||
r = meta.get(key, {})
|
||
if not r:
|
||
return tuple()
|
||
return (max(0, int(r["x"]) - pad), max(0, int(r["y"]) - pad),
|
||
int(r["x"] + r["w"]) + pad, int(r["y"] + r["h"]) + pad)
|
||
|
||
|
||
def rel_box(meta: dict, key: str, fx0: float, fy0: float, fx1: float, fy1: float) -> tuple:
|
||
r = meta.get(key, {})
|
||
if not r:
|
||
return tuple()
|
||
x, y, w, h = r["x"], r["y"], r["w"], r["h"]
|
||
return (int(x + w * fx0), int(y + h * fy0), int(x + w * fx1), int(y + h * fy1))
|
||
|
||
|
||
def box_pixels(img: Image.Image, box: tuple) -> list:
|
||
return list(img.crop(box).convert("L").getdata())
|
||
|
||
|
||
def check_text_box(img: Image.Image, box: tuple, name: str, failures: list) -> None:
|
||
if not box:
|
||
failures.append("%s: meta 几何缺失" % name)
|
||
return
|
||
px = sorted(box_pixels(img, box))
|
||
n = len(px)
|
||
lo, hi = px[int(n * 0.02)], px[min(n - 1, int(n * 0.98))]
|
||
ratio = wcag(lo, hi)
|
||
mid = px[int(n * 0.2):int(n * 0.8) + 1]
|
||
mid_mean = sum(mid) / len(mid)
|
||
mid_std = (sum((v - mid_mean) ** 2 for v in mid) / len(mid)) ** 0.5
|
||
ok_c = ratio >= 4.5
|
||
ok_t = mid_std <= 18.0 # 文字禁压纹理=背景纹理幅度判据(中带 std——设计内低幅石纹 ≈3 过、busy 纹理 25+ 拒)
|
||
print("[shot_checker] %s — WCAG=%.2f 纹理幅度=%.1f %s%s" % (
|
||
name, ratio, mid_std, "C-OK" if ok_c else "C-FAIL", "" if ok_t else " T-FAIL"))
|
||
if not ok_c:
|
||
failures.append("%s: 文字对比度 %.2f < 4.5" % (name, ratio))
|
||
if not ok_t:
|
||
failures.append("%s: 背景纹理幅度 %.1f > 18(文字压 busy 纹理)" % (name, mid_std))
|
||
|
||
|
||
def check_text_presence(img: Image.Image, box: tuple, name: str, failures: list,
|
||
lo: float = 0.02, hi: float = 0.55, dev: int = 60) -> None:
|
||
"""文字在判据(迭代 28 T4——对比度只判反差不判「字在」)。
|
||
|
||
框内与中位灰度差 >dev 的像素占比须落在 [lo, hi] 带内:极性无关(亮字暗底/暗字亮底同判),
|
||
文字簇量级带(实测菜单标题 13.1%/副标题 11.5%——2026-10-15 窗口化实测定 provisional)。
|
||
出带=疑似空框/纯色块/文字被移出框(结构性移位)。
|
||
"""
|
||
if not box:
|
||
failures.append("%s: meta 几何缺失" % name)
|
||
return
|
||
px = sorted(box_pixels(img, box))
|
||
n = len(px)
|
||
if n == 0:
|
||
failures.append("%s: 空框(零像素)" % name)
|
||
return
|
||
med = px[n // 2]
|
||
ratio = sum(1 for v in px if abs(v - med) > dev) / n
|
||
ok = lo <= ratio <= hi
|
||
print("[shot_checker] %s — 文字像素占比=%.2f%%(带 [%.0f%%, %.0f%%] 极性无关)%s" % (
|
||
name, ratio * 100, lo * 100, hi * 100, "I-OK" if ok else "I-FAIL"))
|
||
if not ok:
|
||
failures.append("%s: 文字像素占比 %.2f%% 出带 [%.0f%%, %.0f%%](字在判据——疑似空框/纯色/文字移位)" % (
|
||
name, ratio * 100, lo * 100, hi * 100))
|
||
|
||
|
||
def main() -> int:
|
||
if len(sys.argv) < 2:
|
||
print("用法: python tools/shot_checker.py <截图目录> [屏清单...]")
|
||
return 1
|
||
d = Path(sys.argv[1])
|
||
shots = sys.argv[2:] or ["shot_menu.png", "shot_camp.png", "shot_level.png", "shot_battle.png"]
|
||
meta = {}
|
||
meta_p = d / "shot_meta.json"
|
||
if meta_p.exists():
|
||
meta = json.loads(meta_p.read_text(encoding="utf-8"))
|
||
failures = []
|
||
imgs = {}
|
||
for name in shots:
|
||
p = d / name
|
||
if not p.exists():
|
||
failures.append("%s: 文件缺失" % name)
|
||
continue
|
||
img = Image.open(p).convert("RGB")
|
||
imgs[name] = img
|
||
if img.size[0] != EXPECTED_SHORT and img.size[1] != EXPECTED_SHORT:
|
||
failures.append("%s: 尺寸 %s 非工程档(短边应 %d)" % (name, img.size, EXPECTED_SHORT))
|
||
continue
|
||
gray = img.convert("L")
|
||
hist = gray.histogram()
|
||
total = sum(hist)
|
||
mean = sum(i * c for i, c in enumerate(hist)) / total
|
||
std = (sum(c * (i - mean) ** 2 for i, c in enumerate(hist)) / total) ** 0.5
|
||
colors = len(set(img.getdata()))
|
||
if std < 8.0:
|
||
failures.append("%s: 灰度 std %.1f < 8(疑似空白/纯色)" % (name, std))
|
||
if colors < 64:
|
||
failures.append("%s: 唯一色数 %d < 64(疑似占位屏)" % (name, colors))
|
||
print("[shot_checker] %s — %s std=%.1f colors=%d" % (name, "x".join(map(str, img.size)), std, colors))
|
||
|
||
# B+C+D 断言(battle/menu 两屏为机检承载屏;camp/level 走骨架+目检清单)
|
||
if "shot_battle.png" in imgs:
|
||
b = imgs["shot_battle.png"]
|
||
check_text_box(b, box_of(meta, "hero_panel"), "战斗·C区面板文字", failures)
|
||
check_text_box(b, box_of(meta, "battle_log_hint"), "战斗·明细带提示", failures)
|
||
# 明度三档带(信息面 > 世界中 > 世界底)
|
||
info_box = box_of(meta, "hero_panel", pad=0)
|
||
world_box = rel_box(meta, "board_zone", 0.25, 0.3, 0.75, 0.8)
|
||
bottom_box = rel_box(meta, "stage_view", 0.25, 0.3, 0.45, 0.85)
|
||
if info_box and world_box and bottom_box:
|
||
lm = [sum(box_pixels(b, bx)) / max(1, len(box_pixels(b, bx))) for bx in (info_box, world_box, bottom_box)]
|
||
ok = lm[0] > lm[1] > lm[2]
|
||
print("[shot_checker] 明度三档带 — 信息面=%.0f 世界中=%.0f 世界底=%.0f %s" % (
|
||
lm[0], lm[1], lm[2], "OK" if ok else "FAIL"))
|
||
if not ok:
|
||
failures.append("明度三档带: %.0f/%.0f/%.0f 未成 info>world>bottom 序" % tuple(lm))
|
||
else:
|
||
failures.append("明度三档带: meta 几何缺失")
|
||
if "shot_menu.png" in imgs:
|
||
m = imgs["shot_menu.png"]
|
||
check_text_presence(m, box_of(meta, "menu_title"), "菜单·标题", failures)
|
||
# 菜单·帮助文断言腿退役(迭代 19 卡① S1 裁 2026-10-08——载体玩法速览牌 2026-10-06 用户令
|
||
# 退役「界面禁玩法文字墙」,锚/取样键/断言段三处同撤,不重锚)
|
||
# 菜单·副标题断言腿退役(2026-10-16 用户令——副标题件整个删除〔无意义标语〕,
|
||
# meta 键/断言两腿同撤,不重锚)
|
||
print("[shot_checker] 焦点预算〔首焦唯一〕= 目检项(T15 段闸人闸清单判定——语义面不机检)")
|
||
if failures:
|
||
print("[shot_checker] FAIL——")
|
||
for f in failures:
|
||
print(" - %s" % f)
|
||
return 1
|
||
print("[shot_checker] PASS(整屏样张闸机检面全过;焦点预算归目检)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|