init
This commit is contained in:
189
validate_parts.py
Executable file
189
validate_parts.py
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate the part library in 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, plus a random sample of real composites
|
||||
|
||||
python validate_parts.py [--sample N]
|
||||
"""
|
||||
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
|
||||
import sprite_parts as cfg
|
||||
|
||||
PARTS = Path("parts")
|
||||
ALLOWED = {tuple(v) for v in gs.KEYS.values()} | {gs.PUPIL, gs.EYE_WHITE} | {tuple(v) for v in gs.MARKERS.values()}
|
||||
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 cfg.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 cfg.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 > gs.CANVAS:
|
||||
problems.append(f"{body['name']}: worst width {w} > {gs.CANVAS - 2} ({wwhy})")
|
||||
if h + gs.GROUND_MARGIN + 1 > gs.CANVAS:
|
||||
problems.append(f"{body['name']}: worst height {h} > {gs.CANVAS - gs.GROUND_MARGIN - 1} ({hwhy})")
|
||||
return problems, worst
|
||||
|
||||
|
||||
def sample_composites(n):
|
||||
problems = []
|
||||
rng = random.Random(12345)
|
||||
for i in range(n):
|
||||
ptype = rng.choice(cfg.TYPES)
|
||||
prng = random.Random(i)
|
||||
plan, chosen, markings, tags = gs.pick_parts(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 + gs.GROUND_MARGIN + 1) > gs.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):
|
||||
n = 2000
|
||||
if "--sample" in argv:
|
||||
n = int(argv[argv.index("--sample") + 1])
|
||||
errs = []
|
||||
for slot, parts in cfg.SLOTS.items():
|
||||
for p in parts:
|
||||
if p["name"] != "none":
|
||||
errs += check_file(slot, p)
|
||||
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 cfg.SLOTS.values() for p in parts if p["name"] != "none")
|
||||
present = sum(1 for slot, parts in cfg.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:]))
|
||||
Reference in New Issue
Block a user