hd_bench: time and weather frozen per shot, several stills per shot, moving pixels (water, clouds, leaves) kept out of the baseline score, warm-up, engine hash in the report (#71)
build / mod (push) Successful in 3m18s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 02:54:01 +03:00
parent 6f3fc644bc
commit 3ced60e84f
+209 -87
View File
@@ -1,24 +1,37 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет. """Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
python3 tools/hd_bench.py --list # сцены и их параметры python3 tools/hd_bench.py --list # сцены и их параметры
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/ python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре) python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре)
python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off
python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after
python3 tools/hd_bench.py --no-freeze # живой замер производительности: время и погода идут
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef, Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем). игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток,
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1. ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4`
(GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
Итог: report.md и results.json в папке вывода. Итог: report.md и results.json в папке вывода.
Анимация (#71). Игровое время и погоду мы замораживаем, но вода (волны, HD `water.y`) и листва (ветер,
`wind_seconds`) идут по часам стены, поэтому два прогона одного бинарника разные и без изменений кода.
Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между
кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не
участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям:
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части
выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов. Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
Только стандартная библиотека; при установленном Pillow сравнение быстрее. Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее.
""" """
import argparse import argparse
import datetime import datetime
import hashlib
import itertools
import json import json
import operator
import os import os
import re import re
import shutil import shutil
@@ -38,9 +51,12 @@ HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересо
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl" CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
SAVES = ROOT / "run-home" / "saves" SAVES = ROOT / "run-home" / "saves"
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой)
DIFF_GAIN = 6 # усиление разницы в diff-картинках DIFF_GAIN = 6 # усиление разницы в diff-картинках
MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва)
FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$") TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
FREEZE_RULES = ("advance_time", "advance_weather") # имена игровых правил в horizon-client-core/gamerules.rs
@dataclass @dataclass
@@ -173,6 +189,14 @@ def stop_game():
SOCK.unlink() SOCK.unlink()
def file_sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
# --- кадры: PNG без Pillow и с ним ------------------------------------------------------------- # --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
def _paeth(a, b, c): def _paeth(a, b, c):
@@ -248,51 +272,103 @@ def write_png_rgb(path, w, h, rgb):
+ chunk(b"IEND", b"")) + chunk(b"IEND", b""))
try: # необязательно: быстрее декодирование и сравнение try: # необязательно: быстрее декодирование
from PIL import Image, ImageChops, ImageStat from PIL import Image
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
Image = None Image = None
def compare_frames(a, b, diff_out=None): def load_rgb(path):
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
if Image is not None: if Image is not None:
A = Image.open(a).convert("RGB") im = Image.open(path).convert("RGB")
B = Image.open(b).convert("RGB") return im.size[0], im.size[1], im.tobytes()
if A.size != B.size: return read_png_rgb(path)
raise BenchError(f"размеры кадров разные: {A.size} и {B.size}")
d = ImageChops.difference(A, B)
mean = sum(ImageStat.Stat(d).mean) / 3 def load_frames(paths):
r, g, bl = d.split() """(w, h, [bytes RGB каждого кадра]); размеры должны совпадать."""
mx = ImageChops.lighter(ImageChops.lighter(r, g), bl) w = h = None
hist = mx.histogram() rgbs = []
total = A.size[0] * A.size[1] for p in paths:
pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total pw, ph, px = load_rgb(p)
if diff_out: if w is None:
d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out) w, h = pw, ph
return {"mean": mean, "pct": pct, "size": list(A.size)} elif (pw, ph) != (w, h):
wa, ha, pa = read_png_rgb(a) raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})")
wb, hb, pb = read_png_rgb(b) rgbs.append(px)
if (wa, ha) != (wb, hb): return w, h, rgbs
raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}")
total_abs = 0
over = 0 def shot_frames(folder, name, n=None):
diff = bytearray(len(pa)) if diff_out else None """Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть."""
for i in range(0, len(pa), 3): paths = []
dr = abs(pa[i] - pb[i]) while n is None or len(paths) < n:
dg = abs(pa[i + 1] - pb[i + 1]) k = len(paths) + 1
db = abs(pa[i + 2] - pb[i + 2]) p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png")
total_abs += dr + dg + db if not p.exists():
if max(dr, dg, db) > DIFF_LEVEL: break
over += 1 paths.append(p)
if diff is not None: return paths
diff[i] = min(255, dr * DIFF_GAIN)
diff[i + 1] = min(255, dg * DIFF_GAIN)
diff[i + 2] = min(255, db * DIFF_GAIN) # --- сравнение кадров с учётом анимации (#71) ---------------------------------------------------
def _per_pixel_max(ch):
"""Из байтов по каналам (RGB, по 3 на пиксель) — максимум по каналам на пиксель."""
return bytes(map(max, ch[0::3], ch[1::3], ch[2::3]))
def _expand3(mask):
"""Маска на пиксель -> маска на байт (по 3 на пиксель)."""
m = bytearray(3 * len(mask))
m[0::3] = mask
m[1::3] = mask
m[2::3] = mask
return m
def motion_range(rgbs):
"""Размах значения каждого пикселя между кадрами одного прогона (максимум по каналам, 0..255)."""
lo = hi = rgbs[0]
for f in rgbs[1:]:
lo = bytes(map(min, lo, f))
hi = bytes(map(max, hi, f))
return _per_pixel_max(bytes(map(operator.sub, hi, lo)))
def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level):
npx = w * h
flick = sum(1 for r in rng if flicker_level < r <= motion_level)
moving = sum(1 for r in rng if r > motion_level)
return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx,
"frames": n_frames, "size": [w, h]}
def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None):
"""Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух
прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная
средняя разница (информация, не порог)."""
npx = w * h
static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng))
d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0])))
dpix = _per_pixel_max(d)
n_static = sum(static)
n_moving = npx - n_static
mean = sum(itertools.compress(d, _expand3(static))) / (3.0 * n_static) if n_static else 0.0
over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL)
moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving)
if n_moving else 0.0)
if diff_out: if diff_out:
write_png_rgb(diff_out, wa, ha, bytes(diff)) img = bytearray(min(255, v * DIFF_GAIN) for v in d)
npx = wa * ha for i, s in enumerate(static):
return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]} if not s: # движущиеся пиксели — синим, чтобы не путать с регрессией
j = 3 * i
img[j], img[j + 1], img[j + 2] = 0, 0, 80
write_png_rgb(diff_out, w, h, bytes(img))
if motion_out:
write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static))))
return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx,
"animated_mean": moving_mean, "size": [w, h]}
# --- тайминги ----------------------------------------------------------------------------------- # --- тайминги -----------------------------------------------------------------------------------
@@ -345,19 +421,26 @@ def bench_gpu_avg(reply):
# --- сцена ------------------------------------------------------------------------------------ # --- сцена ------------------------------------------------------------------------------------
def run_shot(shot, out, settle, bench_secs, timing): def run_shot(shot, out, settle, bench_secs, timing, stills, gap, warmup, freeze):
x, y, z, yaw, pitch = shot.pose x, y, z, yaw, pitch = shot.pose
if freeze: # игровое время и погода стоят: облачные часы и погода не уходят между снимками
for rule in FREEZE_RULES:
ctl("command", f"gamerule {rule} false")
ctl("command", f"time set {shot.time}") ctl("command", f"time set {shot.time}")
ctl("command", "weather clear") ctl("command", "weather clear")
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}") ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
ctl("wait-ready", 120, timeout=150) ctl("wait-ready", 120, timeout=150)
time.sleep(settle) time.sleep(settle)
first = out / f"{shot.name}.png" frames = []
ctl("screenshot", str(first.resolve())) for k in range(stills):
time.sleep(0.5) if k:
second = out / f"{shot.name}_still2.png" time.sleep(gap)
ctl("screenshot", str(second.resolve())) path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png")
result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {}, ctl("screenshot", str(path.resolve()))
frames.append(path.name)
if warmup > 0: # прогрев перед замером, не попадает в отчёт
ctl("bench", warmup, timeout=warmup + 60)
result = {"frame": frames[0], "frames": frames, "gpu_avg_ms": None, "passes": {},
"pass_total_ms": None, "bench_json": None} "pass_total_ms": None, "bench_json": None}
offset = LOG.stat().st_size if LOG.exists() else 0 offset = LOG.stat().st_size if LOG.exists() else 0
reply = ctl("bench", bench_secs, timeout=bench_secs + 60) reply = ctl("bench", bench_secs, timeout=bench_secs + 60)
@@ -397,43 +480,53 @@ def judge(entry, args):
if gpu is None and not entry.get("error"): if gpu is None and not entry.get("error"):
reasons.append("нет GPU-таймингов (bench)") reasons.append("нет GPU-таймингов (bench)")
fl = entry.get("flicker") fl = entry.get("flicker")
if fl and fl["pct"] > args.max_flicker: if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание {fl['pct']:.2f}% > {args.max_flicker:g}%") reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
base = entry.get("vs_baseline") base = entry.get("vs_baseline")
if base: if base:
if base.get("error"): if base.get("error"):
reasons.append(base["error"]) reasons.append(base["error"])
else: else:
if base["mean"] > args.max_mean: if base["mean"] > args.max_mean:
reasons.append(f"разница с базой {base['mean']:.2f} > {args.max_mean:g}") reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}")
if base["pct"] > args.max_pct: if base["pct"] > args.max_pct:
reasons.append(f"пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%") reasons.append(f"статичных пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
return reasons return reasons
def write_report(out, entries, args, env_extra, size, baseline): def write_report(out, entries, args, env_extra, size, baseline, meta):
lines = [] lines = []
add = lines.append add = lines.append
add(f"# HD-полигон: {out.name}") add(f"# HD-полигон: {out.name}")
add("") add("")
add(f"- коммит: `{git_rev()}`, дата: {datetime.datetime.now():%Y-%m-%d %H:%M}") add(f"- движок: `{meta['game']}`, sha256 `{meta['game_sha256'][:16]}`; коммит мода (не движка): `{git_rev()}`")
add(f"- ресурсы: `{meta['resources']}`")
add(f"- дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?") add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?")
add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию")) add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"]
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%") else "идут (`--no-freeze`, живой замер)"))
add(f"- кадров на сцену: {meta['stills']} через {meta['still_gap']:g} с; прогрев {meta['warmup_secs']:g} с")
add(f"- движение: размах между кадрами > {meta['motion_level']}/255 (вода, облака, листва) не сравнивается с базой; "
f"мерцание: статичные пиксели с размахом > {meta['flicker_level']}/255")
add("- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание: "
+ (f"<= {args.max_flicker:g}%" if args.max_flicker is not None else "только в отчёте (порог не задан)"))
if baseline: if baseline:
add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)") add(f"- сравнение с: `{baseline}` (статичные пиксели: средняя разница <= {args.max_mean:g}, "
f"пикселей >20/255 <= {args.max_pct:g}%)")
add("") add("")
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |") add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |")
add("|---|---|---|---|---|---|---|---|---|") add("|---|---|---|---|---|---|---|---|---|---|")
for e in entries: for e in entries:
gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-" gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-"
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-" fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
b = e.get("vs_baseline") or {} b = e.get("vs_baseline") or {}
bm = f"{b['mean']:.2f}" if "mean" in b else "-" bm = f"{b['mean']:.2f}" if "mean" in b else "-"
bp = f"{b['pct']:.2f}" if "pct" in b else "-" bp = f"{b['pct']:.2f}" if "pct" in b else "-"
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"]) status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-") budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-")
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {bm} | {bp} | {status} |") add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {mv} | {bm} | {bp} | {status} |")
passes = [] passes = []
for e in entries: for e in entries:
for p in e.get("passes", {}): for p in e.get("passes", {}):
@@ -458,7 +551,10 @@ def write_report(out, entries, args, env_extra, size, baseline):
add(f"![{e['name']}]({e['frame']})") add(f"![{e['name']}]({e['frame']})")
if e.get("diff_image"): if e.get("diff_image"):
add("") add("")
add(f"Разница с базой (x{DIFF_GAIN}): ![diff]({e['diff_image']})") add(f"Разница с базой (x{DIFF_GAIN}, синим — движущиеся пиксели, не сравниваются): ![diff]({e['diff_image']})")
if e.get("motion_image"):
add("")
add(f"Движущиеся пиксели (белым): ![motion]({e['motion_image']})")
if e.get("warning"): if e.get("warning"):
add("") add("")
add(f"> {e['warning']}") add(f"> {e['warning']}")
@@ -477,14 +573,26 @@ def parse_args(argv):
p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0") p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0")
p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)") p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)")
p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)") p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)")
p.add_argument("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)") p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)")
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255") p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)")
p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%") p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)")
p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%") p.add_argument("--warmup-secs", type=float, default=1.0, help="прогрев перед замером GPU, с; не в отчёте (по умолчанию 1)")
p.add_argument("--motion-level", type=int, default=MOTION_LEVEL,
help=f"размах между кадрами выше этого (из 255) — движение, не сравнивается с базой (по умолчанию {MOTION_LEVEL})")
p.add_argument("--flicker-level", type=int, default=FLICKER_LEVEL,
help=f"размах статичного пикселя выше этого (из 255) — мерцание (по умолчанию {FLICKER_LEVEL})")
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
p.add_argument("--no-freeze", action="store_true",
help="не замораживать игровое время и погоду (живой замер производительности, не снимки)")
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING") p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)") p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3") p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3")
return p.parse_args(argv) args = p.parse_args(argv)
if args.stills < 2:
p.error("--stills: нужно не меньше 2 кадров, иначе нет ни мерцания, ни движения")
return args
def main(argv=None): def main(argv=None):
@@ -518,6 +626,14 @@ def main(argv=None):
game = Path(args.game) if args.game else airdef.built_game() game = Path(args.game) if args.game else airdef.built_game()
resources = Path(args.resources) if args.resources else airdef.resources() resources = Path(args.resources) if args.resources else airdef.resources()
try:
game_sha = file_sha256(game)
except OSError:
game_sha = "?" * 64
meta = {"game": str(game), "game_sha256": game_sha, "resources": str(resources),
"freeze": not args.no_freeze, "stills": args.stills, "still_gap": args.still_gap,
"warmup_secs": args.warmup_secs, "motion_level": args.motion_level,
"flicker_level": args.flicker_level}
timing = not args.no_pass_timing timing = not args.no_pass_timing
env = game_env(env_extra, timing) env = game_env(env_extra, timing)
prepare_home() prepare_home()
@@ -537,23 +653,28 @@ def main(argv=None):
for s in group: for s in group:
entry = {**asdict(s), "frame": f"{s.name}.png"} entry = {**asdict(s), "frame": f"{s.name}.png"}
try: try:
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing)) entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills,
fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png") args.still_gap, args.warmup_secs, not args.no_freeze))
entry["flicker"] = fl w, h, rgbs = load_frames(shot_frames(out, s.name, args.stills))
rng = motion_range(rgbs)
entry["flicker"] = flicker_stats(rng, w, h, len(rgbs), args.motion_level, args.flicker_level)
if baseline and not entry.get("error"):
base_paths = shot_frames(baseline, s.name) # все кадры базы, сколько есть
if not base_paths:
entry["vs_baseline"] = {"error": f"в базе нет {s.name}.png"}
else:
bw, bh, brgbs = load_frames(base_paths)
if (bw, bh) != (w, h):
raise BenchError(f"размеры кадров разные: база {bw}x{bh}, прогон {w}x{h}")
brng = motion_range(brgbs)
diff_name, motion_name = f"{s.name}_diff.png", f"{s.name}_motion.png"
entry["vs_baseline"] = compare_runs(rgbs, rng, brgbs, brng, w, h, args.motion_level,
out / diff_name, out / motion_name)
entry["vs_baseline"]["base_frames"] = len(brgbs)
entry["diff_image"] = diff_name
entry["motion_image"] = motion_name
except BenchError as e: except BenchError as e:
entry["error"] = str(e) entry["error"] = str(e)
if baseline and not entry.get("error"):
base_png = baseline / f"{s.name}.png"
if base_png.exists():
try:
diff_name = f"{s.name}_diff.png"
m = compare_frames(base_png, out / f"{s.name}.png", out / diff_name)
entry["vs_baseline"] = m
entry["diff_image"] = diff_name
except BenchError as e:
entry["vs_baseline"] = {"error": str(e)}
else:
entry["vs_baseline"] = {"error": f"в базе нет {base_png.name}"}
entry["fail"] = judge(entry, args) entry["fail"] = judge(entry, args)
entries.append(entry) entries.append(entry)
stop_game() stop_game()
@@ -567,15 +688,16 @@ def main(argv=None):
break break
if size is None and entries and entries[0].get("flicker"): if size is None and entries and entries[0].get("flicker"):
size = entries[0]["flicker"]["size"] size = entries[0]["flicker"]["size"]
results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, "meta": meta,
"baseline": str(baseline) if baseline else None, "shots": entries} "baseline": str(baseline) if baseline else None, "shots": entries}
(out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n") (out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None) write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None, meta)
failed = [e for e in entries if e["fail"]] failed = [e for e in entries if e["fail"]]
for e in entries: for e in entries:
print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс " print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс "
f"мерцание {e['flicker']['pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}") f"мерцание {e['flicker']['pct']:.2f}% движение {e['flicker']['animated_pct']:.2f}%" if e.get("flicker")
else f"{e['name']:22} {e.get('error', '')}")
for r in e["fail"]: for r in e["fail"]:
print(f" FAIL: {r}") print(f" FAIL: {r}")
print(f"отчёт: {out / 'report.md'}") print(f"отчёт: {out / 'report.md'}")