Files
Unlucky-Launcher/packaging/check_screenshot.py
T
AskaEth ea5f66cf92 feat(ci): M62 —— Linux runtime-smoke(xvfb 真渲染一帧)+ 像素非空白门禁
「编得过」不等于「跑得起来」:CI 里 Linux/macOS 此前只 build + test,从未创建过真实图形窗口。
本 commit 补第一份 Linux 真实图形环境运行证据,并把它做成不可伪造的门禁。

- ci.yml 新增 runtime-smoke(ubuntu-latest):装 xvfb + 软渲染/字体依赖,用 xvfb-run 起 GUI 进程,
  走既有 `--software-renderer --screenshot` 通道渲出一帧 PNG,再断言 ①退出码 0 ②PNG 宽高 > 0
  ③非空白(唯一色数 / 亮度σ / 强边缘占比三项过阈值),并把截图作为 artifact `runtime-smoke-screenshot` 上传。
  既有 fmt/clippy/test/build 四个 job 与触发器语义零改动(纯追加)。
- ci.yml 新增 runtime-smoke-macos 探针:只在 workflow_dispatch 触发且 continue-on-error。官方 macOS
  runner 处于 LaunchDaemon 上下文、无 WindowServer 会话,大概率建不了窗口;本机无 macOS 且 worker 禁
  push,这条路径尚无实测证据,故不拿它卡 PR 门禁(首跑结论决定升常驻或按 D26 归入 iced 重写)。
- packaging/check_screenshot.py:零第三方依赖的 PNG 解码(zlib + 反过滤 0~4)与三项指标判定,
  `--selftest` 打印阈值依据(人为纯色帧 1/0.0/0.0 必红,真实帧 342/19.45/0.00761 全过线)。
- packaging/linux-smoke-docker.sh:在 Docker 里复刻同一 job(TUNA apt/rustup 源),便于无 Linux 桌面的本地复跑。
- packaging/README.md §11:覆盖平台、阈值依据数值、本地复跑、已知跳过项、迁 iced 后需重新验证。

本机实测(Debian 12 容器 + xvfb + 软渲染):退出码 0,PNG 1180x760,唯一色 342 / σ 19.45 / 强边缘 0.00761。
CI(Ubuntu 24.04 runner)首跑仍未验证——本 job 证明「能出图」,不证明视觉正确。
2026-11-01 16:15:47 +08:00

316 lines
13 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
"""截图「不是一帧空白」判定:CI 运行时冒烟的门禁脚本(零第三方依赖)。
为什么需要它:`unlucky-launcher --screenshot` 只要进程退出码 0 就会写出 PNG——
一张全黑/全白(窗口起来了但什么都没画、字体库缺失、软渲染器降级失败)也是「成功退出」。
所以 CI 除了断言退出码与文件存在,还要看**像素本身有没有信息**。
三个判据(阈值依据见 `--selftest` 与 packaging/README.md「CI 运行时冒烟」):
- `unique_colors`:唯一色数。全白 / 全黑图 = 1;真实 UI 数百到上千。
- `luma_stddev`:亮度标准差。纯色图 = 0;真实 UI 有面板/文字/渐变的明暗层次。
- `edge_ratio`:**强边缘像素占比**——水平相邻像素对里亮度差 ≥8 的比例。纯色图 = 0;真实 UI 的文字与
边框占千分之几到百分之几。用它而不是「平均亮度差」:空数据目录的主界面 57% 是同一片底色,
平均差会被大片平面稀释(实测只有 0.4),而强边缘占比只数真正的边界,跨主题/跨字体都稳定。
只用 Python 标准库:`zlib` 解压 PNG 的 deflate 流,自己反过滤(filter 0~4)。
不引 Pillow 是因为 CI 不该为了「看一张图」多装一套图像栈(也符合项目的依赖白名单纪律)。
用法::
python packaging/check_screenshot.py shot.png # 判定,退出码 0/1
python packaging/check_screenshot.py shot.png --json # 机器可读(CI 日志用)
python packaging/check_screenshot.py --selftest # 打印阈值依据的两组数值
"""
from __future__ import annotations
import argparse
import json
import math
import struct
import sys
import tempfile
import zlib
from collections import Counter
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# 判定阈值。两侧数值实测(M62 本机 Docker + xvfb + Windows 历史截图):
# - 真实帧:唯一色 342~2352、亮度σ 19.45~27.46、强边缘占比 0.0076~0.0433(最弱是 Linux 空数据主界面);
# - 人为纯色帧:1 / 0.0 / 0.0。
# 阈值取在两者之间、贴近真实下限的 1/5~1/8:换主题/换字体/换窗口尺寸都不会误报,空白帧一定红。
DEFAULT_MIN_UNIQUE = 64
DEFAULT_MIN_STDDEV = 3.0
DEFAULT_MIN_EDGE_RATIO = 0.001
# 「强边缘」的亮度差门槛:≥8/255 肉眼即视为一条边界;抗锯齿造成的 1~2 级抖动不算。
EDGE_LUMA_DELTA = 8
# PNG 颜色类型 → 每像素通道数(调色板 3 走索引,需要 PLTE,本脚本不解)
CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4}
class PngError(Exception):
"""PNG 结构不受支持或损坏。"""
def _iter_chunks(data: bytes):
"""按 PNG 规范切块:长度(4) + 类型(4) + 数据 + CRC(4)。"""
offset = len(PNG_SIGNATURE)
while offset + 12 <= len(data):
(length,) = struct.unpack(">I", data[offset : offset + 4])
yield data[offset + 4 : offset + 8], data[offset + 8 : offset + 8 + length]
offset += 12 + length
def _unfilter(kind: int, line: bytearray, prev: bytearray, bpp: int) -> None:
"""就地把一条扫描线反过滤(PNG 规范 §9.2 的五个过滤类型)。"""
if kind == 0:
return
if kind == 1: # Sub:加上左边
for index in range(bpp, len(line)):
line[index] = (line[index] + line[index - bpp]) & 0xFF
elif kind == 2: # Up:加上正上方
for index in range(len(line)):
line[index] = (line[index] + prev[index]) & 0xFF
elif kind == 3: # Average:加上 (左 + 上) // 2
for index in range(len(line)):
left = line[index - bpp] if index >= bpp else 0
line[index] = (line[index] + ((left + prev[index]) >> 1)) & 0xFF
elif kind == 4: # Paeth:加上 Paeth 预测子
for index in range(len(line)):
left = line[index - bpp] if index >= bpp else 0
up = prev[index]
upper_left = prev[index - bpp] if index >= bpp else 0
estimate = left + up - upper_left
dist_left = abs(estimate - left)
dist_up = abs(estimate - up)
dist_corner = abs(estimate - upper_left)
if dist_left <= dist_up and dist_left <= dist_corner:
predictor = left
elif dist_up <= dist_corner:
predictor = up
else:
predictor = upper_left
line[index] = (line[index] + predictor) & 0xFF
else:
raise PngError(f"未知的行过滤类型 {kind}")
def decode_png(path: Path) -> tuple[int, int, list[bytearray], int]:
"""读 8bit 非隔行 PNG,返回 (宽, 高, 每行原始字节, 通道数)。"""
data = path.read_bytes()
if not data.startswith(PNG_SIGNATURE):
raise PngError(f"{path} 不是 PNG(签名不符,可能是 0 字节文件或半截写入)")
width = height = depth = color_type = interlace = None
compressed = bytearray()
for kind, body in _iter_chunks(data):
if kind == b"IHDR":
width, height, depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"IDAT":
compressed.extend(body)
elif kind == b"IEND":
break
if width is None or height is None:
raise PngError("缺 IHDR 块")
if interlace:
raise PngError("不支持隔行(Adam7)PNG")
if depth != 8:
raise PngError(f"只支持 8bit PNG,实际 {depth}bit")
channels = CHANNELS_BY_COLOR_TYPE.get(color_type)
if channels is None:
raise PngError(f"不支持的颜色类型 {color_type}(调色板 PNG 请先转成 RGB/RGBA)")
raw = zlib.decompress(bytes(compressed))
stride = width * channels
if len(raw) < (stride + 1) * height:
raise PngError(f"像素数据不足:期望 {(stride + 1) * height} 字节,实际 {len(raw)}")
rows: list[bytearray] = []
previous = bytearray(stride)
offset = 0
for _ in range(height):
kind = raw[offset]
offset += 1
line = bytearray(raw[offset : offset + stride])
offset += stride
_unfilter(kind, line, previous, channels)
rows.append(line)
previous = line
return width, height, rows, channels
def measure(width: int, height: int, rows: list[bytearray], channels: int) -> dict:
"""统计「这帧图里有没有信息」的三项指标。"""
colors: Counter[int] = Counter()
luma_sum = 0
luma_square_sum = 0
strong_edges = 0
pair_count = 0
for row in rows:
previous_luma = None
for x in range(width):
base = x * channels
if channels >= 3:
red, green, blue = row[base], row[base + 1], row[base + 2]
else:
red = green = blue = row[base]
colors[(red << 16) | (green << 8) | blue] += 1
# 整数亮度(ITU-R BT.601 系数),避免浮点误差影响阈值比较
luma = (red * 299 + green * 587 + blue * 114) // 1000
luma_sum += luma
luma_square_sum += luma * luma
if previous_luma is not None:
pair_count += 1
if abs(luma - previous_luma) >= EDGE_LUMA_DELTA:
strong_edges += 1
previous_luma = luma
total = width * height
mean = luma_sum / total if total else 0.0
variance = max(luma_square_sum / total - mean * mean, 0.0) if total else 0.0
top_color, top_count = colors.most_common(1)[0] if colors else (0, 0)
return {
"width": width,
"height": height,
"pixels": total,
"unique_colors": len(colors),
"top_color": f"#{top_color:06x}",
"top_color_share": round(top_count / total, 4) if total else 0.0,
"luma_mean": round(mean, 2),
"luma_stddev": round(math.sqrt(variance), 2),
"edge_ratio": round(strong_edges / pair_count, 5) if pair_count else 0.0,
}
def verdict(
stats: dict, min_unique: int, min_stddev: float, min_edge_ratio: float
) -> list[str]:
"""把指标折成「失败原因清单」;空清单 = 通过。"""
problems = []
if stats["pixels"] <= 0:
problems.append("图像尺寸为 0(宽或高是 0)")
return problems
if stats["unique_colors"] < min_unique:
problems.append(
f"唯一色数 {stats['unique_colors']} < 阈值 {min_unique}(像纯色/空白帧)"
)
if stats["luma_stddev"] < min_stddev:
problems.append(
f"亮度标准差 {stats['luma_stddev']} < 阈值 {min_stddev}(整帧明暗无层次)"
)
if stats["edge_ratio"] < min_edge_ratio:
problems.append(
f"强边缘占比 {stats['edge_ratio']} < 阈值 {min_edge_ratio}(没有边界/文字的迹象)"
)
return problems
def encode_png(width: int, height: int, rgb_row) -> bytes:
"""最小 PNG 编码器(8bit RGB、filter 0、zlib 压缩)——只给 `--selftest` 造样图。"""
raw = bytearray()
for y in range(height):
raw.append(0)
raw.extend(rgb_row(y))
out = bytearray(PNG_SIGNATURE)
for kind, body in (
(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)),
(b"IDAT", zlib.compress(bytes(raw), 6)),
(b"IEND", b""),
):
out.extend(struct.pack(">I", len(body)))
out.extend(kind)
out.extend(body)
out.extend(struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF))
return bytes(out)
def selftest() -> int:
"""造两张对照样图跑一遍,打印阈值依据的两组数值。
- 全白 / 全黑:模拟「窗口起来了但一个像素都没画」的空白帧;
- 一像素黑边框 + 中部一条横线:最弱的「有内容」下限(远弱于真实 UI)。
"""
width, height = 1180, 760
cases = {
"flat-white": lambda y: b"\xff" * (width * 3),
"flat-black": lambda y: b"\x00" * (width * 3),
"border-line": lambda y: b"".join(
b"\x00\x00\x00"
if (y in (0, height - 1) or x in (0, width - 1) or y == height // 2)
else b"\xff\xff\xff"
for x in range(width)
),
}
with tempfile.TemporaryDirectory() as tmp:
for name, describe in cases.items():
path = Path(tmp) / f"{name}.png"
path.write_bytes(encode_png(width, height, describe))
stats = measure(*decode_png(path))
failed = verdict(
stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO
)
print(
json.dumps(
{
"case": name,
"unique_colors": stats["unique_colors"],
"luma_stddev": stats["luma_stddev"],
"edge_ratio": stats["edge_ratio"],
"verdict": "fail" if failed else "pass",
},
ensure_ascii=False,
)
)
return 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="截图非空白判定(CI 运行时冒烟门禁)")
parser.add_argument("png", nargs="?", type=Path, help="待判定的 PNG 路径")
parser.add_argument("--json", action="store_true", help="以 JSON 输出统计结果")
parser.add_argument("--min-unique", type=int, default=DEFAULT_MIN_UNIQUE, help="唯一色数下限")
parser.add_argument("--min-stddev", type=float, default=DEFAULT_MIN_STDDEV, help="亮度标准差下限")
parser.add_argument(
"--min-edge-ratio", type=float, default=DEFAULT_MIN_EDGE_RATIO, help="强边缘占比下限"
)
parser.add_argument("--selftest", action="store_true", help="打印阈值依据的对照数值后退出")
args = parser.parse_args(argv)
if args.selftest:
return selftest()
if args.png is None:
parser.error("需要给出 PNG 路径(或 --selftest)")
try:
stats = measure(*decode_png(args.png))
except (PngError, OSError, zlib.error) as error:
print(f"[fail] 读不出截图 {args.png}: {error}", file=sys.stderr)
return 1
problems = verdict(stats, args.min_unique, args.min_stddev, args.min_edge_ratio)
if args.json:
print(json.dumps({**stats, "problems": problems}, ensure_ascii=False))
else:
print(
f"{args.png}: {stats['width']}x{stats['height']} "
f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} "
f"强边缘 {stats['edge_ratio']} 主色 {stats['top_color']}"
f"({stats['top_color_share'] * 100:.1f}%)"
)
if problems:
for problem in problems:
print(f"[fail] {problem}", file=sys.stderr)
return 1
print("[ok] 截图非空白")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))