#!/usr/bin/env python3 """ Validate a part library (default: the pixellab one in parts_pl/; --library classic for parts/). Fails on: - a missing file for any part listed in sprite_parts.py - any color outside the palette keys, pupil, eye white and marker colors - any partial alpha - a missing (or duplicated) required marker for the slot / body type - any legal composite that triggers "canvas expanded": checked exhaustively per body via bounding boxes (only for libraries without a fits() rule), plus a random sample of real composites python validate_parts.py [--library pixellab|classic] [--sample N] [--verbose] """ import random import sys from itertools import product from pathlib import Path import numpy as np from PIL import Image import generate_sprite as gs from sprite_parts import TYPES lib = None # registry module, set in main PARTS = None ALLOWED = {tuple(v) for v in gs.KEYS.values()} | {gs.PUPIL, gs.EYE_WHITE} | {tuple(v) for v in gs.MARKERS.values()} | gs.FIXED_COLORS MARKER_BY_COLOR = {v: k for k, v in gs.MARKERS.items()} def required_markers(slot, p): if slot == "body": return {"pivot", "tail", "back", "aura", "head_top"} return {"pivot"} def check_file(slot, p): errs = [] path = PARTS / slot / f"{p['name']}.png" if not path.exists(): return [f"missing file {path}"] arr = np.array(Image.open(path).convert("RGBA")) a = arr[:, :, 3] partial = np.count_nonzero((a > 0) & (a < 255)) if partial: errs.append(f"{path}: {partial} partial-alpha pixel(s)") opaque = arr[a > 0][:, :3] colors = {tuple(int(v) for v in c) for c in np.unique(opaque, axis=0)} if len(opaque) else set() bad = colors - ALLOWED if bad: errs.append(f"{path}: illegal colors {sorted(gs.rgb_to_hex(c) for c in bad)[:8]}") found = {} for c in colors & set(MARKER_BY_COLOR): n = int(np.count_nonzero(np.all(arr[:, :, :3] == c, axis=-1) & (a > 0))) found[MARKER_BY_COLOR[c]] = n req = required_markers(slot, p) missing = req - set(found) if missing: errs.append(f"{path}: missing markers {sorted(missing)}") extra = set(found) - req if extra: errs.append(f"{path}: unexpected markers {sorted(extra)}") dup = [k for k, n in found.items() if n > 1] if dup: errs.append(f"{path}: marker used more than once {sorted(dup)}") if slot == "aura" and (arr.shape[1], arr.shape[0]) != (84, 84): errs.append(f"{path}: aura must be 84x84, is {arr.shape[1]}x{arr.shape[0]}") return errs # ------------------------------------------------------------------ size checks def bbox_of(slot, name): """(anchors, opaque bbox x0,y0,x1,y1 exclusive) of a loaded part.""" loaded = gs.load_part(str(PARTS), slot, name) if loaded is None: return None img, anch = loaded bb = img.getbbox() return anch, bb def exhaustive_extents(): """For each body, the worst legal composite size, computed from bounding boxes. Every variation slot is independent given the body, so the union bbox worst case is found by taking each side's extreme from a different slot (or one part covering both).""" problems = [] worst = {} for body in lib.BODY: plan = body["plans"][0] b = bbox_of("body", body["name"]) if b is None: continue banch, bbb = b bpos = (-banch["pivot"][0], -banch["pivot"][1]) tags = {f"plan:{plan}"} | set(body["tags"]) excl = set(body["excludes"]) fx0, fy0, fx1, fy1 = bpos[0] + bbb[0], bpos[1] + bbb[1], bpos[0] + bbb[2], bpos[1] + bbb[3] opt = {} for slot in gs.PICK_ORDER[1:]: lst = [] for p in lib.SLOTS[slot]: if p["name"] == "none" or plan not in p["plans"] or p["name"] in body["deny"]: continue if p["requires"] and not set(p["requires"]) & tags: continue if set(p["tags"]) & excl or set(p["excludes"]) & tags: continue pb = bbox_of(slot, p["name"]) a = banch.get(slot) if pb is None or a is None or pb[1] is None: continue panch, pbb = pb piv = panch.get("pivot", (0, 0)) pos = (bpos[0] + a[0] - piv[0], bpos[1] + a[1] - piv[1]) lst.append((pos[0] + pbb[0], pos[1] + pbb[1], pos[0] + pbb[2], pos[1] + pbb[3], p["name"])) if lst: opt[slot] = lst def worst_span(lo_i, hi_i, f_lo, f_hi): best = (f_hi - f_lo, "") for s1 in opt: for p in opt[s1]: # single part both sides span = max(f_hi, p[hi_i]) - min(f_lo, p[lo_i]) if span > best[0]: best = (span, f"{s1}={p[4]}") for s1, s2 in product(opt, opt): if s1 == s2: continue lo = min(opt[s1], key=lambda p: p[lo_i]) hi = max(opt[s2], key=lambda p: p[hi_i]) span = max(f_hi, hi[hi_i]) - min(f_lo, lo[lo_i]) if span > best[0]: best = (span, f"{s1}={lo[4]} + {s2}={hi[4]}") return best w, wwhy = worst_span(0, 2, fx0, fx1) h, hwhy = worst_span(1, 3, fy0, fy1) worst[body["name"]] = (w, h, wwhy, hwhy) if w + 2 > lib.CANVAS: problems.append(f"{body['name']}: worst width {w} > {lib.CANVAS - 2} ({wwhy})") if h + lib.GROUND_MARGIN + 1 > lib.CANVAS: problems.append(f"{body['name']}: worst height {h} > {lib.CANVAS - lib.GROUND_MARGIN - 1} ({hwhy})") return problems, worst def sample_composites(n): problems = [] rng = random.Random(12345) for i in range(n): ptype = rng.choice(TYPES) prng = random.Random(i) plan, chosen, markings, tags = gs.pick_parts(lib, ptype, prng) if any(not (PARTS / s / f"{p['name']}.png").exists() for s, p in chosen.items() if p["name"] != "none"): continue spr = gs.compose(str(PARTS), chosen, markings, prng) if max(spr.width + 2, spr.height + lib.GROUND_MARGIN + 1) > lib.CANVAS: problems.append(f"sample {ptype} seed {i}: {spr.width}x{spr.height} " f"({', '.join(f'{s}={p['name']}' for s, p in chosen.items())})") return problems def main(argv): global lib, PARTS n = 2000 if "--sample" in argv: n = int(argv[argv.index("--sample") + 1]) name = argv[argv.index("--library") + 1] if "--library" in argv else gs.DEFAULT_LIBRARY if name not in gs.LIBRARIES: print(f"unknown library {name!r}, choose from {', '.join(gs.LIBRARIES)}") return 2 lib = gs.load_library(name) PARTS = Path(lib.PARTS_DIR) print(f"library {name} ({PARTS}/, canvas {lib.CANVAS})") if not lib.BODY: print("library is empty (no bodies), nothing to validate") return 0 errs = [] for slot, parts in lib.SLOTS.items(): for p in parts: if p["name"] != "none": errs += check_file(slot, p) if hasattr(lib, "fits"): worst = {} print("exhaustive size check skipped: library has a fits() rule, relying on the random sample") else: size_probs, worst = exhaustive_extents() errs += size_probs errs += sample_composites(n) for e in errs: print("FAIL", e) if "--verbose" in argv: for b, (w, hh, ww, hw) in sorted(worst.items()): print(f"{b:16s} worst {w}x{hh} w:{ww} h:{hw}") total = sum(1 for parts in lib.SLOTS.values() for p in parts if p["name"] != "none") present = sum(1 for slot, parts in lib.SLOTS.items() for p in parts if p["name"] != "none" and (PARTS / slot / f"{p['name']}.png").exists()) print(f"{present}/{total} parts present, {len(errs)} problem(s)") return 1 if errs else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))