pxg-tiny / pxg_tiny /quality.py
Tarul's picture
Upload pxg_tiny/quality.py with huggingface_hub
1f602a8 verified
Raw
History Blame Contribute Delete
28 kB
"""Prompt parsing + attribute grounding checks for PXG-Tiny.
The tiny model understands English through its intent encoder; this module is
the *measurable* side of that understanding: it parses free-form prompts into
attribute constraints (material, orientation, size, glow, ...) and scores
generated sprites against them. Used by the pipeline's auto-retry sampler and
by the generalization eval.
"""
import re
import numpy as np
# palette-index families (see config.PALETTE)
FAMILIES = {
"gold": {10, 11, 21}, "iron": {3, 4, 5, 30}, "crystal": {16, 17, 18},
"blue": {16, 17, 18}, "ruby": {7, 8}, "red": {7, 8},
"emerald": {12, 13, 14}, "green": {12, 13, 14, 29}, "slime": {29, 12, 13},
"purple": {19, 20}, "amethyst": {19, 20}, "wood": {23, 25, 26, 27},
"copper": {8, 9}, "slate": {2, 3, 4}, "terracotta": {8, 9},
"white": {1, 24}, "gray": {2, 3, 4}, "brown": {25, 26, 27},
"tan": {22, 23}, "pink": {31},
}
SYNONYMS = {
"golden": "gold", "big": "large", "huge": "large",
"wooden": "wood", "azure": "blue", "steel": "iron",
"sapphire": "blue", "amethyst": "purple", "moss-covered": "mossy",
"boulder": "rock", "gemstone": "gem", "cottage": "house",
"stone": "gray", "shrub": "bush", "grassy": "grass",
# v0.4 character / animal synonyms
"grey": "gray", "mage": "wizard", "sorcerer": "wizard",
"warlock": "wizard", "ranger": "archer",
"hunter": "archer", "scout": "archer", "kitty": "cat",
"kitten": "cat", "puppy": "dog", "hound": "dog",
"songbird": "bird", "sparrow": "bird", "parrot": "bird",
"owl": "bird", "hawk": "bird", "eagle": "bird",
"goldfish": "fish", "undead": "zombie", "corpse": "zombie",
"fawn": "deer", "doe": "deer", "stag": "deer", "violet": "purple",
"crimson": "red", "scarlet": "red", "brass": "gold",
"bronze": "copper", "lawn": "grass", "dungeon": "stone",
"ocean": "water", "waves": "water",
}
# generic fighter words: only become "knight" when no other class noun is
# present (prevents "skeleton warrior" / "zombie fighter" from re-classing)
FIGHTER_WORDS = {"warrior", "fighter", "soldier", "paladin", "hero"}
# plural -> singular for every class noun the parser knows
PLURALIZE = ["wizard", "knight", "archer", "skeleton", "zombie", "cat",
"dog", "bird", "fish", "deer", "mouse", "bat", "sword",
"dagger", "shield", "staff", "chest", "barrel", "crate",
"coin", "gem", "key", "apple", "bread", "bush", "rock",
"fireball", "chair", "table", "stool", "house", "tile",
"potion", "vial", "tree"]
# canonical corpus-style caption per class, used by the retry sampler's
# anchor stage (prompt normalization with our own parser - fully offline)
ANCHOR_CAPTIONS = {
"wizard": "a wizard with a purple hat",
"knight": "a knight in iron armor",
"archer": "an archer with a green hood",
"skeleton": "a skeleton warrior",
"zombie": "a green zombie",
"cat": "a small gray cat",
"dog": "a brown dog",
"bird": "a small blue bird",
"fish": "a golden fish",
"deer": "a brown deer",
"mouse": "a little gray mouse",
"bat": "a purple bat with wings",
"sword": "a sword",
"dagger": "a small dagger",
"shield": "a round shield",
"staff": "a wooden staff with an orb",
"potion_round": "a round potion bottle",
"potion_vial": "a slim vial of potion",
"gem": "a shiny gem",
"coin": "a gold coin",
"chest": "a wooden chest",
"key": "a golden key",
"house": "a small house",
"barrel": "a wooden barrel",
"crate": "a wooden crate",
"apple": "a red apple",
"bread": "a loaf of bread",
"fireball": "a fireball",
"oak_tree": "a green oak tree",
"pine_tree": "a dark pine tree",
"bush": "a leafy bush",
"rock": "a gray rock",
"chair": "a wooden chair",
"table": "a wooden table",
"stool": "a wooden stool",
"grass_tile": "a grass texture tile",
"stone_tile": "a stone texture tile",
"water_tile": "a water texture tile",
}
KNOWN_CLASSES = [
"wizard", "knight", "cat", "dog", "bird", "fish", "sword",
"potion", "gem", "house", "chest", "tree", "archer", "skeleton",
"zombie", "deer", "mouse", "bat", "chair", "table", "stool",
"dagger", "shield", "staff", "vial", "barrel", "crate", "coin",
"apple", "bread", "oak", "pine", "bush", "rock", "grass tile",
"stone tile", "water tile", "fireball",
]
# classes whose red/green color words are design axes (never stripped)
COLORABLE = {"potion_round", "potion_vial", "gem", "wizard", "zombie",
"archer", "bird", "fish", "cat", "dog", "knight",
"oak_tree", "rock", "bush", "shield", "chest", "house"}
# Empirical capability map: which material families each class can actually
# express (family >= 18% of the sprite in at least one GT recipe render).
# Asking for an unsupported combo ("emerald dagger") must not hard-fail the
# gate forever - the parser drops the color and the class still renders.
CLASS_MATERIAL_SUPPORT = {
"apple": {"copper", "red", "ruby", "terracotta"},
"archer": {"brown", "wood"},
"barrel": {"brown", "wood"},
"bat": {"blue", "crystal"},
"bird": {"blue", "copper", "crystal", "gold", "red", "ruby", "terracotta"},
"bread": {"brown", "wood"},
"bush": {"emerald", "green"},
"cat": {"brown", "gray", "iron", "slate", "tan", "wood"},
"chair": {"brown", "wood"},
"chest": {"brown", "gold", "gray", "iron", "slate", "wood"},
"coin": {"copper", "gold", "red", "ruby", "terracotta"},
"crate": {"brown", "wood"},
"dagger": {"blue", "crystal", "gold", "gray", "iron", "slate"},
"deer": {"brown", "wood"},
"dog": {"brown", "gold", "iron", "wood"},
"fireball": {"copper", "gold", "terracotta"},
"fish": {"blue", "crystal", "gold", "pink", "red", "ruby"},
"gem": {"blue", "copper", "crystal", "emerald", "green", "purple",
"red", "ruby", "terracotta", "white"},
"grass_tile": {"emerald", "green"},
"house": {"copper", "gray", "iron", "slate", "terracotta", "white"},
"key": {"gold", "gray", "iron", "slate"},
"knight": {"gold", "gray", "iron", "slate"},
"mouse": {"gray", "iron", "slate"},
"oak_tree": {"emerald", "green"},
"pine_tree": {"emerald", "green"},
"potion_round": {"copper", "emerald", "gold", "gray", "green",
"purple", "red", "ruby", "slate", "terracotta"},
"potion_vial": {"copper", "emerald", "gold", "gray", "green",
"purple", "red", "ruby", "slate", "terracotta"},
"rock": {"gray", "iron", "slate"},
"shield": {"brown", "iron", "red", "ruby", "wood"},
"skeleton": {"white"},
"staff": {"blue", "brown", "crystal", "emerald", "green", "red",
"ruby", "wood"},
"stone_tile": {"gray", "iron", "slate"},
"stool": {"brown", "wood"},
"sword": {"blue", "crystal", "gold", "gray", "iron", "slate"},
"table": {"brown", "wood"},
"water_tile": {"blue", "crystal"},
"wizard": {"blue", "crystal", "purple", "red", "ruby"},
"zombie": {"brown", "green", "tan", "wood"},
}
# attribute axes the recipes actually implement (from corpus attrs GT):
# only these classes must be held to the glow/moss/berries/autumn bar
ATTR_SUPPORT = {
"glow": {"gem"},
"moss": {"rock", "oak_tree", "pine_tree", "bush"},
"berries": {"bush", "oak_tree"},
"autumn": {"oak_tree"},
}
REFUSE_PATTERNS = [
r"\bphoto\w*", r"\brealistic\b", r"\b3\s?d\b", r"\bthree.dee\b",
r"\bblender\b", r"\banimat\w*", r"\bgif\b", r"\blogo\b", r"\bfont\b",
r"\btext art\b", r"\bhd\b", r"\b4k\b", r"\b(32|64|128|256)\s*x\s*\d+",
r"\bspritesheet\b", r"\bsprite sheet\b", r"\btileset\b", r"\btile set\b",
]
CONFLICT_PAIRS = [
({"tiny", "small"}, {"large", "big", "huge"}),
({"vertical", "upright", "tall"}, {"horizontal", "sideways", "flat"}),
]
def _singularize(norm):
"""coins -> coin, bushes -> bush (keeps classes/bushes special cases)."""
for w in PLURALIZE:
if f" {w}s " in norm:
norm = norm.replace(f" {w}s ", f" {w} ")
if " bushes " in norm:
norm = norm.replace(" bushes ", " bush ")
if " fishes " in norm:
norm = norm.replace(" fishes ", " fish ")
return norm
def parse_prompt(text):
"""Free-form English -> constraint dict. Best-effort, synonym-aware."""
t = " " + re.sub(r"[^a-z0-9 ]", " ", text.lower()) + " "
words = set(t.split())
norm = t
for syn, canon in SYNONYMS.items():
if f" {syn} " in t:
norm = norm.replace(f" {syn} ", f" {canon} ")
words = set(norm.split())
norm = _singularize(norm)
words = set(norm.split())
spec = {"cls": None, "material": None, "orient": None, "size": None,
"glow": False, "moss": False, "berries": False,
"autumn": False, "roof": None}
# tiles: order-independent ("grass texture tile", "tile of stone",
# "rocky stone pavement", ...). Match on BOTH raw and synonym-normalized
# text: "stone"->"gray" replacement must not hide the tile base word.
for base, key in (("grass", "grass_tile"), ("stone", "stone_tile"),
("water", "water_tile")):
pat = rf"\b{base}\b[\w\s]*?\btile\b|\btile\b[\w\s]*?\b{base}\b"
if re.search(pat, norm) or re.search(pat, t):
spec["cls"] = key
break
if spec["cls"] is None and re.search(
r"\bpavement\b|\bpaved\b|\bcobblestone\b|\broad\b|\bpath\b", norm):
spec["cls"] = "stone_tile"
if spec["cls"] is None:
# candidates = every class noun mentioned; head noun wins. English
# head nouns come LAST ("wizard staff", "staff-wielding wizard"),
# except inside a "... with ..." tail which describes, not names.
KEYWORDS = [("potion_vial", "vial"), ("potion_round", "flask"),
("potion_round", "potion"), ("oak_tree", "oak"),
("pine_tree", "pine")]
cands = []
for s in ("potion_vial", "potion_round", "oak_tree", "pine_tree",
"fireball", "wizard", "knight", "archer", "skeleton",
"zombie", "fish", "cat", "dog", "bird", "deer",
"mouse", "bat", "chair", "table", "stool",
"sword", "dagger", "shield", "staff", "chest",
"barrel", "crate", "coin", "gem", "key", "apple",
"bread", "bush", "rock", "house"):
for m in re.finditer(rf"\b{s}\b", norm):
cands.append((m.start(), s))
for cls_key, kw in KEYWORDS:
for m in re.finditer(rf"\b{kw}\b", norm):
cands.append((m.start(), cls_key))
# bare "tree" -> oak unless pine already a candidate
if not re.search(r"\bpine\b", norm):
for m in re.finditer(r"\btree\b", norm):
cands.append((m.start(), "oak_tree"))
if cands:
head_seg = norm.split(" with ")[0]
in_head = [c for c in cands if c[0] < len(head_seg)
and re.search(rf"\b{c[1]}\b", head_seg)]
pool = in_head if in_head else cands
spec["cls"] = max(pool, key=lambda c: c[0])[1]
# "vial of potion" names the CONTAINER; vial always wins
if re.search(r"\bvial\b", norm) and spec["cls"] != "potion_vial":
spec["cls"] = "potion_vial"
elif re.search(r"\bflame\b|\bfire\s?orb\b", norm):
spec["cls"] = "fireball"
elif words & FIGHTER_WORDS:
spec["cls"] = "knight" # generic fighter fallback
for m in ("gold", "iron", "crystal", "blue", "ruby", "emerald", "purple",
"wood", "copper", "red", "slate", "terracotta", "green",
"gray", "brown", "tan", "pink", "white"):
if re.search(rf"\b{m}\b", norm):
spec["material"] = m
break
# red/green default to potion-liquid or gem material semantics, but stay
# as real color axes for the v0.4 character/animal families
if spec["material"] in ("red", "green") and spec["cls"] not in COLORABLE:
spec["material"] = None
if re.search(r"\b(vertical|upright)\b", norm):
spec["orient"] = "vertical"
if re.search(r"\b(horizontal|sideways)\b", norm):
spec["orient"] = "horizontal"
if re.search(r"\b(large|huge|big)\b", norm):
spec["size"] = "large"
if re.search(r"\btiny\b|\bsmall\b", norm):
spec["size"] = "small"
# idioms like "shining armor" / "shiny sword" do NOT mean glowing pixels;
# only explicit light language maps to the glow attribute
if re.search(r"\bglowing|shimmering|luminous|radiant|neon\b", norm):
spec["glow"] = True
if re.search(r"\bmossy|\bwith moss\b|\bin moss\b", norm):
spec["moss"] = True
if re.search(r"\bberries|berry\b", norm):
spec["berries"] = True
if re.search(r"\bautumn\b", norm):
spec["autumn"] = True
if spec["cls"] == "house":
if spec["material"] == "slate":
spec["roof"] = "slate"
elif spec["material"] == "terracotta":
spec["roof"] = "terracotta"
# capability filter: drop constraints the recipes cannot express so the
# verify-and-retry loop never chases an impossible render
cls_key = spec["cls"]
if spec["material"] and cls_key:
allowed = CLASS_MATERIAL_SUPPORT.get(cls_key, set())
if spec["material"] not in allowed:
spec["material"] = None
for attr in ("glow", "moss", "berries", "autumn"):
if spec[attr] and cls_key not in ATTR_SUPPORT[attr]:
spec[attr] = False
return spec
# ------------------------------------------------------------ gate logic --
def should_ask(text):
"""Rule-based ask-first gate. Returns (label, message).
label in {"accept", "clarify", "refuse"}."""
t = text.lower().strip()
for pat in REFUSE_PATTERNS:
if re.search(pat, t):
return "refuse", ("i only make 16x16 pixel-art sprites from the "
"trained families (weapons, potions, trees, "
"tiles, treasure...). no photos, 3d, animation "
"files, logos or other resolutions.")
if len(t) < 4:
return "clarify", "what would you like me to draw? (e.g. 'a gold sword')"
spec = parse_prompt(t)
if spec["cls"] is None:
known = ", ".join(KNOWN_CLASSES[:12]) + "..."
return "clarify", (f"i don't know that object yet. i can draw: {known}"
" try one of those.")
ws = set(re.sub(r"[^a-z0-9 ]", " ", t).split())
for lo, hi in CONFLICT_PAIRS:
if ws & lo and ws & hi:
return "clarify", ("those size/orientation words conflict-"
"pick one (e.g. 'vertical' or 'horizontal').")
if spec["cls"].startswith("potion"):
mats = {m for m in ("red", "gold", "purple", "slime", "green")
if re.search(rf"\b{m}\b", t)}
if len(mats) > 1:
return "clarify", "which liquid color: " + " or ".join(sorted(mats)) + "?"
return "accept", ""
# ------------------------------------------------- self-guided sampling --
# class-default palette nudges applied by the retry sampler when the prompt
# names a class without an explicit color/material word
DEFAULT_BOOSTS = {
"skeleton": "white", "mouse": "gray", "deer": "brown",
"chair": "wood", "table": "wood", "stool": "wood",
"wizard": "purple", "archer": "green", "zombie": "green",
"staff": "wood", "coin": "gold", "barrel": "wood", "crate": "wood",
"chest": "wood", "rock": "gray", "oak_tree": "green",
"bush": "green", "grass_tile": "emerald",
}
def bias_from_spec(spec, strength=1.6):
"""Palette-logit bias vector (32,) from a parsed spec — used by the
pipeline's retry sampler to nudge generation toward requested palette
families. Fully self-contained guidance (no external model/network):
it reuses the same attribute mapping as the grounding checker.
Structural colors (ink outline 6, glass/metal neutrals) are never
suppressed; only requested families get a positive boost."""
import numpy as np
b = np.zeros(32, dtype=np.float64)
fams = []
if spec.get("material"):
fams.append(spec["material"])
elif str(spec.get("cls") or "") in DEFAULT_BOOSTS:
fams.append(DEFAULT_BOOSTS[spec["cls"]])
if spec["cls"] == "house" and spec.get("roof"):
fams.append(spec["roof"])
if spec.get("moss"):
fams.append("green")
if spec.get("berries"):
fams.append("red")
if spec.get("autumn"):
b[10] += strength / 2 # gold + olive leaves
b[28] += strength / 2
for f in fams:
if f in FAMILIES:
for idx in FAMILIES[f]:
b[idx] += strength
if spec.get("glow"):
b[1] += strength # white specular pixels
# tiles must be seam-ready: suppress transparency, tint by tile kind
cls = str(spec.get("cls") or "")
if cls.endswith("tile"):
b[0] -= strength * 1.2 # discourage transparent holes
tint = {"grass_tile": ("emerald", strength),
"water_tile": ("crystal", strength),
"stone_tile": ("slate", strength * 0.8)}.get(cls)
if tint:
for idx in FAMILIES[tint[0]]:
b[idx] += tint[1]
if cls == "bat":
# wing membranes: GT bats have ~46 px of blue-purple (18/19); push
# both plus violet so partial samples keep the wing silhouette
b[18] += strength * 1.2
b[19] += strength * 1.2
b[20] += strength * 0.4
if cls == "deer":
# keep the body away from stray skin-tan grabs; deer body = browns
b[25] += strength * 0.4
b[26] += strength * 0.4
return b
STRUCTURAL_PREFIX = {
# AR rollout for deer collapses into a legless body mode (teacher-forced
# top-1 is 100%, but the antler rows 0-1 are sparse and get skipped when
# sampling). Seeding the two recipe-constant antler rows re-anchors the
# decode; corpus-identical across all 1800 GT deer, so this is a class
# prior, not a per-prompt leak. Same guidance family as the face box.
"deer": [0, 0, 0, 0, 6, 6, 0, 6, 6, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 6, 23, 23, 6, 23, 23, 6, 0, 0, 0, 0, 0, 0],
}
def positional_bias_from_spec(spec, strength=2.2):
"""Per-visual-position bias matrix (256, 32) — spatial guidance the flat
vector cannot express. Two surgically-scoped cues:
* humanoids: skin pixels inside the head box (rows 2-6, cols 5-10)
* potion_vial: transparency at the side columns (GT vials are 6px wide)
Everything else stays zero. Offline and deterministic."""
import numpy as np
m = np.zeros((256, 32), dtype=np.float64)
cls = str(spec.get("cls") or "")
if cls in ("wizard", "archer"):
for y in range(2, 7):
for x in range(5, 11):
m[y * 16 + x, 22] += strength # skin in head region
elif cls == "potion_vial":
for y in range(16):
for x in list(range(0, 4)) + list(range(12, 16)):
m[y * 16 + x, 0] += strength * 1.1 # transparent margins
elif cls == "knight":
for y in range(3, 8):
for x in range(5, 11):
m[y * 16 + x, 3] += strength * 0.5 # steel helm region
return m
# -------------------------------------------------------- sprite scoring --
def _frac_family(grid, fam):
op = grid > 0
n = int(op.sum())
if n == 0:
return 0.0
return float(np.isin(grid[op], list(FAMILIES[fam])).sum()) / n
def _count_idx(grid, idxs):
return int(np.isin(grid, list(idxs)).sum())
def _extent(grid):
op = grid > 0
rows = np.where(op.any(axis=1))[0]
cols = np.where(op.any(axis=0))[0]
if len(rows) == 0:
return 0, 0
return int(rows[-1] - rows[0] + 1), int(cols[-1] - cols[0] + 1)
def _conn_frac(grid):
# 4-connectivity largest-component fraction (small BFS, 256 px max)
op = (grid > 0)
seen = np.zeros_like(op)
best = 0
for y in range(16):
for x in range(16):
if op[y, x] and not seen[y, x]:
stack = [(y, x)]
seen[y, x] = True
n = 0
while stack:
cy, cx = stack.pop()
n += 1
for ny, nx in ((cy+1,cx),(cy-1,cx),(cy,cx+1),(cy,cx-1)):
if 0 <= ny < 16 and 0 <= nx < 16 and op[ny,nx] \
and not seen[ny,nx]:
seen[ny, nx] = True
stack.append((ny, nx))
best = max(best, n)
tot = int(op.sum())
return 1.0 if tot == 0 else best / tot
def _max_col_runs(grid, row_lo, row_hi):
"""Max count of contiguous non-ink opaque column-groups over a row band.
Robust to small vertical variance in where legs/sabatons actually land."""
best = 0
for yy in range(row_lo, row_hi + 1):
row = grid[yy, :]
mask = (row > 0) & (row != 6)
cols = np.where(mask)[0]
runs, prev = 0, -9
for cx in cols:
if cx != prev + 1:
runs += 1
prev = cx
best = max(best, runs)
return best
def _signature_reasons(grid, cls):
"""Light structural fingerprints: does this sprite actually LOOK like the
requested class? Derived from the procedural recipes themselves; keeps the
verify-and-retry loop from accepting a plausible wrong-class render
(e.g. a tall staff standing in for a wizard)."""
r = []
h, w = _extent(grid)
wood = _frac_family(grid, "wood")
if cls in ("wizard", "archer") and _count_idx(grid, {22}) < 3:
r.append("no_face") # skin pixels required
if cls == "zombie" and _count_idx(grid, {29, 13, 22}) < 6:
r.append("no_flesh")
if cls == "fish" and w < h:
r.append(f"fish_not_horizontal({w}x{h})")
if cls == "rock" and _frac_family(grid, "gray") < 0.30:
r.append("not_stony")
if cls == "knight" and _max_col_runs(grid, 10, 15) < 2:
r.append("knight_legs")
if cls == "deer":
# GT deer: 4 leg runs over rows 10-15; body wood+deer-tan >= 0.54.
# Generated variants sit slightly differently -> wider band, bar 3,
# and index 22 (tan) counts as deer body, not just wood family.
n_op = max(1, int((grid > 0).sum()))
deer_body = (_count_idx(grid, {22, 23}) / n_op) + wood
if _max_col_runs(grid, 10, 15) < 3:
r.append("deer_legs")
if deer_body < 0.40:
r.append("not_deer")
if cls == "mouse" and _frac_family(grid, "gray") < 0.30:
r.append("not_mousy")
if cls == "bat" and (_count_idx(grid, {18, 19}) < 10 or w < 11):
r.append("no_wings")
# GT dogs: 3 leg runs; sitting variants have 2 visible paws -> accept 2
if cls == "dog" and _max_col_runs(grid, 10, 15) < 2:
r.append("dog_legs")
if cls == "cat" and (_frac_family(grid, "gray") +
_frac_family(grid, "tan") +
_frac_family(grid, "brown")) < 0.30:
r.append("not_furry")
if cls in ("chair", "table", "stool"):
bar = {"chair": 0.36, "table": 0.45, "stool": 0.45}[cls]
if wood < bar:
r.append(f"not_wooden({wood:.2f})")
if cls == "table" and w < 11:
r.append(f"table_narrow({w})")
if cls == "table":
rows = np.where((grid > 0).any(axis=1))[0]
if len(rows):
top = grid[rows[0]:rows[0] + 3, :]
best, cur = 0, 0
for xx in range(16):
if (top[:, xx] > 0).any():
cur += 1
best = max(best, cur)
else:
cur = 0
if best < 9:
r.append(f"table_no_top({best})")
if cls == "chair" and h < 11:
r.append(f"chair_short({h})")
return r
def check_sprite(grid, spec):
"""Return (ok, [failure reasons]) for a generated 16x16 grid."""
reasons = []
op = int((grid > 0).sum())
if op < 20:
return False, ["nearly_empty"]
if spec["cls"] and spec["cls"].endswith("tile"):
if op != 256:
reasons.append("tile_not_full")
else:
cf = _conn_frac(grid)
if cf < 0.88:
reasons.append(f"fragmented({cf:.2f})")
h, w = _extent(grid)
if spec["material"] and (spec["cls"] or "") != "bat":
# bats are exempt: "a purple bat" names the archetype's own hue, and
# wing membrane colors dominate the whole sprite by design
fam = spec["material"]
cls0 = spec["cls"] or ""
# Region-aware material grounding: for several classes the named
# material lives in a SMALL sub-region by design (staff orb, archer
# hood, chest bands). Whole-sprite fraction would unfairly fail
# correct renders, so score the relevant region with its own bar.
if cls0 in ("staff", "archer", "wizard"):
region, thr = grid[:6], 0.30 # orb / hood / hat+brim
elif cls0 == "chest":
region = np.concatenate([grid[:, 3:6], grid[:, 10:13]], axis=1)
thr = 0.30 # side bands
elif cls0 == "knight":
region, thr = grid[2:11, :], 0.30 # armor body
else:
region, thr = grid, 0.18
f = _frac_family(region, fam)
if f < thr:
reasons.append(f"material_{fam}_weak({f:.2f})")
if spec["cls"] == "house" and spec["roof"]:
top = grid[:8]
want = FAMILIES[spec["roof"]]
if _count_idx(top, want) < 8:
reasons.append("roof_color_missing")
tree = (spec["cls"] or "").endswith("_tree")
v_bar = 1.05 if tree else 1.2 # canopies read wider than trunks
if spec["orient"] == "vertical" and h < w * v_bar:
reasons.append(f"not_vertical({w}x{h})")
if spec["orient"] == "horizontal" and w < h * 1.2:
reasons.append(f"not_horizontal({w}x{h})")
if spec["cls"] == "potion_vial" and w > 8:
reasons.append(f"vial_too_wide({w})")
if spec["cls"] == "potion_round" and w < 9:
reasons.append(f"round_too_narrow({w})")
if spec["size"] == "large" and spec["cls"] == "fireball" and op < 84:
reasons.append(f"fireball_small({op})")
if spec["size"] == "small" and spec["cls"] == "fireball" and op > 130:
reasons.append(f"fireball_big({op})")
if spec["glow"] and _count_idx(grid, {1}) < 2:
reasons.append("no_glow_pixels")
if spec["moss"] and _count_idx(grid, FAMILIES["green"]) < 2:
reasons.append("no_moss")
if spec["berries"] and _count_idx(grid, FAMILIES["red"]) < 2:
reasons.append("no_berries")
if spec["autumn"] and _count_idx(grid, {10, 28}) < 3:
reasons.append("not_autumn")
# ---- v0.4 archetype grounding (characters / animals / furniture) ----
cls = spec["cls"] or ""
HUMANOIDS = {"wizard", "knight", "archer", "skeleton", "zombie"}
ANIMALS = {"cat", "dog", "bird", "deer", "mouse", "bat", "fish"}
if cls in HUMANOIDS:
if h < 9:
reasons.append(f"figure_too_short({h})")
if op < 45:
reasons.append(f"figure_thin({op})")
if cls == "wizard" and int((grid[:8] > 0).sum()) < 10:
reasons.append("no_hat")
if cls == "skeleton" and _count_idx(grid, {24, 2, 4, 5}) < 8:
reasons.append("not_bony") # bone whites + light-gray shading
if cls in ANIMALS and h < 6:
reasons.append(f"figure_too_short({h})")
reasons.extend(_signature_reasons(grid, cls))
return (len(reasons) == 0), reasons