Compare commits

..

1 Commits

+209 -87
View File
@@ -1,24 +1,37 @@
#!/usr/bin/env python3
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
"""Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
python3 tools/hd_bench.py --list # сцены и их параметры
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
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 --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,
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток,
ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4`
(GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
Итог: 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 — ошибка запуска или аргументов.
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее.
"""
import argparse
import datetime
import hashlib
import itertools
import json
import operator
import os
import re
import shutil
@@ -38,9 +51,12 @@ HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересо
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
SAVES = ROOT / "run-home" / "saves"
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой)
DIFF_GAIN = 6 # усиление разницы в diff-картинках
MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва)
FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание
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
@@ -173,6 +189,14 @@ def stop_game():
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 и с ним -------------------------------------------------------------
def _paeth(a, b, c):
@@ -248,51 +272,103 @@ def write_png_rgb(path, w, h, rgb):
+ chunk(b"IEND", b""))
try: # необязательно: быстрее декодирование и сравнение
from PIL import Image, ImageChops, ImageStat
try: # необязательно: быстрее декодирование
from PIL import Image
except ImportError: # pragma: no cover
Image = None
def compare_frames(a, b, diff_out=None):
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
def load_rgb(path):
if Image is not None:
A = Image.open(a).convert("RGB")
B = Image.open(b).convert("RGB")
if A.size != B.size:
raise BenchError(f"размеры кадров разные: {A.size} и {B.size}")
d = ImageChops.difference(A, B)
mean = sum(ImageStat.Stat(d).mean) / 3
r, g, bl = d.split()
mx = ImageChops.lighter(ImageChops.lighter(r, g), bl)
hist = mx.histogram()
total = A.size[0] * A.size[1]
pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total
if diff_out:
d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out)
return {"mean": mean, "pct": pct, "size": list(A.size)}
wa, ha, pa = read_png_rgb(a)
wb, hb, pb = read_png_rgb(b)
if (wa, ha) != (wb, hb):
raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}")
total_abs = 0
over = 0
diff = bytearray(len(pa)) if diff_out else None
for i in range(0, len(pa), 3):
dr = abs(pa[i] - pb[i])
dg = abs(pa[i + 1] - pb[i + 1])
db = abs(pa[i + 2] - pb[i + 2])
total_abs += dr + dg + db
if max(dr, dg, db) > DIFF_LEVEL:
over += 1
if diff is not None:
diff[i] = min(255, dr * DIFF_GAIN)
diff[i + 1] = min(255, dg * DIFF_GAIN)
diff[i + 2] = min(255, db * DIFF_GAIN)
im = Image.open(path).convert("RGB")
return im.size[0], im.size[1], im.tobytes()
return read_png_rgb(path)
def load_frames(paths):
"""(w, h, [bytes RGB каждого кадра]); размеры должны совпадать."""
w = h = None
rgbs = []
for p in paths:
pw, ph, px = load_rgb(p)
if w is None:
w, h = pw, ph
elif (pw, ph) != (w, h):
raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})")
rgbs.append(px)
return w, h, rgbs
def shot_frames(folder, name, n=None):
"""Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть."""
paths = []
while n is None or len(paths) < n:
k = len(paths) + 1
p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png")
if not p.exists():
break
paths.append(p)
return paths
# --- сравнение кадров с учётом анимации (#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:
write_png_rgb(diff_out, wa, ha, bytes(diff))
npx = wa * ha
return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]}
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
for i, s in enumerate(static):
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
if freeze: # игровое время и погода стоят: облачные часы и погода не уходят между снимками
for rule in FREEZE_RULES:
ctl("command", f"gamerule {rule} false")
ctl("command", f"time set {shot.time}")
ctl("command", "weather clear")
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
ctl("wait-ready", 120, timeout=150)
time.sleep(settle)
first = out / f"{shot.name}.png"
ctl("screenshot", str(first.resolve()))
time.sleep(0.5)
second = out / f"{shot.name}_still2.png"
ctl("screenshot", str(second.resolve()))
result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {},
frames = []
for k in range(stills):
if k:
time.sleep(gap)
path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png")
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}
offset = LOG.stat().st_size if LOG.exists() else 0
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"):
reasons.append("нет GPU-таймингов (bench)")
fl = entry.get("flicker")
if fl and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание {fl['pct']:.2f}% > {args.max_flicker:g}%")
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}%")
base = entry.get("vs_baseline")
if base:
if base.get("error"):
reasons.append(base["error"])
else:
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:
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
def write_report(out, entries, args, env_extra, size, baseline):
def write_report(out, entries, args, env_extra, size, baseline, meta):
lines = []
add = lines.append
add(f"# HD-полигон: {out.name}")
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"- окружение игры: " + (", ".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} с), мерцание <= {args.max_flicker:g}%")
add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"]
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:
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("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |")
add("|---|---|---|---|---|---|---|---|---|")
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |")
add("|---|---|---|---|---|---|---|---|---|---|")
for e in entries:
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 "-"
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
b = e.get("vs_baseline") or {}
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
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 "-")
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 = []
for e in entries:
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']})")
if e.get("diff_image"):
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"):
add("")
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("--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("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)")
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=5.0, help="порог мерцания (два кадра подряд), %%")
p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)")
p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)")
p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)")
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("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
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):
@@ -518,6 +626,14 @@ def main(argv=None):
game = Path(args.game) if args.game else airdef.built_game()
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
env = game_env(env_extra, timing)
prepare_home()
@@ -537,23 +653,28 @@ def main(argv=None):
for s in group:
entry = {**asdict(s), "frame": f"{s.name}.png"}
try:
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing))
fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png")
entry["flicker"] = fl
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills,
args.still_gap, args.warmup_secs, not args.no_freeze))
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:
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)
entries.append(entry)
stop_game()
@@ -567,15 +688,16 @@ def main(argv=None):
break
if size is None and entries and entries[0].get("flicker"):
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}
(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"]]
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)} мс "
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"]:
print(f" FAIL: {r}")
print(f"отчёт: {out / 'report.md'}")