#!/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())