Text-to-Image
English
numpy
machine-learning
deep-learning
generative-ai
text-2-image
image-generation
open-weights
model-weights
ai-art
pixel-art
game-development
gamedev
game-assets
asset-generator
sprite-generator
offline
tiny-model
numpy-runtime
int8-quantization
self-supervised
procedural-data
gpt
english-prompts
awesome-ai
| """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 | |