Files
gplan-2/tools/shot_checker.py
T
DKChan 9846a29f4c fix(呈现): 主菜单副标题「扫雷的因 · 逐行兑现的果」整个删除(2026-10-16 用户令——无意义标语,玩家可见面禁扫雷字眼)
- 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)
2026-10-16 04:33:17 +08:00

180 lines
8.6 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
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())