Files
gplan-2/tools/shot_checker.py

191 lines
9.5 KiB
Python
Raw Permalink 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 — 整屏样张闸机检(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/唯一色数≥40(渲染真实发生——std≥8 取值在
圣经 §7.2 文字 4.5:1 推论下界之下、正常实测最低 19.3 之上;色数 40=正常实测地板 60
〔营地场景图,七屏最低〕的 2/3,低于一切正常屏,色数异常回落=画面异常信号,停下人工复查)/
有效色数≤128(色板整屏总闸,圣经 §4.1 第三层——只数面积 ≥0.25% 的成片颜色,锯齿/字缘/
阴影环/渐变中间带不判断,2026-11-02 用户裁口径;地板 40 保留整屏口径不变);
B 文字对比度: 已知文字框〔主墨/暖白 文字簇 vs 背景簇 p2/p98 极性无关〕WCAG ≥4.5:1
(战斗 C 区面板/结算明细提示等已知文字框——圣经 v3.5 §7.2 全局文字硬指标机检面);
C 明度三档带: 战斗屏 信息面(C 区面板均值) > 世界中(棋盘区均值) > 世界底(舞台无光柱区均值)
(§2 明度分层——层间灰度可分);
D 文字禁压纹理: 各文字框背景主簇纯度 ≥0.5(±12 灰度带占比——文字下垫平滑容器非 busy 纹理);
F 文字在判据: 菜单文字框内与中位灰度差 >60 的像素占比在 [2%, 55%] 带内(极性无关——亮字暗底/
暗字亮底同判;对比度只判反差不判「字在」,被埋菜单坐标处有反差即过线的缺口由本判据补,
provisional 阈值由窗口化实测定);
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:
"""文字在判据——对比度只判反差不判「字在」。
框内与中位灰度差 >dev 的像素占比须落在 [lo, hi] 带内:极性无关(亮字暗底/暗字亮底同判),
文字簇量级带(实测菜单标题 13.1%/副标题 11.5%,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
color_counts = {}
for px in img.getdata():
color_counts[px] = color_counts.get(px, 0) + 1
colors = len(color_counts)
# 有效色数=面积 ≥0.25% 的成片颜色(1280 档=2560px)——锯齿/字缘/阴影环/渐变中间带
# 不判断(2026-11-02 用户裁降噪口径);地板 40 保留整屏口径不变。
eff_colors = sum(1 for v in color_counts.values() if v >= img.size[0] * img.size[1] * 0.0025)
if std < 8.0:
failures.append("%s: 灰度 std %.1f < 8(疑似空白/纯色)" % (name, std))
# 唯一色数下限=正常实测地板 60(营地场景图,七屏最低)的 2/3——低于一切正常屏;
# 色数异常回落=画面异常信号,停下人工复查。
if colors < 40:
failures.append("%s: 唯一色数 %d < 40(色数异常回落)" % (name, colors))
# 整屏有效色数总闸=色板纪律第三层上限(art-bible §4.1,2026-11-02 用户裁 128)。
if eff_colors > 128:
failures.append("%s: 有效色数 %d > 128(色板整屏总闸超限)" % (name, eff_colors))
print("[shot_checker] %s — %s std=%.1f colors=%d eff=%d" % (name, "x".join(map(str, img.size)), std, colors, eff_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)
# 菜单·帮助文与副标题断言腿无承载(载体件已退役,不重锚)
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())