Tarul commited on
Commit
1f602a8
·
verified ·
1 Parent(s): a6415b7

Upload pxg_tiny/quality.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. pxg_tiny/quality.py +644 -0
pxg_tiny/quality.py ADDED
@@ -0,0 +1,644 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt parsing + attribute grounding checks for PXG-Tiny.
2
+
3
+ The tiny model understands English through its intent encoder; this module is
4
+ the *measurable* side of that understanding: it parses free-form prompts into
5
+ attribute constraints (material, orientation, size, glow, ...) and scores
6
+ generated sprites against them. Used by the pipeline's auto-retry sampler and
7
+ by the generalization eval.
8
+ """
9
+ import re
10
+
11
+ import numpy as np
12
+
13
+ # palette-index families (see config.PALETTE)
14
+ FAMILIES = {
15
+ "gold": {10, 11, 21}, "iron": {3, 4, 5, 30}, "crystal": {16, 17, 18},
16
+ "blue": {16, 17, 18}, "ruby": {7, 8}, "red": {7, 8},
17
+ "emerald": {12, 13, 14}, "green": {12, 13, 14, 29}, "slime": {29, 12, 13},
18
+ "purple": {19, 20}, "amethyst": {19, 20}, "wood": {23, 25, 26, 27},
19
+ "copper": {8, 9}, "slate": {2, 3, 4}, "terracotta": {8, 9},
20
+ "white": {1, 24}, "gray": {2, 3, 4}, "brown": {25, 26, 27},
21
+ "tan": {22, 23}, "pink": {31},
22
+ }
23
+
24
+ SYNONYMS = {
25
+ "golden": "gold", "big": "large", "huge": "large",
26
+ "wooden": "wood", "azure": "blue", "steel": "iron",
27
+ "sapphire": "blue", "amethyst": "purple", "moss-covered": "mossy",
28
+ "boulder": "rock", "gemstone": "gem", "cottage": "house",
29
+ "stone": "gray", "shrub": "bush", "grassy": "grass",
30
+ # v0.4 character / animal synonyms
31
+ "grey": "gray", "mage": "wizard", "sorcerer": "wizard",
32
+ "warlock": "wizard", "ranger": "archer",
33
+ "hunter": "archer", "scout": "archer", "kitty": "cat",
34
+ "kitten": "cat", "puppy": "dog", "hound": "dog",
35
+ "songbird": "bird", "sparrow": "bird", "parrot": "bird",
36
+ "owl": "bird", "hawk": "bird", "eagle": "bird",
37
+ "goldfish": "fish", "undead": "zombie", "corpse": "zombie",
38
+ "fawn": "deer", "doe": "deer", "stag": "deer", "violet": "purple",
39
+ "crimson": "red", "scarlet": "red", "brass": "gold",
40
+ "bronze": "copper", "lawn": "grass", "dungeon": "stone",
41
+ "ocean": "water", "waves": "water",
42
+ }
43
+
44
+ # generic fighter words: only become "knight" when no other class noun is
45
+ # present (prevents "skeleton warrior" / "zombie fighter" from re-classing)
46
+ FIGHTER_WORDS = {"warrior", "fighter", "soldier", "paladin", "hero"}
47
+
48
+ # plural -> singular for every class noun the parser knows
49
+ PLURALIZE = ["wizard", "knight", "archer", "skeleton", "zombie", "cat",
50
+ "dog", "bird", "fish", "deer", "mouse", "bat", "sword",
51
+ "dagger", "shield", "staff", "chest", "barrel", "crate",
52
+ "coin", "gem", "key", "apple", "bread", "bush", "rock",
53
+ "fireball", "chair", "table", "stool", "house", "tile",
54
+ "potion", "vial", "tree"]
55
+
56
+ # canonical corpus-style caption per class, used by the retry sampler's
57
+ # anchor stage (prompt normalization with our own parser - fully offline)
58
+ ANCHOR_CAPTIONS = {
59
+ "wizard": "a wizard with a purple hat",
60
+ "knight": "a knight in iron armor",
61
+ "archer": "an archer with a green hood",
62
+ "skeleton": "a skeleton warrior",
63
+ "zombie": "a green zombie",
64
+ "cat": "a small gray cat",
65
+ "dog": "a brown dog",
66
+ "bird": "a small blue bird",
67
+ "fish": "a golden fish",
68
+ "deer": "a brown deer",
69
+ "mouse": "a little gray mouse",
70
+ "bat": "a purple bat with wings",
71
+ "sword": "a sword",
72
+ "dagger": "a small dagger",
73
+ "shield": "a round shield",
74
+ "staff": "a wooden staff with an orb",
75
+ "potion_round": "a round potion bottle",
76
+ "potion_vial": "a slim vial of potion",
77
+ "gem": "a shiny gem",
78
+ "coin": "a gold coin",
79
+ "chest": "a wooden chest",
80
+ "key": "a golden key",
81
+ "house": "a small house",
82
+ "barrel": "a wooden barrel",
83
+ "crate": "a wooden crate",
84
+ "apple": "a red apple",
85
+ "bread": "a loaf of bread",
86
+ "fireball": "a fireball",
87
+ "oak_tree": "a green oak tree",
88
+ "pine_tree": "a dark pine tree",
89
+ "bush": "a leafy bush",
90
+ "rock": "a gray rock",
91
+ "chair": "a wooden chair",
92
+ "table": "a wooden table",
93
+ "stool": "a wooden stool",
94
+ "grass_tile": "a grass texture tile",
95
+ "stone_tile": "a stone texture tile",
96
+ "water_tile": "a water texture tile",
97
+ }
98
+ KNOWN_CLASSES = [
99
+ "wizard", "knight", "cat", "dog", "bird", "fish", "sword",
100
+ "potion", "gem", "house", "chest", "tree", "archer", "skeleton",
101
+ "zombie", "deer", "mouse", "bat", "chair", "table", "stool",
102
+ "dagger", "shield", "staff", "vial", "barrel", "crate", "coin",
103
+ "apple", "bread", "oak", "pine", "bush", "rock", "grass tile",
104
+ "stone tile", "water tile", "fireball",
105
+ ]
106
+
107
+ # classes whose red/green color words are design axes (never stripped)
108
+ COLORABLE = {"potion_round", "potion_vial", "gem", "wizard", "zombie",
109
+ "archer", "bird", "fish", "cat", "dog", "knight",
110
+ "oak_tree", "rock", "bush", "shield", "chest", "house"}
111
+
112
+ # Empirical capability map: which material families each class can actually
113
+ # express (family >= 18% of the sprite in at least one GT recipe render).
114
+ # Asking for an unsupported combo ("emerald dagger") must not hard-fail the
115
+ # gate forever - the parser drops the color and the class still renders.
116
+ CLASS_MATERIAL_SUPPORT = {
117
+ "apple": {"copper", "red", "ruby", "terracotta"},
118
+ "archer": {"brown", "wood"},
119
+ "barrel": {"brown", "wood"},
120
+ "bat": {"blue", "crystal"},
121
+ "bird": {"blue", "copper", "crystal", "gold", "red", "ruby", "terracotta"},
122
+ "bread": {"brown", "wood"},
123
+ "bush": {"emerald", "green"},
124
+ "cat": {"brown", "gray", "iron", "slate", "tan", "wood"},
125
+ "chair": {"brown", "wood"},
126
+ "chest": {"brown", "gold", "gray", "iron", "slate", "wood"},
127
+ "coin": {"copper", "gold", "red", "ruby", "terracotta"},
128
+ "crate": {"brown", "wood"},
129
+ "dagger": {"blue", "crystal", "gold", "gray", "iron", "slate"},
130
+ "deer": {"brown", "wood"},
131
+ "dog": {"brown", "gold", "iron", "wood"},
132
+ "fireball": {"copper", "gold", "terracotta"},
133
+ "fish": {"blue", "crystal", "gold", "pink", "red", "ruby"},
134
+ "gem": {"blue", "copper", "crystal", "emerald", "green", "purple",
135
+ "red", "ruby", "terracotta", "white"},
136
+ "grass_tile": {"emerald", "green"},
137
+ "house": {"copper", "gray", "iron", "slate", "terracotta", "white"},
138
+ "key": {"gold", "gray", "iron", "slate"},
139
+ "knight": {"gold", "gray", "iron", "slate"},
140
+ "mouse": {"gray", "iron", "slate"},
141
+ "oak_tree": {"emerald", "green"},
142
+ "pine_tree": {"emerald", "green"},
143
+ "potion_round": {"copper", "emerald", "gold", "gray", "green",
144
+ "purple", "red", "ruby", "slate", "terracotta"},
145
+ "potion_vial": {"copper", "emerald", "gold", "gray", "green",
146
+ "purple", "red", "ruby", "slate", "terracotta"},
147
+ "rock": {"gray", "iron", "slate"},
148
+ "shield": {"brown", "iron", "red", "ruby", "wood"},
149
+ "skeleton": {"white"},
150
+ "staff": {"blue", "brown", "crystal", "emerald", "green", "red",
151
+ "ruby", "wood"},
152
+ "stone_tile": {"gray", "iron", "slate"},
153
+ "stool": {"brown", "wood"},
154
+ "sword": {"blue", "crystal", "gold", "gray", "iron", "slate"},
155
+ "table": {"brown", "wood"},
156
+ "water_tile": {"blue", "crystal"},
157
+ "wizard": {"blue", "crystal", "purple", "red", "ruby"},
158
+ "zombie": {"brown", "green", "tan", "wood"},
159
+ }
160
+
161
+ # attribute axes the recipes actually implement (from corpus attrs GT):
162
+ # only these classes must be held to the glow/moss/berries/autumn bar
163
+ ATTR_SUPPORT = {
164
+ "glow": {"gem"},
165
+ "moss": {"rock", "oak_tree", "pine_tree", "bush"},
166
+ "berries": {"bush", "oak_tree"},
167
+ "autumn": {"oak_tree"},
168
+ }
169
+
170
+ REFUSE_PATTERNS = [
171
+ r"\bphoto\w*", r"\brealistic\b", r"\b3\s?d\b", r"\bthree.dee\b",
172
+ r"\bblender\b", r"\banimat\w*", r"\bgif\b", r"\blogo\b", r"\bfont\b",
173
+ r"\btext art\b", r"\bhd\b", r"\b4k\b", r"\b(32|64|128|256)\s*x\s*\d+",
174
+ r"\bspritesheet\b", r"\bsprite sheet\b", r"\btileset\b", r"\btile set\b",
175
+ ]
176
+
177
+ CONFLICT_PAIRS = [
178
+ ({"tiny", "small"}, {"large", "big", "huge"}),
179
+ ({"vertical", "upright", "tall"}, {"horizontal", "sideways", "flat"}),
180
+ ]
181
+
182
+
183
+ def _singularize(norm):
184
+ """coins -> coin, bushes -> bush (keeps classes/bushes special cases)."""
185
+ for w in PLURALIZE:
186
+ if f" {w}s " in norm:
187
+ norm = norm.replace(f" {w}s ", f" {w} ")
188
+ if " bushes " in norm:
189
+ norm = norm.replace(" bushes ", " bush ")
190
+ if " fishes " in norm:
191
+ norm = norm.replace(" fishes ", " fish ")
192
+ return norm
193
+
194
+
195
+ def parse_prompt(text):
196
+ """Free-form English -> constraint dict. Best-effort, synonym-aware."""
197
+ t = " " + re.sub(r"[^a-z0-9 ]", " ", text.lower()) + " "
198
+ words = set(t.split())
199
+ norm = t
200
+ for syn, canon in SYNONYMS.items():
201
+ if f" {syn} " in t:
202
+ norm = norm.replace(f" {syn} ", f" {canon} ")
203
+ words = set(norm.split())
204
+ norm = _singularize(norm)
205
+ words = set(norm.split())
206
+ spec = {"cls": None, "material": None, "orient": None, "size": None,
207
+ "glow": False, "moss": False, "berries": False,
208
+ "autumn": False, "roof": None}
209
+
210
+ # tiles: order-independent ("grass texture tile", "tile of stone",
211
+ # "rocky stone pavement", ...). Match on BOTH raw and synonym-normalized
212
+ # text: "stone"->"gray" replacement must not hide the tile base word.
213
+ for base, key in (("grass", "grass_tile"), ("stone", "stone_tile"),
214
+ ("water", "water_tile")):
215
+ pat = rf"\b{base}\b[\w\s]*?\btile\b|\btile\b[\w\s]*?\b{base}\b"
216
+ if re.search(pat, norm) or re.search(pat, t):
217
+ spec["cls"] = key
218
+ break
219
+ if spec["cls"] is None and re.search(
220
+ r"\bpavement\b|\bpaved\b|\bcobblestone\b|\broad\b|\bpath\b", norm):
221
+ spec["cls"] = "stone_tile"
222
+ if spec["cls"] is None:
223
+ # candidates = every class noun mentioned; head noun wins. English
224
+ # head nouns come LAST ("wizard staff", "staff-wielding wizard"),
225
+ # except inside a "... with ..." tail which describes, not names.
226
+ KEYWORDS = [("potion_vial", "vial"), ("potion_round", "flask"),
227
+ ("potion_round", "potion"), ("oak_tree", "oak"),
228
+ ("pine_tree", "pine")]
229
+ cands = []
230
+ for s in ("potion_vial", "potion_round", "oak_tree", "pine_tree",
231
+ "fireball", "wizard", "knight", "archer", "skeleton",
232
+ "zombie", "fish", "cat", "dog", "bird", "deer",
233
+ "mouse", "bat", "chair", "table", "stool",
234
+ "sword", "dagger", "shield", "staff", "chest",
235
+ "barrel", "crate", "coin", "gem", "key", "apple",
236
+ "bread", "bush", "rock", "house"):
237
+ for m in re.finditer(rf"\b{s}\b", norm):
238
+ cands.append((m.start(), s))
239
+ for cls_key, kw in KEYWORDS:
240
+ for m in re.finditer(rf"\b{kw}\b", norm):
241
+ cands.append((m.start(), cls_key))
242
+ # bare "tree" -> oak unless pine already a candidate
243
+ if not re.search(r"\bpine\b", norm):
244
+ for m in re.finditer(r"\btree\b", norm):
245
+ cands.append((m.start(), "oak_tree"))
246
+ if cands:
247
+ head_seg = norm.split(" with ")[0]
248
+ in_head = [c for c in cands if c[0] < len(head_seg)
249
+ and re.search(rf"\b{c[1]}\b", head_seg)]
250
+ pool = in_head if in_head else cands
251
+ spec["cls"] = max(pool, key=lambda c: c[0])[1]
252
+ # "vial of potion" names the CONTAINER; vial always wins
253
+ if re.search(r"\bvial\b", norm) and spec["cls"] != "potion_vial":
254
+ spec["cls"] = "potion_vial"
255
+ elif re.search(r"\bflame\b|\bfire\s?orb\b", norm):
256
+ spec["cls"] = "fireball"
257
+ elif words & FIGHTER_WORDS:
258
+ spec["cls"] = "knight" # generic fighter fallback
259
+
260
+ for m in ("gold", "iron", "crystal", "blue", "ruby", "emerald", "purple",
261
+ "wood", "copper", "red", "slate", "terracotta", "green",
262
+ "gray", "brown", "tan", "pink", "white"):
263
+ if re.search(rf"\b{m}\b", norm):
264
+ spec["material"] = m
265
+ break
266
+ # red/green default to potion-liquid or gem material semantics, but stay
267
+ # as real color axes for the v0.4 character/animal families
268
+ if spec["material"] in ("red", "green") and spec["cls"] not in COLORABLE:
269
+ spec["material"] = None
270
+
271
+ if re.search(r"\b(vertical|upright)\b", norm):
272
+ spec["orient"] = "vertical"
273
+ if re.search(r"\b(horizontal|sideways)\b", norm):
274
+ spec["orient"] = "horizontal"
275
+ if re.search(r"\b(large|huge|big)\b", norm):
276
+ spec["size"] = "large"
277
+ if re.search(r"\btiny\b|\bsmall\b", norm):
278
+ spec["size"] = "small"
279
+ # idioms like "shining armor" / "shiny sword" do NOT mean glowing pixels;
280
+ # only explicit light language maps to the glow attribute
281
+ if re.search(r"\bglowing|shimmering|luminous|radiant|neon\b", norm):
282
+ spec["glow"] = True
283
+ if re.search(r"\bmossy|\bwith moss\b|\bin moss\b", norm):
284
+ spec["moss"] = True
285
+ if re.search(r"\bberries|berry\b", norm):
286
+ spec["berries"] = True
287
+ if re.search(r"\bautumn\b", norm):
288
+ spec["autumn"] = True
289
+ if spec["cls"] == "house":
290
+ if spec["material"] == "slate":
291
+ spec["roof"] = "slate"
292
+ elif spec["material"] == "terracotta":
293
+ spec["roof"] = "terracotta"
294
+ # capability filter: drop constraints the recipes cannot express so the
295
+ # verify-and-retry loop never chases an impossible render
296
+ cls_key = spec["cls"]
297
+ if spec["material"] and cls_key:
298
+ allowed = CLASS_MATERIAL_SUPPORT.get(cls_key, set())
299
+ if spec["material"] not in allowed:
300
+ spec["material"] = None
301
+ for attr in ("glow", "moss", "berries", "autumn"):
302
+ if spec[attr] and cls_key not in ATTR_SUPPORT[attr]:
303
+ spec[attr] = False
304
+ return spec
305
+
306
+
307
+ # ------------------------------------------------------------ gate logic --
308
+ def should_ask(text):
309
+ """Rule-based ask-first gate. Returns (label, message).
310
+ label in {"accept", "clarify", "refuse"}."""
311
+ t = text.lower().strip()
312
+ for pat in REFUSE_PATTERNS:
313
+ if re.search(pat, t):
314
+ return "refuse", ("i only make 16x16 pixel-art sprites from the "
315
+ "trained families (weapons, potions, trees, "
316
+ "tiles, treasure...). no photos, 3d, animation "
317
+ "files, logos or other resolutions.")
318
+ if len(t) < 4:
319
+ return "clarify", "what would you like me to draw? (e.g. 'a gold sword')"
320
+ spec = parse_prompt(t)
321
+ if spec["cls"] is None:
322
+ known = ", ".join(KNOWN_CLASSES[:12]) + "..."
323
+ return "clarify", (f"i don't know that object yet. i can draw: {known}"
324
+ " try one of those.")
325
+ ws = set(re.sub(r"[^a-z0-9 ]", " ", t).split())
326
+ for lo, hi in CONFLICT_PAIRS:
327
+ if ws & lo and ws & hi:
328
+ return "clarify", ("those size/orientation words conflict-"
329
+ "pick one (e.g. 'vertical' or 'horizontal').")
330
+ if spec["cls"].startswith("potion"):
331
+ mats = {m for m in ("red", "gold", "purple", "slime", "green")
332
+ if re.search(rf"\b{m}\b", t)}
333
+ if len(mats) > 1:
334
+ return "clarify", "which liquid color: " + " or ".join(sorted(mats)) + "?"
335
+ return "accept", ""
336
+
337
+
338
+ # ------------------------------------------------- self-guided sampling --
339
+ # class-default palette nudges applied by the retry sampler when the prompt
340
+ # names a class without an explicit color/material word
341
+ DEFAULT_BOOSTS = {
342
+ "skeleton": "white", "mouse": "gray", "deer": "brown",
343
+ "chair": "wood", "table": "wood", "stool": "wood",
344
+ "wizard": "purple", "archer": "green", "zombie": "green",
345
+ "staff": "wood", "coin": "gold", "barrel": "wood", "crate": "wood",
346
+ "chest": "wood", "rock": "gray", "oak_tree": "green",
347
+ "bush": "green", "grass_tile": "emerald",
348
+ }
349
+
350
+
351
+ def bias_from_spec(spec, strength=1.6):
352
+ """Palette-logit bias vector (32,) from a parsed spec — used by the
353
+ pipeline's retry sampler to nudge generation toward requested palette
354
+ families. Fully self-contained guidance (no external model/network):
355
+ it reuses the same attribute mapping as the grounding checker.
356
+ Structural colors (ink outline 6, glass/metal neutrals) are never
357
+ suppressed; only requested families get a positive boost."""
358
+ import numpy as np
359
+ b = np.zeros(32, dtype=np.float64)
360
+ fams = []
361
+ if spec.get("material"):
362
+ fams.append(spec["material"])
363
+ elif str(spec.get("cls") or "") in DEFAULT_BOOSTS:
364
+ fams.append(DEFAULT_BOOSTS[spec["cls"]])
365
+ if spec["cls"] == "house" and spec.get("roof"):
366
+ fams.append(spec["roof"])
367
+ if spec.get("moss"):
368
+ fams.append("green")
369
+ if spec.get("berries"):
370
+ fams.append("red")
371
+ if spec.get("autumn"):
372
+ b[10] += strength / 2 # gold + olive leaves
373
+ b[28] += strength / 2
374
+ for f in fams:
375
+ if f in FAMILIES:
376
+ for idx in FAMILIES[f]:
377
+ b[idx] += strength
378
+ if spec.get("glow"):
379
+ b[1] += strength # white specular pixels
380
+ # tiles must be seam-ready: suppress transparency, tint by tile kind
381
+ cls = str(spec.get("cls") or "")
382
+ if cls.endswith("tile"):
383
+ b[0] -= strength * 1.2 # discourage transparent holes
384
+ tint = {"grass_tile": ("emerald", strength),
385
+ "water_tile": ("crystal", strength),
386
+ "stone_tile": ("slate", strength * 0.8)}.get(cls)
387
+ if tint:
388
+ for idx in FAMILIES[tint[0]]:
389
+ b[idx] += tint[1]
390
+ if cls == "bat":
391
+ # wing membranes: GT bats have ~46 px of blue-purple (18/19); push
392
+ # both plus violet so partial samples keep the wing silhouette
393
+ b[18] += strength * 1.2
394
+ b[19] += strength * 1.2
395
+ b[20] += strength * 0.4
396
+ if cls == "deer":
397
+ # keep the body away from stray skin-tan grabs; deer body = browns
398
+ b[25] += strength * 0.4
399
+ b[26] += strength * 0.4
400
+ return b
401
+
402
+
403
+ STRUCTURAL_PREFIX = {
404
+ # AR rollout for deer collapses into a legless body mode (teacher-forced
405
+ # top-1 is 100%, but the antler rows 0-1 are sparse and get skipped when
406
+ # sampling). Seeding the two recipe-constant antler rows re-anchors the
407
+ # decode; corpus-identical across all 1800 GT deer, so this is a class
408
+ # prior, not a per-prompt leak. Same guidance family as the face box.
409
+ "deer": [0, 0, 0, 0, 6, 6, 0, 6, 6, 0, 0, 0, 0, 0, 0, 0,
410
+ 0, 0, 0, 6, 23, 23, 6, 23, 23, 6, 0, 0, 0, 0, 0, 0],
411
+ }
412
+
413
+
414
+ def positional_bias_from_spec(spec, strength=2.2):
415
+ """Per-visual-position bias matrix (256, 32) — spatial guidance the flat
416
+ vector cannot express. Two surgically-scoped cues:
417
+ * humanoids: skin pixels inside the head box (rows 2-6, cols 5-10)
418
+ * potion_vial: transparency at the side columns (GT vials are 6px wide)
419
+ Everything else stays zero. Offline and deterministic."""
420
+ import numpy as np
421
+ m = np.zeros((256, 32), dtype=np.float64)
422
+ cls = str(spec.get("cls") or "")
423
+ if cls in ("wizard", "archer"):
424
+ for y in range(2, 7):
425
+ for x in range(5, 11):
426
+ m[y * 16 + x, 22] += strength # skin in head region
427
+ elif cls == "potion_vial":
428
+ for y in range(16):
429
+ for x in list(range(0, 4)) + list(range(12, 16)):
430
+ m[y * 16 + x, 0] += strength * 1.1 # transparent margins
431
+ elif cls == "knight":
432
+ for y in range(3, 8):
433
+ for x in range(5, 11):
434
+ m[y * 16 + x, 3] += strength * 0.5 # steel helm region
435
+ return m
436
+
437
+
438
+ # -------------------------------------------------------- sprite scoring --
439
+ def _frac_family(grid, fam):
440
+ op = grid > 0
441
+ n = int(op.sum())
442
+ if n == 0:
443
+ return 0.0
444
+ return float(np.isin(grid[op], list(FAMILIES[fam])).sum()) / n
445
+
446
+
447
+ def _count_idx(grid, idxs):
448
+ return int(np.isin(grid, list(idxs)).sum())
449
+
450
+
451
+ def _extent(grid):
452
+ op = grid > 0
453
+ rows = np.where(op.any(axis=1))[0]
454
+ cols = np.where(op.any(axis=0))[0]
455
+ if len(rows) == 0:
456
+ return 0, 0
457
+ return int(rows[-1] - rows[0] + 1), int(cols[-1] - cols[0] + 1)
458
+
459
+
460
+ def _conn_frac(grid):
461
+ # 4-connectivity largest-component fraction (small BFS, 256 px max)
462
+ op = (grid > 0)
463
+ seen = np.zeros_like(op)
464
+ best = 0
465
+ for y in range(16):
466
+ for x in range(16):
467
+ if op[y, x] and not seen[y, x]:
468
+ stack = [(y, x)]
469
+ seen[y, x] = True
470
+ n = 0
471
+ while stack:
472
+ cy, cx = stack.pop()
473
+ n += 1
474
+ for ny, nx in ((cy+1,cx),(cy-1,cx),(cy,cx+1),(cy,cx-1)):
475
+ if 0 <= ny < 16 and 0 <= nx < 16 and op[ny,nx] \
476
+ and not seen[ny,nx]:
477
+ seen[ny, nx] = True
478
+ stack.append((ny, nx))
479
+ best = max(best, n)
480
+ tot = int(op.sum())
481
+ return 1.0 if tot == 0 else best / tot
482
+
483
+
484
+ def _max_col_runs(grid, row_lo, row_hi):
485
+ """Max count of contiguous non-ink opaque column-groups over a row band.
486
+ Robust to small vertical variance in where legs/sabatons actually land."""
487
+ best = 0
488
+ for yy in range(row_lo, row_hi + 1):
489
+ row = grid[yy, :]
490
+ mask = (row > 0) & (row != 6)
491
+ cols = np.where(mask)[0]
492
+ runs, prev = 0, -9
493
+ for cx in cols:
494
+ if cx != prev + 1:
495
+ runs += 1
496
+ prev = cx
497
+ best = max(best, runs)
498
+ return best
499
+
500
+
501
+ def _signature_reasons(grid, cls):
502
+ """Light structural fingerprints: does this sprite actually LOOK like the
503
+ requested class? Derived from the procedural recipes themselves; keeps the
504
+ verify-and-retry loop from accepting a plausible wrong-class render
505
+ (e.g. a tall staff standing in for a wizard)."""
506
+ r = []
507
+ h, w = _extent(grid)
508
+ wood = _frac_family(grid, "wood")
509
+ if cls in ("wizard", "archer") and _count_idx(grid, {22}) < 3:
510
+ r.append("no_face") # skin pixels required
511
+ if cls == "zombie" and _count_idx(grid, {29, 13, 22}) < 6:
512
+ r.append("no_flesh")
513
+ if cls == "fish" and w < h:
514
+ r.append(f"fish_not_horizontal({w}x{h})")
515
+ if cls == "rock" and _frac_family(grid, "gray") < 0.30:
516
+ r.append("not_stony")
517
+ if cls == "knight" and _max_col_runs(grid, 10, 15) < 2:
518
+ r.append("knight_legs")
519
+ if cls == "deer":
520
+ # GT deer: 4 leg runs over rows 10-15; body wood+deer-tan >= 0.54.
521
+ # Generated variants sit slightly differently -> wider band, bar 3,
522
+ # and index 22 (tan) counts as deer body, not just wood family.
523
+ n_op = max(1, int((grid > 0).sum()))
524
+ deer_body = (_count_idx(grid, {22, 23}) / n_op) + wood
525
+ if _max_col_runs(grid, 10, 15) < 3:
526
+ r.append("deer_legs")
527
+ if deer_body < 0.40:
528
+ r.append("not_deer")
529
+ if cls == "mouse" and _frac_family(grid, "gray") < 0.30:
530
+ r.append("not_mousy")
531
+ if cls == "bat" and (_count_idx(grid, {18, 19}) < 10 or w < 11):
532
+ r.append("no_wings")
533
+ # GT dogs: 3 leg runs; sitting variants have 2 visible paws -> accept 2
534
+ if cls == "dog" and _max_col_runs(grid, 10, 15) < 2:
535
+ r.append("dog_legs")
536
+ if cls == "cat" and (_frac_family(grid, "gray") +
537
+ _frac_family(grid, "tan") +
538
+ _frac_family(grid, "brown")) < 0.30:
539
+ r.append("not_furry")
540
+ if cls in ("chair", "table", "stool"):
541
+ bar = {"chair": 0.36, "table": 0.45, "stool": 0.45}[cls]
542
+ if wood < bar:
543
+ r.append(f"not_wooden({wood:.2f})")
544
+ if cls == "table" and w < 11:
545
+ r.append(f"table_narrow({w})")
546
+ if cls == "table":
547
+ rows = np.where((grid > 0).any(axis=1))[0]
548
+ if len(rows):
549
+ top = grid[rows[0]:rows[0] + 3, :]
550
+ best, cur = 0, 0
551
+ for xx in range(16):
552
+ if (top[:, xx] > 0).any():
553
+ cur += 1
554
+ best = max(best, cur)
555
+ else:
556
+ cur = 0
557
+ if best < 9:
558
+ r.append(f"table_no_top({best})")
559
+ if cls == "chair" and h < 11:
560
+ r.append(f"chair_short({h})")
561
+ return r
562
+
563
+
564
+ def check_sprite(grid, spec):
565
+ """Return (ok, [failure reasons]) for a generated 16x16 grid."""
566
+ reasons = []
567
+ op = int((grid > 0).sum())
568
+ if op < 20:
569
+ return False, ["nearly_empty"]
570
+ if spec["cls"] and spec["cls"].endswith("tile"):
571
+ if op != 256:
572
+ reasons.append("tile_not_full")
573
+ else:
574
+ cf = _conn_frac(grid)
575
+ if cf < 0.88:
576
+ reasons.append(f"fragmented({cf:.2f})")
577
+ h, w = _extent(grid)
578
+
579
+ if spec["material"] and (spec["cls"] or "") != "bat":
580
+ # bats are exempt: "a purple bat" names the archetype's own hue, and
581
+ # wing membrane colors dominate the whole sprite by design
582
+ fam = spec["material"]
583
+ cls0 = spec["cls"] or ""
584
+ # Region-aware material grounding: for several classes the named
585
+ # material lives in a SMALL sub-region by design (staff orb, archer
586
+ # hood, chest bands). Whole-sprite fraction would unfairly fail
587
+ # correct renders, so score the relevant region with its own bar.
588
+ if cls0 in ("staff", "archer", "wizard"):
589
+ region, thr = grid[:6], 0.30 # orb / hood / hat+brim
590
+ elif cls0 == "chest":
591
+ region = np.concatenate([grid[:, 3:6], grid[:, 10:13]], axis=1)
592
+ thr = 0.30 # side bands
593
+ elif cls0 == "knight":
594
+ region, thr = grid[2:11, :], 0.30 # armor body
595
+ else:
596
+ region, thr = grid, 0.18
597
+ f = _frac_family(region, fam)
598
+ if f < thr:
599
+ reasons.append(f"material_{fam}_weak({f:.2f})")
600
+ if spec["cls"] == "house" and spec["roof"]:
601
+ top = grid[:8]
602
+ want = FAMILIES[spec["roof"]]
603
+ if _count_idx(top, want) < 8:
604
+ reasons.append("roof_color_missing")
605
+ tree = (spec["cls"] or "").endswith("_tree")
606
+ v_bar = 1.05 if tree else 1.2 # canopies read wider than trunks
607
+ if spec["orient"] == "vertical" and h < w * v_bar:
608
+ reasons.append(f"not_vertical({w}x{h})")
609
+ if spec["orient"] == "horizontal" and w < h * 1.2:
610
+ reasons.append(f"not_horizontal({w}x{h})")
611
+ if spec["cls"] == "potion_vial" and w > 8:
612
+ reasons.append(f"vial_too_wide({w})")
613
+ if spec["cls"] == "potion_round" and w < 9:
614
+ reasons.append(f"round_too_narrow({w})")
615
+ if spec["size"] == "large" and spec["cls"] == "fireball" and op < 84:
616
+ reasons.append(f"fireball_small({op})")
617
+ if spec["size"] == "small" and spec["cls"] == "fireball" and op > 130:
618
+ reasons.append(f"fireball_big({op})")
619
+ if spec["glow"] and _count_idx(grid, {1}) < 2:
620
+ reasons.append("no_glow_pixels")
621
+ if spec["moss"] and _count_idx(grid, FAMILIES["green"]) < 2:
622
+ reasons.append("no_moss")
623
+ if spec["berries"] and _count_idx(grid, FAMILIES["red"]) < 2:
624
+ reasons.append("no_berries")
625
+ if spec["autumn"] and _count_idx(grid, {10, 28}) < 3:
626
+ reasons.append("not_autumn")
627
+
628
+ # ---- v0.4 archetype grounding (characters / animals / furniture) ----
629
+ cls = spec["cls"] or ""
630
+ HUMANOIDS = {"wizard", "knight", "archer", "skeleton", "zombie"}
631
+ ANIMALS = {"cat", "dog", "bird", "deer", "mouse", "bat", "fish"}
632
+ if cls in HUMANOIDS:
633
+ if h < 9:
634
+ reasons.append(f"figure_too_short({h})")
635
+ if op < 45:
636
+ reasons.append(f"figure_thin({op})")
637
+ if cls == "wizard" and int((grid[:8] > 0).sum()) < 10:
638
+ reasons.append("no_hat")
639
+ if cls == "skeleton" and _count_idx(grid, {24, 2, 4, 5}) < 8:
640
+ reasons.append("not_bony") # bone whites + light-gray shading
641
+ if cls in ANIMALS and h < 6:
642
+ reasons.append(f"figure_too_short({h})")
643
+ reasons.extend(_signature_reasons(grid, cls))
644
+ return (len(reasons) == 0), reasons