#!/usr/bin/env python3 """ Stitch WoWMapUprez_Dungeons 4×3 BLP tile grids and emit BOTH a hi-res PNG (lossless intermediate) and a web-ready WebP per dungeon-floor in parallel. Inputs: data/uprez/Interface/Worldmap//_.blp Outputs: data/maps_png_hires/[_floor].png (lossless, ~8 MB) web/assets/maps/[_floor].webp (q=82, ~600 KB-1.5 MB) """ from __future__ import annotations import argparse import os import re import sys from collections import defaultdict from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path from PIL import Image ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "data" / "uprez" / "Interface" / "Worldmap" DST_PNG = ROOT / "data" / "maps_png_hires" DST_WEBP = ROOT / "web" / "assets" / "maps" # Two tile-naming conventions: # _.blp — multi-level dungeons (e.g. Naxxramas2_5.blp) # .blp — single-level dungeons (e.g. ZulFarrak5.blp) # In both cases, idx is 1-12 (12 tiles per level in a 4×3 grid). TILE_IDX_RE = re.compile(r"^(?P.+?)(?P1[0-2]|[1-9])\.blp$", re.IGNORECASE) FLOOR_SUFFIX_RE = re.compile(r"^(?P.+?)(?P\d+)_$") COLS, ROWS = 4, 3 TILES_PER_FLOOR = COLS * ROWS def parse_tile(filename: str): """Return (mapname, floor, idx) or None. floor=0 indicates the "single-level" form with no explicit floor in the filename.""" m = TILE_IDX_RE.match(filename) if not m: return None prefix = m.group("prefix") idx = int(m.group("idx")) fm = FLOOR_SUFFIX_RE.match(prefix) if fm: return fm.group("map"), int(fm.group("floor")), idx return prefix, 0, idx def stitch_one(args): out_basename, tile_paths, png_dir, webp_dir, max_width, quality, write_png = args # Open tile #1 to learn dimensions; assume uniform. with Image.open(tile_paths[1]) as t1: tw, th = t1.size canvas = Image.new("RGBA", (COLS * tw, ROWS * th)) for idx in range(1, TILES_PER_FLOOR + 1): with Image.open(tile_paths[idx]) as tile: col = (idx - 1) % COLS row = (idx - 1) // COLS canvas.paste(tile.convert("RGBA"), (col * tw, row * th)) if write_png: canvas.save(png_dir / f"{out_basename}.png", optimize=False) # WebP needs RGB and may want a downscale img = canvas.convert("RGB") if max_width and img.width > max_width: ratio = max_width / img.width img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS) img.save(webp_dir / f"{out_basename}.webp", "WEBP", quality=quality, method=4) return out_basename, canvas.size def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--max-width", type=int, default=4096, help="downscale WebP to this max width (default: keep 4096)") ap.add_argument("--quality", type=int, default=82) ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 1)) ap.add_argument("--no-png", action="store_true", help="skip lossless PNG intermediate (save disk + time)") args = ap.parse_args() if not SRC.exists(): print(f"missing {SRC} — clone WoWMapUprez_Dungeons first", file=sys.stderr) return 1 DST_PNG.mkdir(parents=True, exist_ok=True) DST_WEBP.mkdir(parents=True, exist_ok=True) # Group: { (mapname, floor): {idx: path} }; floor=0 is "no floor in filename" groups: dict[tuple[str, int], dict[int, Path]] = defaultdict(dict) for dungeon_dir in sorted(SRC.iterdir()): if not dungeon_dir.is_dir(): continue for blp in dungeon_dir.glob("*.blp"): parsed = parse_tile(blp.name) if not parsed: continue mapname, floor, idx = parsed groups[(mapname, floor)][idx] = blp floors_per_map: dict[str, set[int]] = defaultdict(set) for (mapname, floor) in groups: floors_per_map[mapname].add(floor) jobs = [] skipped = [] for (mapname, floor), tiles in sorted(groups.items()): if len(tiles) != TILES_PER_FLOOR: skipped.append(f"{mapname} floor {floor}: {len(tiles)}/{TILES_PER_FLOOR}") continue floors = floors_per_map[mapname] if len(floors) == 1: suffix = "" # only one variant elif floor == 0: suffix = "_overview" # mixed: 0 + others else: suffix = f"_floor{floor}" out_base = f"{mapname}{suffix}" jobs.append((out_base, tiles, DST_PNG, DST_WEBP, args.max_width, args.quality, not args.no_png)) print(f"stitching {len(jobs)} dungeon-floors with {args.workers} workers...") done = 0 with ProcessPoolExecutor(max_workers=args.workers) as pool: futures = [pool.submit(stitch_one, j) for j in jobs] for fut in as_completed(futures): try: base, size = fut.result() done += 1 if done % 20 == 0 or done == len(jobs): print(f" {done}/{len(jobs)} (latest: {base} {size[0]}×{size[1]})") except Exception as e: print(f" FAIL: {e}", file=sys.stderr) print(f"done: {done}/{len(jobs)} stitched") if skipped: print(f"\nskipped {len(skipped)} (incomplete tile sets):", file=sys.stderr) for s in skipped[:10]: print(f" {s}", file=sys.stderr) if len(skipped) > 10: print(f" ... and {len(skipped) - 10} more", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main())