fix(packaging): 堵两处判定假绿 + 证据账读数可复跑(M249 r1 P2-1/2/3)

P2-1 判据 2:`Selected: AdapterInfo {` 与字段不在同一行时(pretty-print Debug 漂移)
`device_type` 取不到、旧写法默认 `"?"` 而 `"?" ∉ {Cpu,Other}` ⇒ 判绿。改成取不到即判红。
P2-2 判据 1:`"wgpu" in renderers` 是「曾经出现过」,把 CPU 那次的 stderr >> 追加进来
再配 CPU 那张图会判绿。改成 `set(renderers) == {"wgpu"}`(全部 [shot] 行都声称 wgpu)。
P2-3 doc §2:A6 `--expect-refuse` 那行的「3/3」需要传 PNG 路径才成立,按表复跑是 2/2;
命令列补上 PNG,并单列「不传 PNG = 2/2」一行。

行数:判据脚本 383 → 311(自测脚手架搬到 tests/gpu_shot_selftest.py,124 行;向量
16 → 19 条,红 16 / 绿 3,新增「多行 Debug」「空体」「混入异种 renderer」三条靶子)。
This commit is contained in:
worker-gpushot committed 2026-11-11 13:13:36 +08:00
1 parent d7a9ae15a5
commit 9aefb6a5b0
5 files changed
+191 -114

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""`packaging/check_gpu_shot.py --self-test` 的执行器(M249):合成日志 + 合成 PNG + 向量比对。
为什么单独一份而不是塞进判据脚本:那一份要守 ≤400 行硬线,而这里全是「按真实行格式手拼日志、
现造 PNG、逐条对 `expect_fail`」的**测试脚手架**,与判据逻辑无关。数据另外放
`gpu_shot_vectors.json`(同 `guard_vectors.json` 的做法),被测的判据函数由调用方**以参数传入**
——本文件不反向 import `check_gpu_shot`,免得循环导入。
一颗诚实的钉子:本文件的样本是**按今天的日志格式**手拼的,所以它守**判据**,不守**格式漂移**。
产品真换了 Debug 形状时,靠的是判据里「取不到 device_type 就算失败」这类保守口径(见 doc §3)。
"""
from __future__ import annotations
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # `check_screenshot` 在 packaging/
from check_screenshot import encode_png # noqa: E402
#: 向量数据。`log` 是 `synth_log` 的关键字覆盖(缺省键取函数默认值,`null` = 该行不出现)。
VECTORS_PATH = Path(__file__).resolve().parent / "gpu_shot_vectors.json"
SELFTEST_SIZE = (200, 120)
def synth_log(
*,
renderer: str | None = "wgpu",
device_type: str | None = "DiscreteGpu",
backend: str | None = "final=wgpu result=started",
size: tuple[int, int] | None = None,
no_size_line: bool = False,
multiline_selected: bool = False,
empty_selected: bool = False,
extra_shot_renderer: str | None = None,
) -> str:
"""按 M246 真实日志的**行格式**拼一份最小日志(传 `None` = 该行故意不出现)。"""
lines = [
"[probe] renderer = wgpu reason=forced-gpu 兜底=tiny-skia(失败会先打印具名错误)",
# 真实日志里永远有这一行提示文本,且它含 `Selected: AdapterInfo` 字样 —— 留着它,
# 「只有提示、没有真 Selected 行」的向量才是在测真东西。
"[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU",
]
if empty_selected: # `Selected: AdapterInfo {` 之后再没有内容(字段全丢)
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {")
elif device_type is not None:
fields = [
' name: "AMD Radeon RX 6600 XT",',
f" device_type: {device_type},",
' backend: Vulkan,',
]
if multiline_selected: # pretty-print Debug:字段不在 `{` 那一行(P2-1 的形状)
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {")
lines.extend(fields)
lines.append("}")
else:
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {" + "".join(fields) + " }")
if not no_size_line:
dims = size or SELFTEST_SIZE
lines.append(f"[shot] {dims[0]}x{dims[1]} scale=1 -> selftest.png")
for value in (renderer, extra_shot_renderer):
if value is not None:
lines.append(f'[shot] screen=gallery theme=light renderer={value} route=Detail("survival")')
if backend is not None:
lines.append(f"[backend] {backend} elapsed=1000ms evidence=selftest")
return "\n".join(lines) + "\n"
def synth_png(kind: str, size: tuple[int, int]) -> bytes:
"""`content` = 有内容的合成帧(渐变 + 8px 棋盘格);`flat` = 全白。"""
width, height = size
def content_row(y: int) -> bytes:
return b"".join(
bytes((x * 255 // max(width - 1, 1), y * 255 // max(height - 1, 1), 40))
if ((x // 8) + (y // 8)) % 2 == 0
else b"\x14\x14\x14"
for x in range(width)
)
if kind == "flat":
return encode_png(width, height, lambda _y: b"\xff" * (width * 3))
return encode_png(width, height, content_row)
def run_vector(root: Path, index: int, case: dict, default_size: tuple[int, int], judges: tuple) -> list[str]:
"""跑一条向量,返回它实际红的判据清单。`judges` = (judge_evidence, judge_refuse)。"""
judge_evidence, judge_refuse = judges
overrides = {key: (tuple(value) if isinstance(value, list) else value) for key, value in case.get("log", {}).items()}
log_path = root / f"case{index:02d}.log"
log_path.write_text(synth_log(**{"size": default_size, **overrides}), encoding="utf-8")
png_path = root / f"case{index:02d}.png"
png_size = tuple(case.get("png_size", default_size))
if case.get("png") == "truncated":
png_path.write_bytes(synth_png("content", png_size)[:200])
elif case.get("png") is not None:
png_path.write_bytes(synth_png(case["png"], png_size))
report = judge_refuse(log_path, png_path) if case.get("mode") == "refuse" else judge_evidence(log_path, png_path)
return report.problems()
def run_selftest(judge_evidence, judge_refuse) -> int:
"""跑 `gpu_shot_vectors.json` 的全部向量,逐条对 `expect_fail`(多一条少一条都算失败)。"""
data = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))
default_size = tuple(data["size"])
failures = 0
with tempfile.TemporaryDirectory(prefix="ull-gpu-shot-") as tmp:
root = Path(tmp)
for index, case in enumerate(data["cases"], start=1):
got = run_vector(root, index, case, default_size, (judge_evidence, judge_refuse))
want = case["expect_fail"]
if got != want:
print(f"::error::自测失败:{case['label']} 期望失败 {want},实际 {got}", file=sys.stderr)
failures += 1
else:
print(f"OK: {case['label']} → {'红 ' + ' / '.join(got) if got else '绿'}")
red = sum(1 for case in data["cases"] if case["expect_fail"])
if failures:
return 1
print(f"OK: GPU 截图证据判定自测通过({len(data['cases'])} 条向量:红 {red} / 绿 {len(data['cases']) - red})")
return 0