review r1(p2-1items/fix-then-merge)只要求改文档与注释,门禁逻辑一行未动。 - P2-1(唯一阻塞项):原稿称「字体库缺失 → 合法 PNG + 退出码 0」,这是错的。review 实测两条失效都是 **硬失败**:完全没有字体 → fontique panic(退出码 101,不产生 PNG);非软渲染(默认 femtovg)→ `take_snapshot is not supported by this FemtoVG backend`(退出码 1)。改正口径:`--screenshot` 的 PNG 是进程自己编码写出的,只要走到 `take_snapshot` 就是「合法 PNG + 退出码 0」,像素断言是**纵深防御**, 守的是「进程活着、PNG 合法、但场景几乎没画」这一档;其余情形本机暂无样本,属防御性设计、不宣称已复现。 落点:packaging/ci-runtime-smoke.md §1 与 packaging/check_screenshot.py 模块 docstring。 - P3-1:ci.yml 的 runtime-smoke job 头补一行 —— 本 job 绑死 Slint `Window::take_snapshot`,迁 iced 后必须 提供等价离屏截图通道,否则静默退化成「能起进程」。 - P3-2:阈值余量写明量级(342/64=5.3×、19.45/3.0=6.5×、0.00761/0.001=7.6×,改「5~8 倍」);并写明 闸门下限 ≈「至少画出一条标题栏」(review 实测切片 174/35.10/0.02623 与右下 400×300 切片仍 pass), 即只证明「场景被真实渲染过」,不证明元素齐全、更不证明视觉正确。 - P3-3:README 398/400 已贴红线 —— 借 review 给的逃生口**提前拆**:细节搬进 packaging/ci-runtime-smoke.md (78 行),README §11 收缩为 12 行索引(374 行),并写明「再增长继续往细节文档写,两文件都 ≤400」。 - P3-4:本地复跑示例改 Windows 安全路径 `D:/tmp/ull-smoke`;脚本对 repo_root/out_dir 统一 `cygpath -m` 归一并回显真实路径(Git Bash 下 `/tmp` 会静默落进 Docker VM,宿主什么都收不到 —— review 实测踩到)。 - P3-5:两个 `run:` 里的 `set -o pipefail` 保留但加注释说明「当前无管道,为将来加管道先有失败传播」; `cargo clean -p unlucky-app || true` 加内联注释(N7 兜底,CI 里没有对应行,别误抄进 ci.yml)。 验证:`--selftest` 三组仍全部 fail(判红能力未被改坏);真实截图仍 pass(1180x760 / 342 / 19.45 / 0.00761); 0 字节与非 PNG 路径 exit 1;`yaml.safe_load` 通过(6 job,Linux 槽位无 continue-on-error,artifact 步 `if: always()`);`cargo fmt --all --check` 退出 0;cygpath 修复后重跑 Docker 冒烟,宿主机 D:/tmp/ull-m62-rev2 拿到 PNG,md5 `156c382a…` 与前两次 run 及 review 的 artifact 完全一致。
318 lines
13 KiB
Python
318 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""截图「不是一帧空白」判定:CI 运行时冒烟的门禁脚本(零第三方依赖)。
|
||
|
||
为什么需要它:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到
|
||
「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。
|
||
本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic
|
||
(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this
|
||
FemtoVG backend`(退出码 1)。其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计。
|
||
|
||
三个判据(阈值依据见 `--selftest` 与 packaging/ci-runtime-smoke.md):
|
||
|
||
- `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:]))
|