Files
mtgodot-poc/scripts/build_hd_ui.py
T
shenleiandClaude Opus 5.5 01cc252a1d Host: 2x UI textures from hd_ui.pak (AI-upscaled 40250 UI art)
The phone magnifies the 600-high 40250 UI about 1.8x, so the 1x window art
and icons look soft next to the oversampled text. scripts/build_hd_ui.py
upscales every UI image the zh client loads (d:/ymir work/ui/, icon/,
locale/zh/) with realesrgan-x4plus, reduces it to 2x and back-projects the
residual so the board grain the network denoises away comes back; the host
uploads a texture found in hd_ui.pak in place of the pack file. Layout,
sizes and UVs stay the engine's 1x ones. MT_HD_UI=0 disables it; the pak
(135 MB, Ymir art) is built into the client dir and synced to the phone,
never committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:04:46 +09:00

253 lines
11 KiB
Python

#!/usr/bin/env python3
"""Builds hd_ui.pak: the 40250 UI textures at twice their resolution, for src/host/hd_ui.cpp.
The phone lays the 40250 UI out 600 logical pixels high and magnifies it about 1.8x onto the screen, so
the 1x art (32x32 icons, board frames) looks soft. This reads every UI image the client would load
(d:/ymir work/ui/, icon/, locale/<locale>/) from the registered packs (first registered pack wins, as
CEterPackManager), upscales it 4x with Real-ESRGAN (realesrgan-x4plus, ncnn-vulkan build) and
Lanczos-reduces it to exactly 2x. The engine keeps the 1x size for layout and UVs; the host only
samples finer texels, so nothing about the 40250 UI changes but the texel density.
scripts/build_hd_ui.py --esrgan DIR [--client build/zh-locale/Client] [--locale zh] [--out FILE]
DIR holds the realesrgan-ncnn-vulkan binary and its models/ (github.com/xinntao/Real-ESRGAN releases,
v0.2.5.0). Neither the tool nor the pak enters git: the output is Ymir art (see .gitignore).
Grain: realesrgan-x4plus smooths the stone and paper fills of the boards; two rounds of back-projection
(the source minus the result box-reduced to 1x, enlarged and added) put that texture back.
Edges: each image is padded before upscaling so the network sees context at the border. An image whose
opposite edges continue each other (a tiling board fill) is padded by wrapping, so its repeats meet
without a seam; others by reflection. Colours under fully transparent pixels are bled in from their
opaque neighbours first, so the upscaled alpha edges carry no dark fringe.
Pak format (little endian): "MTHD", u32 version (1), u32 count, then count x {u16 name length, name
bytes (the pack path, lowercase), u32 offset, u32 size}, then the PNG payloads at their offsets.
"""
from __future__ import annotations
import argparse
import io
import json
import os
import shutil
import struct
import subprocess
import sys
import tempfile
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "tools/epk_scan"))
sys.path.insert(0, str(REPO / ".agents/skills/metin2-40250-parity-audit/scripts"))
import epk_scan # noqa: E402
import refroot # noqa: E402
IMAGE_EXTS = (".tga", ".dds", ".png", ".jpg", ".bmp")
MAX_SIDE = 1024 # larger images (none in the UI dirs today) are left at 1x
PAD = 8 # source pixels of context around each image
MODEL = "realesrgan-x4plus"
BACK_PROJECT_ROUNDS = 2
VERSION = 1
def pack_entries(client: Path):
"""{pack path: reader} for the first registered pack holding each name."""
_, keys, order, packs, _ = epk_scan.load(client, refroot.reference_root())
seen = {}
for name, _folder in order:
p = packs[name]
if p["missing"]:
continue
for e in p["entries"]:
seen.setdefault(e["name"], (p["epk"], e))
return keys, seen
def read_entry(keys, epk: Path, e: dict) -> bytes:
with open(epk, "rb") as f:
f.seek(e["pos"])
stored = f.read(e["size"])
if e["type"] == 0:
return stored
f.seek(e["pos"])
obj = f.read(epk_scan.lz_object_len(stored[:16]))
return epk_scan.lz_object(obj, keys["s_adwEterPackSecurityKey"] if e["type"] == 2 else None)
def wanted(name: str, locale: str) -> bool:
if not name.endswith(IMAGE_EXTS):
return False
return (name.startswith("d:/ymir work/ui/") or name.startswith("icon/")
or name.startswith(f"locale/{locale}/"))
def tiles(a: np.ndarray, axis: int) -> bool:
"""Whether the image continues across the edges of `axis` (1: left/right, 0: top/bottom)."""
if a.shape[axis] < 4 or a[..., 3].min() < 255:
return False
rgb = a[..., :3].astype(np.int32)
first, last = np.take(rgb, 0, axis=axis), np.take(rgb, -1, axis=axis)
wrap = np.abs(first - last).mean()
inner = np.abs(np.diff(rgb, axis=axis)).mean()
return wrap <= max(2.0, 1.5 * inner)
def bleed(im: Image.Image) -> Image.Image:
"""Fills the colour of fully transparent pixels from nearby opaque ones (premultiplied blur)."""
a = np.asarray(im).astype(np.float32)
alpha = a[..., 3:4] / 255.0
if alpha.min() > 0:
return im
fill = a[..., :3].copy()
for radius in (2, 6, 16, 48):
pre = Image.fromarray(np.clip(a[..., :3] * alpha, 0, 255).astype(np.uint8))
w = Image.fromarray((alpha[..., 0] * 255).astype(np.uint8))
pre_b = np.asarray(pre.filter(ImageFilter.GaussianBlur(radius))).astype(np.float32)
w_b = np.asarray(w.filter(ImageFilter.GaussianBlur(radius))).astype(np.float32)[..., None]
ok = (w_b > 0.5) & (alpha == 0)
est = pre_b / np.maximum(w_b / 255.0, 1e-6)
fill = np.where(ok, est, fill)
alpha_known = (alpha > 0) | ok
if alpha_known.all():
break
out = a.copy()
out[..., :3] = np.where(alpha == 0, fill, a[..., :3])
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA")
def prepare(job):
"""Decodes one pack image into a padded RGBA PNG for the upscaler; returns its metadata or None."""
idx, name, data, work = job
try:
im = Image.open(io.BytesIO(data))
im.load()
except Exception:
return None
im = im.convert("RGBA")
w, h = im.size
if max(w, h) > MAX_SIDE or min(w, h) < 2:
return None
a = np.asarray(im)
tile_x, tile_y = tiles(a, 1), tiles(a, 0)
src = np.asarray(bleed(im))
px, py = min(PAD, w), min(PAD, h)
src = np.pad(src, ((0, 0), (px, px), (0, 0)), mode="wrap" if tile_x else "reflect")
src = np.pad(src, ((py, py), (0, 0), (0, 0)), mode="wrap" if tile_y else "reflect")
Image.fromarray(src, "RGBA").save(work / "in" / f"{idx}.png", compress_level=1)
return {"idx": idx, "name": name, "w": w, "h": h, "px": px, "py": py,
"alpha": bool(a[..., 3].min() < 255)}
def original(meta, work: Path) -> Image.Image:
src = Image.open(work / "in" / f"{meta['idx']}.png").convert("RGBA")
return src.crop((meta["px"], meta["py"], meta["px"] + meta["w"], meta["py"] + meta["h"]))
def back_project(hd: Image.Image, src: Image.Image, rounds: int = BACK_PROJECT_ROUNDS) -> Image.Image:
"""Adds back what the upscale lost at the source scale, so the result box-reduces to the source.
realesrgan-x4plus denoises, and the fine stone and paper grain of the 40250 boards is what it takes
for noise: the frames come out sharp but the fills flat. The residual (source minus the result
reduced to 1x), bicubic-enlarged and added, restores that grain while the network's edges stay."""
a = np.asarray(hd).astype(np.float32)
ref = np.asarray(src).astype(np.float32)
for _ in range(rounds):
down = np.asarray(Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "RGBA").resize(src.size, Image.BOX))
residual = ref - down.astype(np.float32)
a += np.stack([np.asarray(Image.fromarray(residual[..., c]).resize(hd.size, Image.BICUBIC)) for c in range(4)], -1)
return Image.fromarray(np.clip(a + 0.5, 0, 255).astype(np.uint8), "RGBA")
def finish(job):
"""Crops the padding off the 4x result and reduces it to 2x; returns (name, png bytes) or None."""
meta, work = job
path = work / "out" / f"{meta['idx']}.png"
if not path.exists():
return None
up = Image.open(path).convert("RGBA")
s = 4
up = up.crop((meta["px"] * s, meta["py"] * s, (meta["px"] + meta["w"]) * s, (meta["py"] + meta["h"]) * s))
hd = back_project(up.resize((meta["w"] * 2, meta["h"] * 2), Image.LANCZOS), original(meta, work))
if not meta["alpha"]:
hd.putalpha(255)
buf = io.BytesIO()
hd.save(buf, "PNG", compress_level=6)
return meta["name"], buf.getvalue()
def write_pak(out: Path, items: list[tuple[str, bytes]]) -> None:
items = sorted(items)
offset = 12 + sum(2 + len(n.encode("latin-1")) + 8 for n, _ in items)
header = [b"MTHD", struct.pack("<II", VERSION, len(items))]
entries = []
for name, data in items:
raw = name.encode("latin-1")
entries.append(struct.pack("<H", len(raw)) + raw + struct.pack("<II", offset, len(data)))
offset += len(data)
tmp = out.with_suffix(".tmp")
with open(tmp, "wb") as f:
f.write(b"".join(header) + b"".join(entries))
for _, data in items:
f.write(data)
os.replace(tmp, out)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--esrgan", type=Path, default=os.environ.get("MT_ESRGAN"), required="MT_ESRGAN" not in os.environ,
help="directory with realesrgan-ncnn-vulkan and models/ (or MT_ESRGAN)")
ap.add_argument("--client", type=Path, default=REPO / "build/zh-locale/Client")
ap.add_argument("--locale", default="zh")
ap.add_argument("--out", type=Path, help="default: <client>/hd_ui.pak")
ap.add_argument("--work", type=Path, help="keep intermediates here (default: a temporary directory)")
ap.add_argument("--reuse", action="store_true",
help="keep the upscaled images already in --work and only redo the 2x reduction and the pak")
ap.add_argument("--limit", type=int, default=0, help="only the first N images (a quick look)")
args = ap.parse_args()
out = args.out or args.client / "hd_ui.pak"
binary = args.esrgan / "realesrgan-ncnn-vulkan"
if not binary.exists():
sys.exit(f"{binary} not found")
keys, seen = pack_entries(args.client)
names = sorted(n for n in seen if wanted(n, args.locale))
if args.limit:
names = names[:args.limit]
print(f"{len(names)} UI images in {args.client}/pack")
if args.reuse and not (args.work and (args.work / "manifest.json").exists()):
sys.exit("--reuse needs a --work directory from an earlier run")
work = args.work or Path(tempfile.mkdtemp(prefix="hd_ui_"))
try:
if args.reuse:
metas = json.loads((work / "manifest.json").read_text())
else:
for sub in ("in", "out"):
shutil.rmtree(work / sub, ignore_errors=True)
(work / sub).mkdir(parents=True)
jobs = [(i, n, read_entry(keys, *seen[n]), work) for i, n in enumerate(names)]
with ProcessPoolExecutor() as pool:
metas = [m for m in pool.map(prepare, jobs, chunksize=16) if m]
print(f"prepared {len(metas)} (skipped {len(names) - len(metas)} undecodable or oversized)")
(work / "manifest.json").write_text(json.dumps(metas))
subprocess.run([str(binary), "-i", str(work / "in"), "-o", str(work / "out"), "-n", MODEL, "-s", "4",
"-f", "png"], cwd=args.esrgan, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with ProcessPoolExecutor() as pool:
items = [r for r in pool.map(finish, [(m, work) for m in metas], chunksize=16) if r]
write_pak(out, items)
print(f"wrote {out}: {len(items)} images, {out.stat().st_size / 2**20:.1f} MB")
finally:
if not args.work:
shutil.rmtree(work, ignore_errors=True)
return 0
if __name__ == "__main__":
sys.exit(main())