Files
gplan-2/tools/shot_checker.py
T

147 lines
6.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 全局文字硬指标机检面);
C 明度三档带: 战斗屏 信息面(C 区面板均值) > 世界中(棋盘区均值) > 世界底(舞台无光柱区均值)
(§2 明度分层——层间灰度可分);
D 文字禁压纹理: 各文字框背景主簇纯度 ≥0.5(±12 灰度带占比——文字下垫平滑容器非 busy 纹理);
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 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_box(m, box_of(meta, "menu_sub"), "菜单·副标题", failures)
check_text_box(m, box_of(meta, "menu_help"), "菜单·帮助文", failures)
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())