DJLougen commited on
Commit
6fbb543
·
verified ·
1 Parent(s): 72018aa

PixelGen2: compositional hand-weighted pixel-art generator + 4 scaling experiments

Browse files
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ out/
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 DJ Lougen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: pytorch
4
+ pipeline_tag: text-to-image
5
+ tags:
6
+ - pixel-art
7
+ - weight-space-programming
8
+ - hand-written-weights
9
+ - no-training
10
+ - tiny-model
11
+ - pico-8
12
+ - toy
13
+ language:
14
+ - en
15
+ ---
16
+
17
+ <div align="center">
18
+
19
+ # 🕹️ PixelGen2
20
+
21
+ **A text-to-pixel-art model with 6,747 parameters. Zero of them were trained.**
22
+
23
+ Every weight was typed in by hand. It draws 21,866 distinct sprites from 28 templates.
24
+
25
+ <img src="showcase2.png" alt="12 bodies × 8 accessories, two colours each, generated by PixelGen2" width="100%"/>
26
+
27
+ <sub>Every sprite above is one prompt: <code>"&lt;colour&gt; &lt;body&gt; with &lt;colour&gt; &lt;accessory&gt;"</code>. No gradients were harmed.</sub>
28
+
29
+ <br/>
30
+
31
+ ![params](https://img.shields.io/badge/parameters-6%2C747-ff004d?style=flat-square)
32
+ ![outputs](https://img.shields.io/badge/distinct%20sprites-21%2C866-29adff?style=flat-square)
33
+ ![training](https://img.shields.io/badge/training%20steps-0-00e436?style=flat-square)
34
+ ![palette](https://img.shields.io/badge/palette-PICO--8-ffec27?style=flat-square)
35
+ ![license](https://img.shields.io/badge/license-MIT-83769c?style=flat-square)
36
+
37
+ </div>
38
+
39
+ ---
40
+
41
+ ## What this is
42
+
43
+ A tiny PyTorch `nn.Module` that turns a prompt like `"red potion"` or `"purple ghost with gold crown"` into a 16×16 pixel-art sprite in the PICO-8 palette, upscaled to 128×128.
44
+
45
+ It's an experiment in **weight-space programming**, inspired by [this post](https://rikiyat.github.io/blog/weight-space-programming/): instead of training a network, you write a program and *compile it into weights*. Linear layers become lookup tables, argmax becomes a decision, a 1×1 conv becomes a palette. The model is a real `nn.Module` — you can call `.forward()`, save a `state_dict`, load it back — but no optimizer ever touched it.
46
+
47
+ There are two models in the repo:
48
+
49
+ | | `PixelGen` (v1) | `PixelGen2` (v2) |
50
+ |---|---|---|
51
+ | prompt | `"<colour> <body>"` | `"[small] <colour> <body> [with <colour> <accessory>]"` |
52
+ | parameters | 4,227 | 6,747 |
53
+ | hand-drawn templates | 21 bodies | 21 bodies + 7 accessories |
54
+ | distinct sprites (seed 0) | 189 | **21,866** |
55
+ | shading | fixed row bands | structural rim light (two 3×3 convs) |
56
+
57
+ v1 is the minimal version, published separately as [DJLougen/pixelgen](https://huggingface.co/DJLougen/pixelgen); v2 is the same thing plus four compositional layers, and is what the CLI uses by default. This repo contains both. Everything below was measured, not estimated — scripts in `experiments/`.
58
+
59
+ ## How it works
60
+
61
+ Nine steps for v1. Each is an ordinary tensor op; together they're a program.
62
+
63
+ ```
64
+ "red potion"
65
+ │
66
+ ▼
67
+ 1. bag of words prompt → count vector over a 138-word vocab
68
+ 2. class WTA Linear(138→21) → argmax → one-hot body [potion]
69
+ 3. shape bank one-hot selects a 16×8 half-template;
70
+ '?' cells flip on when seed-noise ≥ 0.5
71
+ 4. mirror half + horizontal flip → 16×16 body (always symmetric)
72
+ 5. outline 3×3 dilate(body) − body → outline pixels (palette index 1)
73
+ 6. shading rows 0–6 light, 7–10 mid, 11–15 dark
74
+ 7. ramp WTA Linear(138→9) → argmax → colour ramp [red]
75
+ 8. one-hot palette every pixel → one-hot over 16 PICO-8 colours
76
+ 9. palette conv Conv2d(16→3, 1×1) → RGB
77
+ │
78
+ ▼
79
+ 16×16 sprite → nearest-neighbour ×8 → 128×128 PNG
80
+ ```
81
+
82
+ v2 inserts four more fixed-weight layers (`pixelgen/compose.py`):
83
+
84
+ | layer | mechanism | params |
85
+ |---|---|---|
86
+ | **accessory** | `Linear(138→8)` WTA selects a 16×8 part (crown, halo, horns, helmet, antenna, wings, torch) overlaid on the body with `max`. `none` wins ties. | 1,112 |
87
+ | **second colour** | the prompt is split at `with`/`wearing`/`holding`/`and`; a second `Linear(138→9)` colours the accessory from the words after it. No colour word there → body colour wins via a 0.5 one-hot bonus. | 1,251 |
88
+ | **structural shading** | two 3×3 convs with a single tap each: a pixel is *light* if its up-left neighbour is empty, *dark* if its down-right neighbour is. Light from the top-left, for every shape, for 18 parameters. | 18 |
89
+ | **size** | `small`/`tiny`/`mini` → nearest-neighbour 0.75× resample of the body map, bottom-centred. | 139 |
90
+
91
+ The "generative" part is step 3: templates have `?` cells gated by per-seed noise, so the same prompt gives small variations across seeds:
92
+
93
+ <div align="center">
94
+ <img src="seeds.png" alt="blue invader, seeds 0–7" width="80%"/>
95
+ <sub><code>"blue invader"</code>, seeds 0 through 7</sub>
96
+ </div>
97
+
98
+ ## Numbers
99
+
100
+ | | v1 | v2 |
101
+ |---|---|---|
102
+ | Registered parameters | 4,227 | **6,747** |
103
+ | Non-trainable buffers (template / shade / ramp / part banks) | 6,171 | 7,333 |
104
+ | Total values | 10,398 (≈41.6 KB fp32) | 14,080 (≈56.3 KB fp32) |
105
+ | Training steps | **0** | **0** |
106
+ | Optimizers, backward passes, `requires_grad=True` in the repo | 0 (grep-verified) | 0 |
107
+ | Bodies / accessories / colour ramps | 21 / – / 9 | 21 / 7 / 9 |
108
+ | Distinct images at seed 0 | 189 of 189 prompts | 21,866 of 24,192 prompts |
109
+ | Output | 16×16 → 128×128 RGB, every pixel ∈ PICO-8 | same |
110
+ | `weights/pixelgen.pt` (v1) | 45.7 KB, SHA-256 `4c5dffd1…540eb`, identical across rebuilds | – |
111
+
112
+ <details>
113
+ <summary>Parameter breakdown (v2; v1 is the first six rows)</summary>
114
+
115
+ | Tensor | Shape | Count | What it is |
116
+ |---|---|---|---|
117
+ | `word_to_class.weight` | 21 × 138 | 2,898 | vocab → body scores (synonym table) |
118
+ | `word_to_class.bias` | 21 | 21 | default body (potion) |
119
+ | `word_to_ramp.weight` | 9 × 138 | 1,242 | vocab → colour-ramp scores |
120
+ | `word_to_ramp.bias` | 9 | 9 | default ramp (green) |
121
+ | `outline.weight` | 1×1×3×3 | 9 | dilation kernel |
122
+ | `palette_conv.weight` | 3×16×1×1 | 48 | one-hot colour → RGB (exact PICO-8 ÷ 255) |
123
+ | `word_to_acc.weight` / `.bias` | 8 × 138, 8 | 1,112 | vocab → accessory scores, `none` biased |
124
+ | `word_to_ramp2.weight` / `.bias` | 9 × 138, 9 | 1,251 | accessory-half vocab → ramp |
125
+ | `lit.weight`, `shadow.weight` | 1×1×3×3 ×2 | 18 | up-left tap, down-right tap |
126
+ | `word_to_size.weight` / `.bias` | 1 × 138, 1 | 139 | size words → small flag |
127
+ | **Parameters** | | **6,747** | |
128
+ | `on_bank` / `free_bank` | 21×16×8 ×2 | 5,376 | body templates (`#` solid, `?` noise-controlled) |
129
+ | `acc_bank` | 8×16×8 | 1,024 | accessory templates |
130
+ | `shade_bank` | 3×16×16 | 768 | row-band base shading |
131
+ | `ramp_table` | 9×3 | 27 | palette indices per ramp |
132
+ | `acc_mask_a` | 138 | 138 | body-class words never mean a hat in the body half |
133
+ | **Buffers** | | **7,333** | |
134
+
135
+ </details>
136
+
137
+ ## How to use
138
+
139
+ ```bash
140
+ git clone https://huggingface.co/DJLougen/pixelgen2 && cd pixelgen2
141
+ pip install torch numpy pillow # that's the whole dependency list
142
+ python generate.py "red potion" --out out/red_potion.png
143
+ python generate.py "green slime with gold crown" --seed 3 --scale 16
144
+ python generate.py "small angel skull" --out out/skull.png
145
+ python generate.py "blue invader" --v1 # original single-part model
146
+ ```
147
+
148
+ Or from Python:
149
+
150
+ ```python
151
+ from pixelgen.compose import PixelGen2
152
+
153
+ model = PixelGen2() # weights are built in __init__, no file needed
154
+ img = model.sprite("purple ghost with horns", seed=4, scale=8) # PIL.Image, 128×128
155
+ img.save("ghost.png")
156
+ ```
157
+
158
+ **Prompt grammar.** `[small] [colour] <body> [with [colour] <accessory>]`. Words are matched by a bag-of-words, so order doesn't matter within each half.
159
+
160
+ <details>
161
+ <summary>Vocabulary</summary>
162
+
163
+ **Bodies:** potion (bottle, flask, elixir, vial) · sword (blade, dagger) · shield (buckler) · ship (rocket, spaceship, starship) · invader (alien, monster) · slime (blob, jelly) · skull (bone, skeleton) · tree (pine, forest) · key · heart (love, life) · star (sparkle) · mushroom (fungus, toadstool, shroom) · ghost (spirit, phantom, spooky) · bomb (grenade, explosive) · coin (token, money, medal) · gem (diamond, jewel, crystal) · cherry (cherries, berry, fruit) · crown · bat (vampire) · cactus (desert, succulent) · robot (droid, android, mech)
164
+
165
+ **Accessories:** crown (king, queen, royal, crowned, prince, princess) · halo (angel, angelic, holy, saint) · horns (horned, devil, demon, evil) · helmet (knight, armor, armour, armored) · antenna (antennae, bug, martian) · wings (winged, fly, flying, bird, fairy, butterfly) · torch (torches, fire, flame, burning)
166
+
167
+ **Colours:** red (crimson, ruby) · blue (azure, sapphire) · green (emerald, lime) · yellow (gold, golden) · purple (violet, amethyst) · orange (amber) · pink (rose) · brown (wood, wooden) · white (grey, gray, silver, bone)
168
+
169
+ **Size:** small, tiny, mini, little. **Separators:** with, wearing, holding, and, plus.
170
+
171
+ `"crown"` in the body half is the crown object; after a separator it's a hat. `"bone"` is both a skull synonym and a white synonym, so `"bone"` alone is a white skull. `"alien"` is an invader body and `"martian"` is an antenna accessory, so `"martian alien"` is what you'd hope.
172
+
173
+ </details>
174
+
175
+ ## How far can you push it?
176
+
177
+ Four experiments, all in `experiments/` with the scripts that produced them. Full notes in [`experiments/README.md`](experiments/README.md).
178
+
179
+ **1. Adding a class is a pure append.** Freeze the 11-class model, add `mushroom`, diff. +400 values. Diffed by *index*, 84 old values changed; diffed by *meaning* (class name × word), **zero** — the raw diff was entirely a column permutation from a `sorted()` vocab. 17/17 old prompts bit-identical over 3 seeds. The same check still passes after +10 classes and the whole accessory vocabulary.
180
+
181
+ **2. Capacity is unbounded; addressing is quadratic.** Synthetic random classes, rebuilt and probed at each size:
182
+
183
+ | classes | params | dense layer share | isolated |
184
+ |---|---|---|---|
185
+ | 21 | 2,937 | 22% | ✓ |
186
+ | 121 | 51,537 | 57% | ✓ |
187
+ | 1,021 | 3.2M | 92% | ✓ |
188
+ | 10,021 | 302M | 99.1% | ✓ |
189
+
190
+ It never breaks — the original 21 classes stay bit-identical at 10k. But vocab grows with classes, so `word_to_class` is ≈3C² and 99.997% zeros. Under 100k params the dense design tops out near **170 classes**.
191
+
192
+ **3. Compressing the class layer costs semantics.** A binary code (`d = ⌈log₂C⌉`, words emit ±1 codes, score = `d − 2·hamming`) shrinks the class layer **528×** at 10k classes with no loss of isolation — but 61% of two-body-word prompts (`"sword shield"`) now resolve to a *third* class, vs 0% dense. The additive bag-of-words semantics were the thing that got compressed away.
193
+
194
+ **4. Composition is the lever.** v1 → v2: 1.6× params, **115× distinct outputs**. 781 distinct images per hand-drawn template instead of 9.
195
+
196
+ <div align="center">
197
+ <img src="experiments/shading_before_after.png" alt="row-band shading vs structural rim lighting" width="100%"/>
198
+ <sub>Top: v1 row bands. Bottom: v2 structural lighting, 18 params.</sub>
199
+ </div>
200
+
201
+ Quality, honestly: structural lighting helps blobby shapes (potion, heart, mushroom, tree, gem) and **hurts thin ones** (invader, bat, robot) — when every pixel is an edge, rim light becomes speckle. A fixed 3×3 kernel has no way to know a shape is too thin to light. That's the limit of hand-written rules without a learned prior, and exactly the kind of thing a few hundred trained parameters would fix.
202
+
203
+ ## Limitations
204
+
205
+ - **It only knows what was drawn.** 21 bodies, 7 accessories. Prompt for a dragon and you get a potion (the default). Capacity is unbounded; *content* is a human with a text editor.
206
+ - **Every shape is horizontally symmetric.** The mirror step makes 16×8 templates cheap; it also means `"torch"` is two torches and the key is a top-down key. Lighting (v2) is deliberately *not* symmetric.
207
+ - **Accessories sit at fixed rows.** A crown floats above short bodies; a helmet merges into tall ones. Fixing that needs per-body anchor points — more drawing, not more mechanism.
208
+ - **One accessory per sprite.** `"devil knight"` picks one by index order.
209
+ - **Variation is cosmetic.** Seeds only toggle the `?` cells — a few pixels per sprite.
210
+ - **Bag of words.** `"sword shield"` is a tie broken by index; `"not red"` is red; within each half, order is ignored.
211
+ - **Structural shading speckles thin shapes.** See above.
212
+ - **It's PyTorch for the bit, not for the need.** You could rewrite this in NumPy. Using `nn.Linear` and `nn.Conv2d` is the point of the exercise.
213
+ - **Art quality is programmer art**, drawn in a text editor by someone who is not a pixel artist.
214
+
215
+ ## Why bother?
216
+
217
+ Because it's a nice demonstration that a lot of what we call "a model" is just *structure*, and structure can be written directly. A linear layer with the right weights *is* a dictionary. A conv with the right kernel *is* an edge detector. Winner-take-all *is* an `if`. The blog post argues this is a useful lens for understanding what trained networks are doing; this repo is the version where you skip the training and just write the weights — and then push on it to see where hand-writing stops being enough.
218
+
219
+ Where it stops: addressing (quadratic), compression (breaks additivity), and quality rules at the edges (shading thin shapes). The natural next step is the hybrid — hand-write the ~95% that's structure, train only the small word→part map.
220
+
221
+ Also it's cool that it worked.
222
+
223
+ ## Repo contents
224
+
225
+ ```
226
+ pixelgen/
227
+ model.py PixelGen (v1) — all weights set in __init__
228
+ compose.py PixelGen2 (v2) — v1 + accessory, second colour, structural shading, size
229
+ templates.py 21 body half-templates ('#' solid, '?' noise, '.' empty)
230
+ parts.py 7 accessory half-templates
231
+ palette.py PICO-8 palette + 9 colour ramps
232
+ tokenizer.py vocab, synonyms, prompt splitting
233
+ weights.py save/load helpers
234
+ build.py writes weights/pixelgen.pt (v1, deterministic)
235
+ generate.py CLI (v2 default, --v1 for the original)
236
+ tests/ 16 property tests
237
+ experiments/ the four experiments above: scripts, frozen baselines, README
238
+ showcase2.png the grid at the top
239
+ seeds.png seed variation strip
240
+ ```
241
+
242
+ Tests: `python -m pytest tests -q` → 16 passed.
243
+
244
+ ## Citation
245
+
246
+ ```bibtex
247
+ @misc{pixelgen2_2025,
248
+ title = {PixelGen2: a compositional hand-weighted pixel-art generator},
249
+ author = {Lougen, D. J.},
250
+ year = {2025},
251
+ url = {https://huggingface.co/DJLougen/pixelgen2},
252
+ note = {Weight-space programming experiment; no parameters were trained.}
253
+ }
254
+ ```
255
+
256
+ Inspired by *[Weight-Space Programming](https://rikiyat.github.io/blog/weight-space-programming/)*.
build.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build PixelGen: write the hand-written weights to weights/pixelgen.pt."""
2
+
3
+ import sys
4
+
5
+ from pixelgen import weights
6
+
7
+
8
+ def main(argv=None):
9
+ path = weights.save()
10
+ print(f"wrote {path}")
11
+ model = weights.load(path)
12
+ print(f"loaded back: {sum(p.numel() for p in model.buffers())} buffer values")
13
+ return 0
14
+
15
+
16
+ if __name__ == "__main__":
17
+ sys.exit(main())
experiments/README.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Experiments: how far does hand-written capacity go?
2
+
3
+ Every number below was produced by a script in this folder. No training anywhere.
4
+
5
+ | script | question | answer |
6
+ |---|---|---|
7
+ | `add_class.py` | what changes when you add one class? | +400 values, 0 semantic edits to old weights, 0 behaviour change on old prompts |
8
+ | `scale_classes.py` | how many classes before it breaks? | it doesn't; 10,021 classes still bit-isolated — but the word→class matrix is O(C²) |
9
+ | `coded_classes.py` | can the class layer be compressed? | 528× at 10k classes via binary codes, at the cost of 61% multi-word interference |
10
+ | `count_outputs.py` | what does composition buy? | 21,866 distinct images from 28 templates; 115× output for 1.6× params |
11
+
12
+ ## 1. Adding one class is a pure append (in function space)
13
+
14
+ `before.pt` is the frozen 11-class model. After adding `mushroom`:
15
+
16
+ ```
17
+ tensor shapes on_bank (11,16,8)->(12,16,8) word_to_class (11,59)->(12,63) +400 values
18
+ raw index diff 84 old values "changed"
19
+ semantic diff 0 old (class,word) entries changed; shade/ramp/outline/palette identical
20
+ old prompts 17/17 bit-identical over 3 seeds; only mushroom-word prompts moved (potion -> mushroom)
21
+ ```
22
+
23
+ The 84-value raw diff is entirely a **column permutation**: the vocab is `sorted()`, so inserting `fungus` at column 11 shifts 48 of 59 old words right. Diffed by meaning, nothing moved. Lesson: if you want weight diffs to be legible, make index assignment append-only.
24
+
25
+ The same check still passes after +10 hand-drawn classes and the accessory vocabulary: v1 renders the original 11 classes bit-identically.
26
+
27
+ ## 2. Capacity is unbounded; addressing is quadratic
28
+
29
+ Synthetic random classes (3 nonsense words each), rebuilt and probed at each size:
30
+
31
+ ```
32
+ classes vocab params buffers MB dense% gen ms isolated
33
+ 21 95 2,937 6,171 0.04 21.9% 0.12 True
34
+ 121 395 51,537 31,771 0.33 57.4% 0.13 True
35
+ 1,021 3,095 3,188,937 262,171 13.80 91.6% 0.25 True
36
+ 3,021 9,095 27,560,937 774,171 113.34 97.0% 1.45 True
37
+ 10,021 30,095 301,862,937 2,566,171 1217.72 99.1% 11.95 True
38
+ ```
39
+
40
+ `isolated` = the 21 real classes and probe prompts (`red potion`, `dragon`, `""`, `bone`) are bit-identical to the 21-class model, and the newest synthetic class resolves by all its synonyms. It never fails.
41
+
42
+ But vocab grows with classes, so `word_to_class` is V×C ≈ 3C² — at 10k classes it's 99.1% of the model and 99.997% zeros. Under a 100k-parameter budget the dense design tops out around **170 classes**.
43
+
44
+ ## 3. Compressing the class layer buys size, costs semantics
45
+
46
+ Replace `Linear(V,C)` with a binary code: each word emits its class's d=⌈log₂C⌉-bit code as ±1, a `Linear(d,C)` of code rows scores `d − 2·hamming`.
47
+
48
+ ```
49
+ classes d dense params coded params ratio isolated interference
50
+ 21 5 2,016 601 3.4x True 61%
51
+ 121 7 47,916 3,733 12.8x True 61%
52
+ 1,021 10 3,161,016 42,181 74.9x True 61%
53
+ 3,021 12 27,479,016 148,413 185.2x True 61%
54
+ 10,021 14 301,592,016 571,645 527.6x True 61%
55
+ ```
56
+
57
+ Isolation survives. But `interference` — the share of `"wordA wordB"` prompts (over the 21 real classes) that resolve to a **third** class — jumps from 0% (dense; ties go to lower index) to 61%, flat across scale. The sum of two codes is nearest to some unrelated code. The bag-of-words layer's additive semantics were the thing being compressed away. Open question: do error-correcting or sparse k-hot codes recover 0% interference, and at what parameter cost?
58
+
59
+ ## 4. Composition: the lever that actually matters
60
+
61
+ `PixelGen2` (`pixelgen/compose.py`) adds four fixed-weight layers to the same model:
62
+
63
+ | layer | mechanism | params |
64
+ |---|---|---|
65
+ | accessory | `Linear(V,8)` WTA → 16×8 part overlaid with `max` | 1,112 + 1,162 buffer |
66
+ | second colour | `Linear(V,9)` over words after `with`/`wearing`/…; body ramp wins ties via a 0.5 one-hot bonus | 1,251 |
67
+ | structural shading | two 3×3 convs: light where up-left neighbour is empty, dark where down-right is | 18 |
68
+ | size | `Linear(V,1)` → nearest 0.75× resample, bottom-centred | 139 |
69
+
70
+ ```
71
+ PixelGen : 4,227 params + 6,171 buffers | 189 prompts -> 189 distinct images
72
+ PixelGen2: 6,747 params + 7,333 buffers | 24,192 prompts -> 21,866 distinct images
73
+ hand-drawn content: 21 bodies + 7 parts = 28 templates
74
+ distinct images per template: 9 -> 781
75
+ param growth 1.60x output growth 115.7x
76
+ ```
77
+
78
+ (The 2,326 duplicates are prompts whose accessory colour equals the body colour, or whose accessory is hidden behind the body.)
79
+
80
+ ### Quality, honestly
81
+
82
+ ![shading before/after](shading_before_after.png)
83
+
84
+ Top: row-band shading (v1). Bottom: structural rim lighting (v2), 18 parameters. It helps blobby shapes — potion, heart, mushroom, tree, gem get a readable highlight and shadow — and **hurts thin ones**: invader, bat, robot turn to speckle because every pixel is an edge. A fixed 3×3 kernel has no notion of "this shape is too thin to light". That's the limit of rule-based shading without a learned prior, and it's exactly the kind of thing a few hundred trained parameters would fix.
85
+
86
+ Accessories are placed at fixed rows, so a crown floats above short bodies and a helmet merges into tall ones. Fixing that properly needs per-body anchor points — more hand-written content, not more mechanism.
87
+
88
+ ## What this says about weight-space programming
89
+
90
+ - **Adding capacity is free and modular.** Hand-written weights are block-diagonal by construction; trained weights are not. Add-a-class is `dict.append`.
91
+ - **The expensive part is the index, not the content.** Dense lookup is O(C²); the content (templates) is O(C). Compression trades additivity for size.
92
+ - **Composition beats enumeration by two orders of magnitude**, and it's the only route to "many things" that doesn't scale a human's drawing time linearly.
93
+ - **Quality rules written by hand are brittle at the edges** — the shading result is the clearest example. The honest endpoint for pure weight-space programming is a very capable procedural generator with legible, auditable failure modes, and zero creativity outside what was drawn.
94
+ - The natural next step is the hybrid the blog post gestures at: hand-write the structure (parts, compositing, palette, transforms — ~95% of params), and train only the small word→part mapping, so new vocabulary becomes learnable while everything downstream stays readable.
experiments/add_class.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measure exactly how PixelGen changed after adding one class.
2
+
3
+ Compares the frozen before-state (experiments/before.pt, before_outputs.npz,
4
+ before_meta.json) against the live model built from current source.
5
+
6
+ Reports:
7
+ 1. tensor shapes before -> after
8
+ 2. raw parameter diff (how many values differ by index)
9
+ 3. semantic diff (how many values differ once rows/cols are aligned by
10
+ class name / word, i.e. the change that actually matters)
11
+ 4. vocab index remap (sorted vocab means new words shift old columns)
12
+ 5. output equivalence on every pre-existing prompt (bit-exact or not)
13
+ 6. what the new prompts do now vs before
14
+ """
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import torch
21
+
22
+ from pixelgen.model import PixelGen
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+ before_sd = torch.load(HERE / "before.pt", map_location="cpu", weights_only=True)
26
+ before_out = np.load(HERE / "before_outputs.npz")
27
+ meta = json.load(open(HERE / "before_meta.json"))
28
+ b_vocab, b_classes, prompts = meta["vocab"], meta["classes"], meta["prompts"]
29
+
30
+ m = PixelGen()
31
+ after_sd = m.state_dict()
32
+ a_vocab, a_classes = m.vocab, m.classes
33
+
34
+ new_classes = [c for c in a_classes if c not in b_classes]
35
+ new_words = [w for w in a_vocab if w not in b_vocab]
36
+ print("=" * 70)
37
+ print(f"new classes: {new_classes} new words: {new_words}")
38
+ print(f"vocab {len(b_vocab)} -> {len(a_vocab)} classes {len(b_classes)} -> {len(a_classes)}")
39
+
40
+ # 1. shapes -------------------------------------------------------------
41
+ print("\n[1] tensor shapes")
42
+ tot_b = tot_a = 0
43
+ for k in after_sd:
44
+ sb, sa = tuple(before_sd[k].shape), tuple(after_sd[k].shape)
45
+ tot_b += before_sd[k].numel(); tot_a += after_sd[k].numel()
46
+ flag = " <-- grew" if sb != sa else ""
47
+ print(f" {k:24} {str(sb):>16} -> {str(sa):<16}{flag}")
48
+ print(f" total values {tot_b} -> {tot_a} (+{tot_a - tot_b})")
49
+
50
+ # 2. raw diff ------------------------------------------------------------
51
+ print("\n[2] raw diff by index (naive view)")
52
+ for k in after_sd:
53
+ b, a = before_sd[k], after_sd[k]
54
+ if b.shape == a.shape:
55
+ n = int((b != a).sum())
56
+ print(f" {k:24} {n:5d} / {a.numel()} values differ")
57
+ else:
58
+ # compare overlapping prefix region
59
+ sl = tuple(slice(0, s) for s in b.shape)
60
+ n = int((b != a[sl]).sum())
61
+ print(f" {k:24} {n:5d} / {b.numel()} values differ in the old-shaped prefix (+{a.numel()-b.numel()} new)")
62
+
63
+ # 3. semantic diff (align by names) ---------------------------------------
64
+ print("\n[3] semantic diff (rows/cols aligned by class name / word)")
65
+ def aligned_linear(name):
66
+ b, a = before_sd[f"{name}.weight"], after_sd[f"{name}.weight"]
67
+ rows = b_classes if name == "word_to_class" else m.ramps
68
+ changed = []
69
+ for ri, r in enumerate(rows):
70
+ for w in b_vocab:
71
+ bv = b[ri, b_vocab[w]].item()
72
+ av = a[a_classes.index(r) if name == "word_to_class" else ri, a_vocab[w]].item()
73
+ if bv != av:
74
+ changed.append((r, w, bv, av))
75
+ return changed
76
+ for name in ("word_to_class", "word_to_ramp"):
77
+ ch = aligned_linear(name)
78
+ print(f" {name:16} old (row,word) entries changed: {len(ch)} {ch[:5]}")
79
+ bb, ab = before_sd["word_to_class.bias"], after_sd["word_to_class.bias"]
80
+ print(" word_to_class.bias old-class entries changed:",
81
+ sum(bb[i].item() != ab[a_classes.index(c)].item() for i, c in enumerate(b_classes)))
82
+ for bank in ("on_bank", "free_bank"):
83
+ b, a = before_sd[bank], after_sd[bank]
84
+ n = sum(int((b[i] != a[a_classes.index(c)]).sum()) for i, c in enumerate(b_classes))
85
+ print(f" {bank:16} old-class template cells changed: {n}")
86
+ for k in ("shade_bank", "ramp_table", "outline.weight", "palette_conv.weight"):
87
+ print(f" {k:16} identical: {torch.equal(before_sd[k], after_sd[k])}")
88
+
89
+ # 4. vocab remap ----------------------------------------------------------
90
+ print("\n[4] vocab index remap caused by sorted insertion")
91
+ moved = [(w, b_vocab[w], a_vocab[w]) for w in b_vocab if b_vocab[w] != a_vocab[w]]
92
+ print(f" {len(moved)} of {len(b_vocab)} old words changed column index")
93
+ print(" first few:", moved[:6])
94
+ print(" new word columns:", {w: a_vocab[w] for w in new_words})
95
+
96
+ # 5. output equivalence ---------------------------------------------------
97
+ print("\n[5] output equivalence on pre-existing prompts (3 seeds each)")
98
+ same = diff = 0
99
+ changed_prompts = []
100
+ for i, p in enumerate(prompts):
101
+ b = before_out[f"p{i}"]
102
+ a = m.generate([p] * 3, [0, 1, 2], scale=1)
103
+ if np.array_equal(a, b):
104
+ same += 1
105
+ else:
106
+ diff += 1
107
+ changed_prompts.append(p)
108
+ print(f" identical: {same} changed: {diff} -> {changed_prompts}")
109
+
110
+ # 6. new prompt behaviour ---------------------------------------------------
111
+ print("\n[6] what the new-class prompts resolve to now")
112
+ ref = {c: (m.generate([c], [0], scale=1)[0].sum(-1) > 0) for c in a_classes}
113
+ for p in ["mushroom", "red mushroom", "purple fungus", "toadstool", "dragon", ""]:
114
+ mask = m.generate([p], [0], scale=1)[0].sum(-1) > 0
115
+ cls = [c for c, r in ref.items() if np.array_equal(r, mask)]
116
+ i = prompts.index(p) if p in prompts else None
117
+ was = "n/a"
118
+ if i is not None:
119
+ bm = before_out[f"p{i}"][0].sum(-1) > 0
120
+ was = [c for c, r in ref.items() if np.array_equal(r, bm)]
121
+ print(f" {p!r:18} before -> {was} after -> {cls}")
122
+
123
+ # image ---------------------------------------------------------------------
124
+ from PIL import Image
125
+ S = 6; cell = 16 * S; pad = 4
126
+ rows = [("before", b_classes), ("after", a_classes)]
127
+ W = max(len(c) for _, c in rows) * (cell + pad) + pad
128
+ img = Image.new("RGB", (W, 2 * (cell + pad) + pad), (29, 43, 83))
129
+ for r, (_, classes) in enumerate(rows):
130
+ for c, cls in enumerate(classes):
131
+ img.paste(Image.fromarray(m.generate([cls], [0], scale=S)[0]), (pad + c * (cell + pad), pad + r * (cell + pad)))
132
+ img.save(HERE / "before_after.png")
133
+ print(f"\nwrote {HERE/'before_after.png'} (top: old classes rendered by new model; bottom: all classes)")
experiments/before.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:175cc5249fba8c756ea019f418f4aa0a9633017ffad7f238b863865758c49b80
3
+ size 23573
experiments/before_after.png ADDED
experiments/before_meta.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"prompts": ["potion", "sword", "shield", "ship", "invader", "slime", "skull", "tree", "key", "heart", "star", "red potion", "blue sword", "bone", "dragon", "golden key", "mushroom", "red mushroom", "purple fungus", ""], "vocab": {"<pad>": 0, "alien": 1, "blade": 2, "blob": 3, "bone": 4, "bottle": 5, "buckler": 6, "dagger": 7, "elixir": 8, "flask": 9, "forest": 10, "heart": 11, "invader": 12, "jelly": 13, "key": 14, "life": 15, "love": 16, "monster": 17, "pine": 18, "potion": 19, "rocket": 20, "shield": 21, "ship": 22, "skeleton": 23, "skull": 24, "slime": 25, "spaceship": 26, "sparkle": 27, "star": 28, "starship": 29, "sword": 30, "tree": 31, "vial": 32, "amber": 33, "amethyst": 34, "azure": 35, "blue": 36, "brown": 37, "crimson": 38, "emerald": 39, "gold": 40, "golden": 41, "gray": 42, "green": 43, "grey": 44, "lime": 45, "orange": 46, "pink": 47, "purple": 48, "red": 49, "rose": 50, "ruby": 51, "sapphire": 52, "silver": 53, "violet": 54, "white": 55, "wood": 56, "wooden": 57, "yellow": 58}, "classes": ["potion", "sword", "shield", "ship", "invader", "slime", "skull", "tree", "key", "heart", "star"]}
experiments/before_outputs.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da424b942dcf80f093f8def15cb0fa9e9f17b7090aa7aecff80d1bb41f46fe05
3
+ size 50842
experiments/coded_classes.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Replace the dense V x C word->class matrix with a hand-written binary code.
2
+
3
+ word -> d-bit code of its class, emitted as +-1 Linear(V, d, bias=False)
4
+ class rows = +-1 codes; score_c = d - 2*hamming Linear(d, C) (+0.5 bias on default)
5
+
6
+ Params drop from V*C to V*d + d*C. Price: multi-word prompts sum two codes
7
+ and can land on a third class. Both are measured here.
8
+ """
9
+
10
+ import itertools
11
+ import sys
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn as nn
16
+
17
+ sys.path.insert(0, ".")
18
+ from experiments.scale_classes import build, REAL_CLASSES, REAL_SYN # noqa: E402
19
+ import pixelgen.model as M # noqa: E402
20
+
21
+
22
+ def code_layer(m):
23
+ C, V = len(m.classes), len(m.vocab)
24
+ d = max(1, int(np.ceil(np.log2(C))))
25
+ codes = torch.tensor([[(ci >> b) & 1 for b in range(d)] for ci in range(C)], dtype=torch.float32) * 2 - 1
26
+ enc = nn.Linear(V, d, bias=False); dec = nn.Linear(d, C)
27
+ with torch.no_grad():
28
+ enc.weight.zero_(); dec.bias.zero_()
29
+ for ci, cls in enumerate(m.classes):
30
+ for w in M.CLASS_SYNONYMS[cls]:
31
+ enc.weight[:, m.vocab[w]] = codes[ci]
32
+ dec.weight.copy_(codes)
33
+ dec.bias[m.classes.index(M.DEFAULT_CLASS)] = 0.5
34
+ seq = nn.Sequential(enc, dec)
35
+ for p in seq.parameters(): p.requires_grad_(False)
36
+ return seq, d
37
+
38
+
39
+ def interference(m):
40
+ """Fraction of 2-class-word prompts whose argmax is neither word's class."""
41
+ words = [(c, M.CLASS_SYNONYMS[c][0]) for c in REAL_CLASSES]
42
+ bad = 0; n = 0
43
+ for (ca, wa), (cb, wb) in itertools.combinations(words, 2):
44
+ toks = m.tokenize(f"{wa} {wb}")
45
+ bag = torch.zeros(1, len(m.vocab)); bag[0, toks] += 1 # noqa (tokenize returns ids)
46
+ cls = m.classes[m.word_to_class(bag).argmax(-1).item()]
47
+ bad += cls not in (ca, cb); n += 1
48
+ return bad / n
49
+
50
+
51
+ Ns = [int(a) for a in sys.argv[1:]] or [0, 100, 1000, 3000, 10000]
52
+ print(f"{'classes':>8} {'d':>3} {'dense params':>13} {'coded params':>13} {'ratio':>7} {'isolated':>8} {'interf':>7}")
53
+ for n in Ns:
54
+ m, _ = build(n)
55
+ ref = {p: m.generate([p] * 2, [0, 1], scale=1) for p in REAL_CLASSES + ["red potion", "", "dragon", "bone"]}
56
+ dense_p = sum(p.numel() for p in m.word_to_class.parameters())
57
+ seq, d = code_layer(m)
58
+ m.word_to_class = seq
59
+ coded_p = sum(p.numel() for p in seq.parameters())
60
+ ok = all(np.array_equal(m.generate([p] * 2, [0, 1], scale=1), ref[p]) for p in ref)
61
+ if n:
62
+ nm = f"syn{n-1:05d}"
63
+ ok = ok and np.array_equal(m.generate([nm + "b"], [0], scale=1)[0], m.generate([nm], [0], scale=1)[0])
64
+ print(f"{len(m.classes):>8} {d:>3} {dense_p:>13,} {coded_p:>13,} {dense_p/coded_p:>6.1f}x {str(ok):>8} {interference(m):>6.0%}")
65
+
66
+ print("\n(interf = share of 'wordA wordB' prompts over the 21 real classes that resolve to a THIRD class;")
67
+ print(" dense layer: 0% by construction, ties go to lower index)")
experiments/count_outputs.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enumerate PixelGen vs PixelGen2 output space at seed 0 and count distinct images."""
2
+
3
+ import itertools
4
+ import sys
5
+ import time
6
+
7
+ import numpy as np
8
+
9
+ sys.path.insert(0, ".")
10
+ from pixelgen.compose import PixelGen2 # noqa: E402
11
+ from pixelgen.model import PixelGen # noqa: E402
12
+ from pixelgen.palette import RAMP_NAMES # noqa: E402
13
+ from pixelgen.parts import ACCESSORIES # noqa: E402
14
+ from pixelgen.templates import CLASSES # noqa: E402
15
+ from pixelgen.tokenizer import ACCESSORY_SYNONYMS # noqa: E402
16
+
17
+
18
+ def distinct(model, prompts, bs=512):
19
+ seen = set()
20
+ for i in range(0, len(prompts), bs):
21
+ chunk = prompts[i:i + bs]
22
+ imgs = model.generate(chunk, [0] * len(chunk), scale=1)
23
+ for im in imgs:
24
+ seen.add(im.tobytes())
25
+ return len(seen)
26
+
27
+
28
+ m1, m2 = PixelGen(), PixelGen2()
29
+ p1 = sum(p.numel() for p in m1.parameters()); b1 = sum(b.numel() for b in m1.buffers())
30
+ p2 = sum(p.numel() for p in m2.parameters()); b2 = sum(b.numel() for b in m2.buffers())
31
+
32
+ base = [f"{r} {c}" for c in CLASSES for r in RAMP_NAMES]
33
+ t = time.perf_counter(); d1 = distinct(m1, base)
34
+ print(f"PixelGen : {p1:,} params + {b1:,} buffers | {len(base):,} prompts -> {d1:,} distinct images ({time.perf_counter()-t:.1f}s)")
35
+
36
+ acc_word = {a: ws[0] for a, ws in ACCESSORY_SYNONYMS.items() if ws}
37
+ comp = []
38
+ for c, r, size in itertools.product(CLASSES, RAMP_NAMES, ["", "small "]):
39
+ comp.append(f"{size}{r} {c}")
40
+ for a, r2 in itertools.product(acc_word, RAMP_NAMES):
41
+ comp.append(f"{size}{r} {c} with {r2} {acc_word[a]}")
42
+ t = time.perf_counter(); d2 = distinct(m2, comp)
43
+ print(f"PixelGen2: {p2:,} params + {b2:,} buffers | {len(comp):,} prompts -> {d2:,} distinct images ({time.perf_counter()-t:.1f}s)")
44
+ print(f"\nhand-drawn content: {len(CLASSES)} bodies + {len(ACCESSORIES)-1} parts = {len(CLASSES)+len(ACCESSORIES)-1} templates")
45
+ print(f"distinct images per template: PixelGen {d1/len(CLASSES):.0f} PixelGen2 {d2/(len(CLASSES)+len(ACCESSORIES)-1):.0f}")
46
+ print(f"param growth {p2/p1:.2f}x output growth {d2/d1:.1f}x")
experiments/scale_classes.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """How many classes can PixelGen absorb before something gives?
2
+
3
+ Synthesises N extra classes (random valid 16x8 templates + 3 unique nonsense
4
+ words each), rebuilds the model at each N, and measures:
5
+
6
+ params / buffers / bytes - the capacity cost
7
+ build time - cost of "compiling" the weights
8
+ generate latency (1 prompt) - inference cost
9
+ isolation - the real 21 classes still bit-identical?
10
+ dense-layer share - how much of the model is the word->class matrix
11
+
12
+ Nothing is trained. The synthetic classes exist only in this process.
13
+ """
14
+
15
+ import random
16
+ import sys
17
+ import time
18
+
19
+ import numpy as np
20
+ import torch
21
+
22
+ import pixelgen.model as M
23
+ import pixelgen.templates as T
24
+ import pixelgen.tokenizer as K
25
+
26
+ REAL_TEMPLATES = dict(T.TEMPLATES)
27
+ REAL_SYN = dict(K.CLASS_SYNONYMS)
28
+ REAL_CLASSES = list(REAL_TEMPLATES)
29
+
30
+
31
+ def random_template(rng):
32
+ rows = ["." * 8, "." * 8]
33
+ for _ in range(12):
34
+ row = ".." + "".join(rng.choice("##.?") for _ in range(6))
35
+ rows.append(row)
36
+ rows += ["." * 8, "." * 8]
37
+ on = sum(r.count("#") for r in rows); free = sum(r.count("?") for r in rows)
38
+ if on < 8 or on + free > 60:
39
+ return random_template(rng)
40
+ return rows
41
+
42
+
43
+ def build(n_extra, seed=0):
44
+ rng = random.Random(seed)
45
+ tmpl = dict(REAL_TEMPLATES); syn = dict(REAL_SYN)
46
+ for i in range(n_extra):
47
+ name = f"syn{i:05d}"
48
+ tmpl[name] = random_template(rng)
49
+ syn[name] = [name, f"{name}a", f"{name}b"]
50
+ T.TEMPLATES = tmpl; T.CLASSES = list(tmpl)
51
+ K.CLASS_SYNONYMS = syn; K.VOCAB = K.build_vocab()
52
+ M.TEMPLATES, M.CLASSES, M.CLASS_SYNONYMS, M.VOCAB = tmpl, T.CLASSES, syn, K.VOCAB
53
+ t0 = time.perf_counter(); m = M.PixelGen(); dt = time.perf_counter() - t0
54
+ return m, dt
55
+
56
+
57
+ # reference outputs from the real model
58
+ ref_model, _ = build(0)
59
+ probe = REAL_CLASSES + ["red potion", "blue invader", "dragon", "", "golden key", "bone"]
60
+ ref_out = {p: ref_model.generate([p] * 2, [0, 1], scale=1) for p in probe}
61
+
62
+ Ns = [int(a) for a in sys.argv[1:]] or [0, 10, 30, 100, 300, 1000, 3000]
63
+ print(f"{'classes':>8} {'vocab':>6} {'params':>11} {'buffers':>9} {'MB':>7} {'dense%':>6} {'build s':>8} {'gen ms':>7} {'isolated':>8}")
64
+ for n in Ns:
65
+ m, dt = build(n)
66
+ P = sum(p.numel() for p in m.parameters()); B = sum(b.numel() for b in m.buffers())
67
+ dense = m.word_to_class.weight.numel()
68
+ t0 = time.perf_counter()
69
+ for _ in range(5): m.generate(["red potion"], [0], scale=1)
70
+ gen = (time.perf_counter() - t0) / 5 * 1000
71
+ ok = all(np.array_equal(m.generate([p] * 2, [0, 1], scale=1), ref_out[p]) for p in probe)
72
+ # also check a synthetic class actually resolves
73
+ if n:
74
+ nm = f"syn{n-1:05d}"
75
+ got = m.generate([f"{nm}b"], [0], scale=1)[0].sum(-1) > 0
76
+ want = m.generate([nm], [0], scale=1)[0].sum(-1) > 0
77
+ ok = ok and np.array_equal(got, want)
78
+ print(f"{len(m.classes):>8} {len(m.vocab):>6} {P:>11,} {B:>9,} {(P+B)*4/1e6:>7.2f} {100*dense/(P+B):>5.1f}% {dt:>8.2f} {gen:>7.2f} {str(ok):>8}")
79
+
80
+ print("\nparam model: word_to_class = V*C where V≈3C -> O(C^2); banks = 256*C -> O(C)")
81
+ for C in (21, 100, 300, 1000, 3000, 10000):
82
+ V = 59 - 0 + 3 * (C - 21) if C >= 21 else 59
83
+ print(f" C={C:>6}: dense {V*C:>12,} banks {256*C:>10,} dense/banks = {V*C/(256*C):.1f}x")
experiments/shading_before_after.png ADDED
generate.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a sprite from a prompt.
2
+
3
+ python generate.py "red potion" --seed 3 --scale 8 --out sprite.png
4
+ """
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from pixelgen import weights
11
+ from pixelgen.compose import PixelGen2
12
+ from pixelgen.model import PixelGen
13
+
14
+
15
+ def main(argv=None):
16
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
17
+ ap.add_argument("prompt")
18
+ ap.add_argument("--seed", type=int, default=0)
19
+ ap.add_argument("--scale", type=int, default=8)
20
+ ap.add_argument("--out", default="sprite.png")
21
+ ap.add_argument("--weights", default=None, help="load a saved weights file instead of building")
22
+ ap.add_argument("--v1", action="store_true", help="use the original single-part model")
23
+ args = ap.parse_args(argv)
24
+
25
+ model = weights.load(args.weights) if args.weights else (PixelGen() if args.v1 else PixelGen2())
26
+ img = model.sprite(args.prompt, seed=args.seed, scale=args.scale)
27
+ out = Path(args.out)
28
+ out.parent.mkdir(parents=True, exist_ok=True)
29
+ img.save(out)
30
+ print(f"wrote {out} ({img.size[0]}x{img.size[1]})")
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
pixelgen/__init__.py ADDED
File without changes
pixelgen/compose.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PixelGen2: the same hand-written model, plus four compositional layers.
2
+
3
+ Still zero training. New fixed weights on top of PixelGen:
4
+
5
+ A. accessory WTA Linear(V, A) -> argmax -> one-hot part, overlaid
6
+ on the body with max(). 'none' wins ties (bias 0.5).
7
+ B. second ramp Linear(V, R) over the words AFTER a separator
8
+ ('with', 'wearing', ...). If that half has no colour
9
+ word, the body's ramp wins via a 0.5 one-hot bonus.
10
+ C. structural shade light where the up-left neighbour is background,
11
+ dark where the down-right neighbour is background.
12
+ Two fixed 3x3 convs. Replaces the fixed row bands,
13
+ which stay only as the interior base.
14
+ D. size 'small' -> nearest-neighbour 0.75x of the body map,
15
+ bottom-centred. One fixed resampling op.
16
+
17
+ Output space = classes x accessories x ramps x ramps x sizes, all composed
18
+ from parts a human drew once.
19
+ """
20
+
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+
25
+ from .model import N_COLORS, PixelGen, SHADE_ROWS, noise_for_seed
26
+ from .palette import COLOR_WORDS, OUTLINE, RAMP_NAMES
27
+ from .parts import ACCESSORIES, PARTS
28
+ from .templates import CLASSES, GRID, HALF
29
+ from .tokenizer import ACCESSORY_SYNONYMS, CLASS_SYNONYMS, SIZE_WORDS, VOCAB, pad_batch_split
30
+
31
+ import numpy as np
32
+
33
+ SMALL = 12 # 'small' sprites are 12x12 inside the 16x16 grid
34
+
35
+
36
+ class PixelGen2(PixelGen):
37
+ def __init__(self):
38
+ super().__init__()
39
+ V, A, R = len(VOCAB), len(ACCESSORIES), len(RAMP_NAMES)
40
+ self.accessories = ACCESSORIES
41
+
42
+ # A. word -> accessory. 'none' biased so plain prompts stay plain.
43
+ self.word_to_acc = nn.Linear(V, A)
44
+ with torch.no_grad():
45
+ self.word_to_acc.weight.zero_(); self.word_to_acc.bias.zero_()
46
+ for ai, acc in enumerate(ACCESSORIES):
47
+ for w in ACCESSORY_SYNONYMS[acc]:
48
+ self.word_to_acc.weight[ai, VOCAB[w]] = 1.0
49
+ self.word_to_acc.bias[ACCESSORIES.index("none")] = 0.5
50
+ # In the body half, a word that names a body class means the body
51
+ # ('crown' = the object). Only the accessory half can use it as a hat.
52
+ mask = torch.ones(V)
53
+ for ws in CLASS_SYNONYMS.values():
54
+ for w in ws:
55
+ mask[VOCAB[w]] = 0.0
56
+ self.register_buffer("acc_mask_a", mask)
57
+
58
+ bank = torch.zeros(A, GRID, HALF)
59
+ for ai, acc in enumerate(ACCESSORIES):
60
+ for r, row in enumerate(PARTS[acc]):
61
+ for c, ch in enumerate(row):
62
+ bank[ai, r, c] = float(ch == "#")
63
+ self.register_buffer("acc_bank", bank)
64
+
65
+ # B. second ramp from the accessory half of the prompt, no default bias
66
+ # (the body ramp is added as a 0.5 bonus in forward).
67
+ self.word_to_ramp2 = nn.Linear(V, R)
68
+ with torch.no_grad():
69
+ self.word_to_ramp2.weight.zero_(); self.word_to_ramp2.bias.zero_()
70
+ for w, name in COLOR_WORDS.items():
71
+ self.word_to_ramp2.weight[RAMP_NAMES.index(name), VOCAB[w]] = 1.0
72
+
73
+ # C. structural shading: does the up-left / down-right neighbour exist?
74
+ self.lit = nn.Conv2d(1, 1, 3, padding=1, bias=False) # up-left tap
75
+ self.shadow = nn.Conv2d(1, 1, 3, padding=1, bias=False) # down-right tap
76
+ with torch.no_grad():
77
+ self.lit.weight.zero_(); self.lit.weight[0, 0, 0, 0] = 1.0
78
+ self.shadow.weight.zero_(); self.shadow.weight[0, 0, 2, 2] = 1.0
79
+
80
+ # D. size: 'small' words -> 1, else 0.
81
+ self.word_to_size = nn.Linear(V, 1)
82
+ with torch.no_grad():
83
+ self.word_to_size.weight.zero_(); self.word_to_size.bias.zero_()
84
+ for w in SIZE_WORDS:
85
+ self.word_to_size.weight[0, VOCAB[w]] = 1.0
86
+
87
+ for p in self.parameters():
88
+ p.requires_grad_(False)
89
+ self.eval()
90
+
91
+ # ------------------------------------------------------------------
92
+ def _bag(self, tokens):
93
+ B = tokens.shape[0]
94
+ bag = torch.zeros(B, len(self.vocab))
95
+ bag.scatter_add_(1, tokens, torch.ones(tokens.shape, dtype=torch.float32))
96
+ bag[:, 0] = 0.0
97
+ return bag
98
+
99
+ def _shade(self, mask):
100
+ """(B,H,W) mask -> (B,H,W) shade index 0 light / 1 mid / 2 dark."""
101
+ band = torch.zeros(GRID, GRID)
102
+ for s, rows in SHADE_ROWS.items():
103
+ for r in rows:
104
+ band[r, :] = float(s)
105
+ m = mask[:, None]
106
+ lit = (self.lit(m)[:, 0] < 0.5) & (mask > 0.5) # up-left is empty
107
+ dark = (self.shadow(m)[:, 0] < 0.5) & (mask > 0.5) # down-right is empty
108
+ shade = band.expand_as(mask).clone()
109
+ shade[lit] = 0.0
110
+ shade[dark & ~lit] = 2.0
111
+ return shade
112
+
113
+ def _colour(self, mask, ramp, shade):
114
+ ramp_idx = self.ramp_table[ramp].float() # (B,3)
115
+ oh = F.one_hot(shade.long(), 3).float() # (B,H,W,3)
116
+ return mask * torch.einsum("bhws,bs->bhw", oh, ramp_idx)
117
+
118
+ def forward2(self, tok_a, tok_b, noise):
119
+ bag_a, bag_b = self._bag(tok_a), self._bag(tok_b)
120
+ bag = bag_a + bag_b
121
+ B = bag.shape[0]
122
+
123
+ cls = self.word_to_class(bag_a).argmax(-1)
124
+ ramp = self.word_to_ramp(bag_a).argmax(-1)
125
+ acc = (self.word_to_acc(bag_a * self.acc_mask_a) + self.word_to_acc(bag_b) - self.word_to_acc.bias).argmax(-1)
126
+ ramp2 = (self.word_to_ramp2(bag_b) + 0.5 * F.one_hot(ramp, len(RAMP_NAMES))).argmax(-1)
127
+ small = (self.word_to_size(bag)[:, 0] > 0.5)
128
+
129
+ oh_c = F.one_hot(cls, len(CLASSES)).float()
130
+ half = torch.einsum("bc,crh->brh", oh_c, self.on_bank) \
131
+ + torch.einsum("bc,crh->brh", oh_c, self.free_bank) * (noise >= 0.5).float()
132
+ body = torch.cat([half, half.flip(-1)], dim=-1)
133
+
134
+ # D. size
135
+ if small.any():
136
+ s = F.interpolate(body[:, None], size=(SMALL, SMALL), mode="nearest")[:, 0]
137
+ pad = GRID - SMALL
138
+ s = F.pad(s, (pad // 2, pad - pad // 2, pad, 0)) # bottom-centred
139
+ body = torch.where(small[:, None, None], s, body)
140
+
141
+ oh_a = F.one_hot(acc, len(ACCESSORIES)).float()
142
+ ahalf = torch.einsum("ba,arh->brh", oh_a, self.acc_bank)
143
+ accm = torch.cat([ahalf, ahalf.flip(-1)], dim=-1) # accessory sits on top
144
+ body_only = body * (1.0 - accm)
145
+
146
+ union = torch.maximum(body, accm)
147
+ dil = self.outline(union[:, None])[:, 0].clamp(0, 1)
148
+ outline = dil * (1.0 - union)
149
+
150
+ colour = self._colour(body_only, ramp, self._shade(body)) \
151
+ + self._colour(accm, ramp2, self._shade(accm)) \
152
+ + outline * float(OUTLINE)
153
+ idx = colour.round().long()
154
+ return F.one_hot(idx, N_COLORS).permute(0, 3, 1, 2).float()
155
+
156
+ @torch.no_grad()
157
+ def generate(self, prompts, seeds, scale=8, noise=None):
158
+ if isinstance(prompts, str):
159
+ prompts = [prompts]
160
+ prompts = list(prompts)
161
+ seeds = [seeds] * len(prompts) if isinstance(seeds, int) else list(seeds)
162
+ if len(seeds) != len(prompts):
163
+ raise ValueError("prompts and seeds must have the same length")
164
+ tok_a, tok_b = pad_batch_split(prompts, self.vocab)
165
+ if noise is None:
166
+ noise = torch.stack([noise_for_seed(s) for s in seeds])
167
+ img = self.rgb(self.forward2(tok_a, tok_b, noise))
168
+ if scale > 1:
169
+ img = np.repeat(np.repeat(img, scale, axis=1), scale, axis=2)
170
+ return img
pixelgen/model.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PixelGen: a tiny pixel-art generator whose weights are written by hand.
2
+
3
+ Nothing here is trained. Every weight is set explicitly in __init__ from
4
+ the templates and palette in this package. The forward pass is:
5
+
6
+ 1. bag of words prompt -> word counts (one vector over the vocab)
7
+ 2. class WTA linear(word counts) -> argmax -> one-hot class
8
+ 3. shape bank one-hot class selects a 16x8 half-template;
9
+ '?' cells take the noise bit (noise >= 0.5)
10
+ 4. mirror half-template + its horizontal flip -> 16x16 body
11
+ 5. outline 3x3 dilation of body, minus body -> palette 1
12
+ 6. shading body rows get light / mid / dark ramp colours
13
+ 7. ramp WTA linear(colour word counts) -> argmax -> ramp
14
+ 8. one-hot palette each pixel becomes a one-hot over the 16 PICO-8 colours
15
+ 9. palette conv 1x1 conv from one-hot to RGB (exact PICO-8 values)
16
+ """
17
+
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ from .palette import COLOR_WORDS, DEFAULT_RAMP, OUTLINE, PICO8, RAMP_NAMES, RAMPS
24
+ from .templates import CLASSES, GRID, HALF, TEMPLATES
25
+ from .tokenizer import CLASS_SYNONYMS, VOCAB, pad_batch, tokenize
26
+
27
+ N_COLORS = len(PICO8)
28
+ N_SHADES = 3
29
+ # rows of the 16-row grid that get each shade (body only lives in rows 2..13)
30
+ SHADE_ROWS = {0: range(0, 7), 1: range(7, 11), 2: range(11, GRID)}
31
+ DEFAULT_CLASS = "potion"
32
+
33
+
34
+ class PixelGen(nn.Module):
35
+ def __init__(self):
36
+ super().__init__()
37
+ V, C, R = len(VOCAB), len(CLASSES), len(RAMP_NAMES)
38
+ self.vocab = VOCAB
39
+ self.classes = CLASSES
40
+ self.ramps = RAMP_NAMES
41
+ self.palette = PICO8
42
+ self.grid = GRID
43
+
44
+ # 1-2. word -> class scores. Each class word adds 1 to its class.
45
+ # A 0.5 bias on the default class wins ties and empty prompts.
46
+ self.word_to_class = nn.Linear(V, C)
47
+ with torch.no_grad():
48
+ self.word_to_class.weight.zero_()
49
+ self.word_to_class.bias.zero_()
50
+ for ci, cls in enumerate(CLASSES):
51
+ for w in CLASS_SYNONYMS[cls]:
52
+ self.word_to_class.weight[ci, VOCAB[w]] = 1.0
53
+ self.word_to_class.bias[CLASSES.index(DEFAULT_CLASS)] = 0.5
54
+
55
+ # 7. colour word -> ramp scores, same scheme, default ramp biased.
56
+ self.word_to_ramp = nn.Linear(V, R)
57
+ with torch.no_grad():
58
+ self.word_to_ramp.weight.zero_()
59
+ self.word_to_ramp.bias.zero_()
60
+ for w, name in COLOR_WORDS.items():
61
+ self.word_to_ramp.weight[RAMP_NAMES.index(name), VOCAB[w]] = 1.0
62
+ self.word_to_ramp.bias[RAMP_NAMES.index(DEFAULT_RAMP)] = 0.5
63
+
64
+ # 3. shape bank: per class, a 16x8 on-mask and a free-mask.
65
+ on = torch.zeros(C, GRID, HALF)
66
+ free = torch.zeros(C, GRID, HALF)
67
+ for ci, cls in enumerate(CLASSES):
68
+ for r, row in enumerate(TEMPLATES[cls]):
69
+ for c, ch in enumerate(row):
70
+ if ch == "#":
71
+ on[ci, r, c] = 1.0
72
+ elif ch == "?":
73
+ free[ci, r, c] = 1.0
74
+ self.register_buffer("on_bank", on)
75
+ self.register_buffer("free_bank", free)
76
+
77
+ # 6. shade bands: one-hot (3,16,16) for light / mid / dark by row.
78
+ shade = torch.zeros(N_SHADES, GRID, GRID)
79
+ for s, rows in SHADE_ROWS.items():
80
+ for r in rows:
81
+ shade[s, r, :] = 1.0
82
+ self.register_buffer("shade_bank", shade)
83
+
84
+ # 7. ramp table: ramp index -> [light, mid, dark] palette indices.
85
+ table = torch.tensor([RAMPS[name] for name in RAMP_NAMES], dtype=torch.long)
86
+ self.register_buffer("ramp_table", table)
87
+
88
+ # 5. outline: 3x3 all-ones conv counts body neighbours (incl. centre).
89
+ self.outline = nn.Conv2d(1, 1, 3, padding=1, bias=False)
90
+ with torch.no_grad():
91
+ self.outline.weight.fill_(1.0)
92
+
93
+ # 9. palette conv: 1x1, 16 one-hot channels -> RGB in [0,1].
94
+ self.palette_conv = nn.Conv2d(N_COLORS, 3, 1, bias=False)
95
+ with torch.no_grad():
96
+ self.palette_conv.weight.zero_()
97
+ for idx, rgb in enumerate(PICO8):
98
+ for ch in range(3):
99
+ self.palette_conv.weight[ch, idx, 0, 0] = rgb[ch] / 255.0
100
+
101
+ for p in self.parameters():
102
+ p.requires_grad_(False)
103
+ self.eval()
104
+
105
+ # ------------------------------------------------------------------
106
+ def tokenize(self, prompt):
107
+ return tokenize(prompt, self.vocab)
108
+
109
+ def forward(self, tokens, noise):
110
+ """tokens: (B, L) long word ids. noise: (B, 16, 8) floats.
111
+ Returns a one-hot palette map of shape (B, 16, 16, 16)
112
+ (batch, palette index, row, col)."""
113
+ B = tokens.shape[0]
114
+ V = len(self.vocab)
115
+ bag = torch.zeros(B, V)
116
+ bag.scatter_add_(1, tokens, torch.ones(tokens.shape, dtype=torch.float32))
117
+ bag[:, 0] = 0.0 # <pad> never counts
118
+
119
+ cls = self.word_to_class(bag).argmax(-1)
120
+ ramp = self.word_to_ramp(bag).argmax(-1)
121
+
122
+ oh_c = F.one_hot(cls, len(CLASSES)).float()
123
+ on = torch.einsum("bc,crh->brh", oh_c, self.on_bank)
124
+ free = torch.einsum("bc,crh->brh", oh_c, self.free_bank)
125
+ bit = (noise >= 0.5).float()
126
+ half = on + free * bit
127
+
128
+ body = torch.cat([half, half.flip(-1)], dim=-1) # exact mirror
129
+
130
+ dil = self.outline(body[:, None])[:, 0].clamp(0.0, 1.0)
131
+ outline = dil * (1.0 - body)
132
+
133
+ ramp_idx = self.ramp_table[ramp].float() # (B, 3)
134
+ body_colour = torch.einsum("bs,shw->bhw", ramp_idx, self.shade_bank)
135
+ colour = body * body_colour + outline * float(OUTLINE)
136
+
137
+ idx = colour.round().long()
138
+ oh = F.one_hot(idx, N_COLORS).permute(0, 3, 1, 2).float()
139
+ return oh
140
+
141
+ @torch.no_grad()
142
+ def rgb(self, onehot):
143
+ """(B,16,H,W) one-hot -> (B,H,W,3) uint8."""
144
+ x = self.palette_conv(onehot) * 255.0
145
+ return x.round().clamp(0, 255).to(torch.uint8).permute(0, 2, 3, 1).numpy()
146
+
147
+ @torch.no_grad()
148
+ def generate(self, prompts, seeds, scale=8, noise=None):
149
+ """Return a uint8 array of shape (B, 16*scale, 16*scale, 3)."""
150
+ if isinstance(prompts, str):
151
+ prompts = [prompts]
152
+ prompts = list(prompts)
153
+ seeds = [seeds] * len(prompts) if isinstance(seeds, int) else list(seeds)
154
+ if len(seeds) != len(prompts):
155
+ raise ValueError("prompts and seeds must have the same length")
156
+ tokens = pad_batch(prompts, self.vocab)
157
+ if noise is None:
158
+ noise = torch.stack([noise_for_seed(s) for s in seeds])
159
+ else:
160
+ noise = torch.as_tensor(noise, dtype=torch.float32).expand(len(prompts), GRID, HALF)
161
+ oh = self.forward(tokens, noise)
162
+ img = self.rgb(oh) # (B,16,16,3)
163
+ if scale > 1:
164
+ img = np.repeat(np.repeat(img, scale, axis=1), scale, axis=2)
165
+ return img
166
+
167
+ def sprite(self, prompt, seed=0, scale=8):
168
+ from PIL import Image
169
+
170
+ arr = self.generate([prompt], [seed], scale=scale)[0]
171
+ return Image.fromarray(arr, mode="RGB")
172
+
173
+
174
+ def noise_for_seed(seed):
175
+ """Deterministic per-seed noise in [0,1), shape (GRID, HALF)."""
176
+ g = torch.Generator().manual_seed(int(seed))
177
+ return torch.rand(GRID, HALF, generator=g)
pixelgen/palette.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PICO-8 palette, colour ramps and the words that select them.
2
+
3
+ The network's final layer can only emit one-hot vectors over these 16
4
+ colours, so nothing outside this palette can ever appear in an image.
5
+ """
6
+
7
+ PICO8 = [
8
+ (0x00, 0x00, 0x00), # 0 black (background)
9
+ (0x1D, 0x2B, 0x53), # 1 dark blue (outline)
10
+ (0x7E, 0x25, 0x53), # 2 dark purple
11
+ (0x00, 0x87, 0x51), # 3 dark green
12
+ (0xAB, 0x52, 0x36), # 4 brown
13
+ (0x5F, 0x57, 0x4F), # 5 dark grey
14
+ (0xC2, 0xC3, 0xC7), # 6 light grey
15
+ (0xFF, 0xF1, 0xE8), # 7 white
16
+ (0xFF, 0x00, 0x4D), # 8 red
17
+ (0xFF, 0xA3, 0x00), # 9 orange
18
+ (0xFF, 0xEC, 0x27), # 10 yellow
19
+ (0x00, 0xE4, 0x36), # 11 green
20
+ (0x29, 0xAD, 0xFF), # 12 blue
21
+ (0x83, 0x76, 0x9C), # 13 lavender
22
+ (0xFF, 0x77, 0xA8), # 14 pink
23
+ (0xFF, 0xCC, 0xAA), # 15 peach
24
+ ]
25
+
26
+ BACKGROUND = 0
27
+ OUTLINE = 1
28
+
29
+ # Each ramp is [light, mid, dark] palette indices. Indices 0 and 1 are
30
+ # reserved for background and outline so a sprite never has more than
31
+ # 2 + 3 = 5 colours.
32
+ RAMPS = {
33
+ "red": [14, 8, 2],
34
+ "blue": [12, 13, 2],
35
+ "green": [11, 3, 5],
36
+ "yellow": [10, 9, 4],
37
+ "purple": [14, 13, 2],
38
+ "orange": [15, 9, 4],
39
+ "pink": [15, 14, 2],
40
+ "brown": [15, 4, 5],
41
+ "white": [7, 6, 5],
42
+ }
43
+ RAMP_NAMES = list(RAMPS)
44
+
45
+ # Ramp used when the prompt names no colour word.
46
+ DEFAULT_RAMP = "green"
47
+
48
+ # Prompt words that force a ramp.
49
+ COLOR_WORDS = {
50
+ "red": "red", "crimson": "red", "ruby": "red",
51
+ "blue": "blue", "azure": "blue", "sapphire": "blue",
52
+ "green": "green", "emerald": "green", "lime": "green",
53
+ "yellow": "yellow", "gold": "yellow", "golden": "yellow",
54
+ "purple": "purple", "violet": "purple", "amethyst": "purple",
55
+ "orange": "orange", "amber": "orange",
56
+ "pink": "pink", "rose": "pink",
57
+ "brown": "brown", "wood": "brown", "wooden": "brown",
58
+ "white": "white", "grey": "white", "gray": "white", "silver": "white", "bone": "white",
59
+ }
60
+
61
+ for _name, _ramp in RAMPS.items():
62
+ assert len(_ramp) == 3, _name
63
+ assert all(2 <= i <= 15 for i in _ramp), _name
pixelgen/parts.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Accessory parts: 16x8 half-templates overlaid on any body.
2
+
3
+ Same alphabet as templates.py ('#' on, '.' off). Mirrored like bodies, so a
4
+ 'torch' is two torches. Rows 0 and 15 stay empty so the outline has room.
5
+ Hats live in rows 1-4 (most bodies start at row 3); side parts in rows 5-10,
6
+ columns 2-3 (outside most bodies).
7
+ """
8
+
9
+ from .templates import GRID, HALF
10
+
11
+ PARTS = {
12
+ "none": ["........"] * GRID,
13
+ "crown": [
14
+ "........",
15
+ "...#.#.#",
16
+ "...#####",
17
+ "...#####",
18
+ "........",
19
+ "........",
20
+ "........",
21
+ "........",
22
+ "........",
23
+ "........",
24
+ "........",
25
+ "........",
26
+ "........",
27
+ "........",
28
+ "........",
29
+ "........",
30
+ ],
31
+ "halo": [
32
+ ".....###",
33
+ "....#...",
34
+ ".....###",
35
+ "........",
36
+ "........",
37
+ "........",
38
+ "........",
39
+ "........",
40
+ "........",
41
+ "........",
42
+ "........",
43
+ "........",
44
+ "........",
45
+ "........",
46
+ "........",
47
+ "........",
48
+ ],
49
+ "horns": [
50
+ "........",
51
+ "...#....",
52
+ "...#....",
53
+ "....#...",
54
+ "....##..",
55
+ "........",
56
+ "........",
57
+ "........",
58
+ "........",
59
+ "........",
60
+ "........",
61
+ "........",
62
+ "........",
63
+ "........",
64
+ "........",
65
+ "........",
66
+ ],
67
+ "helmet": [
68
+ "........",
69
+ "........",
70
+ ".....###",
71
+ "....####",
72
+ "...#####",
73
+ "...##...",
74
+ "........",
75
+ "........",
76
+ "........",
77
+ "........",
78
+ "........",
79
+ "........",
80
+ "........",
81
+ "........",
82
+ "........",
83
+ "........",
84
+ ],
85
+ "antenna": [
86
+ "........",
87
+ ".....#..",
88
+ "......#.",
89
+ "......#.",
90
+ "........",
91
+ "........",
92
+ "........",
93
+ "........",
94
+ "........",
95
+ "........",
96
+ "........",
97
+ "........",
98
+ "........",
99
+ "........",
100
+ "........",
101
+ "........",
102
+ ],
103
+ "wings": [
104
+ "........",
105
+ "........",
106
+ "........",
107
+ "........",
108
+ "........",
109
+ "..#.....",
110
+ "..##....",
111
+ "..###...",
112
+ "..###...",
113
+ "..##....",
114
+ "..#.....",
115
+ "........",
116
+ "........",
117
+ "........",
118
+ "........",
119
+ "........",
120
+ ],
121
+ "torch": [
122
+ "........",
123
+ "........",
124
+ "........",
125
+ "..#.....",
126
+ "..##....",
127
+ "..#.....",
128
+ "..#.....",
129
+ "..#.....",
130
+ "..#.....",
131
+ "..#.....",
132
+ "........",
133
+ "........",
134
+ "........",
135
+ "........",
136
+ "........",
137
+ "........",
138
+ ],
139
+ }
140
+
141
+ ACCESSORIES = list(PARTS)
142
+
143
+
144
+ def _check():
145
+ for name, rows in PARTS.items():
146
+ assert len(rows) == GRID, name
147
+ for r, row in enumerate(rows):
148
+ assert len(row) == HALF and set(row) <= set("#."), (name, r, row)
149
+ assert row[:2] == ".." or name in ("wings", "torch"), (name, r)
150
+ assert rows[-1] == "." * HALF, name
151
+
152
+
153
+ _check()
pixelgen/templates.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hand-drawn sprite templates: the "program" for the shape layer.
2
+
3
+ Each template is the LEFT half of a 16x16 sprite: 16 rows x 8 columns.
4
+ Column 7 is the column just left of the centre line; the network mirrors
5
+ the half to produce a symmetric sprite.
6
+
7
+ '#' forced ON (always body)
8
+ '.' forced OFF (never body)
9
+ '?' free (decided by one noise bit, then smoothed)
10
+
11
+ Rules the shape layer relies on:
12
+ * rows 0,1,14,15 and columns 0,1 are '.', so the body sits in rows/cols
13
+ 2..13 of the full grid and a 1px outline fits inside the border.
14
+ * at least 8 '#' per half -> at least 16 body cells.
15
+ * at most 60 '#'+'?' per half -> at most 120 body cells.
16
+ """
17
+
18
+ GRID = 16
19
+ HALF = GRID // 2
20
+
21
+ TEMPLATES = {
22
+ "potion": [
23
+ "........",
24
+ "........",
25
+ "......##",
26
+ "......##",
27
+ ".......#",
28
+ ".......#",
29
+ ".....?##",
30
+ "...?####",
31
+ "..?#####",
32
+ "..?#####",
33
+ "..?#####",
34
+ "..?#####",
35
+ "...?####",
36
+ "....?###",
37
+ "........",
38
+ "........",
39
+ ],
40
+ "sword": [
41
+ "........",
42
+ "........",
43
+ ".......#",
44
+ ".......#",
45
+ "......?#",
46
+ "......?#",
47
+ "......?#",
48
+ "......?#",
49
+ "......?#",
50
+ "....####",
51
+ "....?###",
52
+ ".......#",
53
+ ".......#",
54
+ "......##",
55
+ "........",
56
+ "........",
57
+ ],
58
+ "shield": [
59
+ "........",
60
+ "........",
61
+ "...#####",
62
+ "..??####",
63
+ "..#?####",
64
+ "..#?#?##",
65
+ "..#?####",
66
+ "..#?#?##",
67
+ "..#?####",
68
+ "...#####",
69
+ "...?####",
70
+ "....?###",
71
+ ".....?##",
72
+ "......?#",
73
+ "........",
74
+ "........",
75
+ ],
76
+ "ship": [
77
+ "........",
78
+ "........",
79
+ ".......#",
80
+ ".......#",
81
+ "......##",
82
+ "......##",
83
+ ".....?##",
84
+ ".....###",
85
+ "....?###",
86
+ "...?####",
87
+ "..?#?###",
88
+ "..##?###",
89
+ "..#?..##",
90
+ "......?#",
91
+ "........",
92
+ "........",
93
+ ],
94
+ "invader": [
95
+ "........",
96
+ "........",
97
+ "........",
98
+ "........",
99
+ "....#...",
100
+ ".....#..",
101
+ "....####",
102
+ "...##.##",
103
+ "..######",
104
+ "..#.####",
105
+ "..#.#..?",
106
+ ".....##.",
107
+ "........",
108
+ "........",
109
+ "........",
110
+ "........",
111
+ ],
112
+ "slime": [
113
+ "........",
114
+ "........",
115
+ "........",
116
+ "........",
117
+ "........",
118
+ "......??",
119
+ ".....?##",
120
+ "....?###",
121
+ "...?####",
122
+ "...##.##",
123
+ "..?#####",
124
+ "..######",
125
+ "..?#####",
126
+ "...?####",
127
+ "........",
128
+ "........",
129
+ ],
130
+ "skull": [
131
+ "........",
132
+ "........",
133
+ "........",
134
+ ".....?##",
135
+ "....####",
136
+ "...#####",
137
+ "...#####",
138
+ "...#..##",
139
+ "...#..##",
140
+ "...#####",
141
+ "....####",
142
+ "....#.#.",
143
+ "....#.#.",
144
+ "........",
145
+ "........",
146
+ "........",
147
+ ],
148
+ "tree": [
149
+ "........",
150
+ "........",
151
+ ".......#",
152
+ "......?#",
153
+ "......##",
154
+ ".....?##",
155
+ ".....###",
156
+ "....?###",
157
+ "....####",
158
+ "...?####",
159
+ "...#####",
160
+ "..?#####",
161
+ "......##",
162
+ "......##",
163
+ "........",
164
+ "........",
165
+ ],
166
+ "key": [
167
+ "........",
168
+ "........",
169
+ "........",
170
+ "......##",
171
+ ".....#..",
172
+ ".....#..",
173
+ "......##",
174
+ ".......#",
175
+ ".......#",
176
+ ".......#",
177
+ "......##",
178
+ ".......#",
179
+ ".....###",
180
+ ".......#",
181
+ "........",
182
+ "........",
183
+ ],
184
+ "heart": [
185
+ "........",
186
+ "........",
187
+ "........",
188
+ "........",
189
+ "..?###..",
190
+ "..#####?",
191
+ "..######",
192
+ "..######",
193
+ "..######",
194
+ "...#####",
195
+ "....####",
196
+ ".....###",
197
+ "......##",
198
+ ".......#",
199
+ "........",
200
+ "........",
201
+ ],
202
+ "star": [
203
+ "........",
204
+ "........",
205
+ ".......#",
206
+ ".......#",
207
+ "......##",
208
+ "......##",
209
+ "..?#####",
210
+ "...#####",
211
+ "....####",
212
+ "....####",
213
+ "...?####",
214
+ "...##?.#",
215
+ "..##...?",
216
+ "..#.....",
217
+ "........",
218
+ "........",
219
+ ],
220
+ "mushroom": [
221
+ "........",
222
+ "........",
223
+ "........",
224
+ ".....###",
225
+ "....##?#",
226
+ "...##.##",
227
+ "..##?###",
228
+ "..######",
229
+ "..######",
230
+ ".....###",
231
+ ".....#.#",
232
+ ".....###",
233
+ ".....###",
234
+ "........",
235
+ "........",
236
+ "........",
237
+ ],
238
+ "ghost": [
239
+ "........",
240
+ "........",
241
+ "........",
242
+ ".....###",
243
+ "....####",
244
+ "...#####",
245
+ "...##.##",
246
+ "...##.##",
247
+ "...#####",
248
+ "...#####",
249
+ "...#####",
250
+ "...#####",
251
+ "...#?#.#",
252
+ "...#.#..",
253
+ "........",
254
+ "........",
255
+ ],
256
+ "bomb": [
257
+ "........",
258
+ "........",
259
+ ".......#",
260
+ "......#.",
261
+ "......##",
262
+ ".....###",
263
+ "....####",
264
+ "...#####",
265
+ "...#####",
266
+ "...##?##",
267
+ "...#####",
268
+ "....####",
269
+ ".....###",
270
+ "........",
271
+ "........",
272
+ "........",
273
+ ],
274
+ "coin": [
275
+ "........",
276
+ "........",
277
+ "........",
278
+ ".....###",
279
+ "....####",
280
+ "...#####",
281
+ "...##..#",
282
+ "...#..##",
283
+ "...#..##",
284
+ "...##..#",
285
+ "...#####",
286
+ "....####",
287
+ ".....###",
288
+ "........",
289
+ "........",
290
+ "........",
291
+ ],
292
+ "gem": [
293
+ "........",
294
+ "........",
295
+ "........",
296
+ "....####",
297
+ "...#####",
298
+ "..######",
299
+ "..#?####",
300
+ "...#####",
301
+ "....####",
302
+ ".....###",
303
+ "......##",
304
+ ".......#",
305
+ "........",
306
+ "........",
307
+ "........",
308
+ "........",
309
+ ],
310
+ "cherry": [
311
+ "........",
312
+ "........",
313
+ "........",
314
+ ".......#",
315
+ "......#.",
316
+ ".....#..",
317
+ "....#...",
318
+ "....#...",
319
+ "...###..",
320
+ "..####..",
321
+ "..####..",
322
+ "..#?##..",
323
+ "...##...",
324
+ "........",
325
+ "........",
326
+ "........",
327
+ ],
328
+ "crown": [
329
+ "........",
330
+ "........",
331
+ "........",
332
+ "..#....#",
333
+ "..#..#.#",
334
+ "..##.###",
335
+ "..######",
336
+ "..######",
337
+ "..######",
338
+ "..######",
339
+ "..######",
340
+ "..######",
341
+ "........",
342
+ "........",
343
+ "........",
344
+ "........",
345
+ ],
346
+ "bat": [
347
+ "........",
348
+ "........",
349
+ "........",
350
+ "......##",
351
+ ".....###",
352
+ ".......#",
353
+ "..#..###",
354
+ "..##.###",
355
+ "..######",
356
+ "...#####",
357
+ "....####",
358
+ "....#.##",
359
+ "........",
360
+ "........",
361
+ "........",
362
+ "........",
363
+ ],
364
+ "cactus": [
365
+ "........",
366
+ "........",
367
+ "........",
368
+ "......##",
369
+ "......##",
370
+ "..#...##",
371
+ "..#...##",
372
+ "..##..##",
373
+ "...#####",
374
+ "......##",
375
+ "......##",
376
+ "......##",
377
+ ".....###",
378
+ "........",
379
+ "........",
380
+ "........",
381
+ ],
382
+ "robot": [
383
+ "........",
384
+ "........",
385
+ "........",
386
+ "......##",
387
+ ".....###",
388
+ "...#####",
389
+ "...#.###",
390
+ "...#####",
391
+ "...#####",
392
+ "....####",
393
+ "..?#####",
394
+ "...#####",
395
+ "...##.##",
396
+ "...##.##",
397
+ "........",
398
+ "........",
399
+ ],
400
+ }
401
+
402
+ CLASSES = list(TEMPLATES)
403
+
404
+
405
+ def _check():
406
+ for name, rows in TEMPLATES.items():
407
+ assert len(rows) == GRID, name
408
+ on = free = 0
409
+ for r, row in enumerate(rows):
410
+ assert len(row) == HALF, (name, r, row)
411
+ assert set(row) <= set("#.?"), (name, r, row)
412
+ if r in (0, 1, GRID - 2, GRID - 1):
413
+ assert row == "." * HALF, (name, r)
414
+ assert row[:2] == "..", (name, r)
415
+ on += row.count("#")
416
+ free += row.count("?")
417
+ assert on >= 8, (name, on)
418
+ assert on + free <= 60, (name, on + free)
419
+
420
+
421
+ _check()
pixelgen/tokenizer.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Word-level tokenizer. Unknown words map to id 0 (<pad>), which the
2
+ embedding layer ignores, so garbage prompts are harmless."""
3
+
4
+ import re
5
+
6
+ import torch
7
+
8
+ from .palette import COLOR_WORDS
9
+ from .templates import CLASSES
10
+
11
+ CLASS_SYNONYMS = {
12
+ "potion": ["potion", "bottle", "flask", "elixir", "vial"],
13
+ "sword": ["sword", "blade", "dagger"],
14
+ "shield": ["shield", "buckler"],
15
+ "ship": ["ship", "rocket", "spaceship", "starship"],
16
+ "invader": ["invader", "alien", "monster"],
17
+ "slime": ["slime", "blob", "jelly"],
18
+ "skull": ["skull", "bone", "skeleton"],
19
+ "tree": ["tree", "pine", "forest"],
20
+ "key": ["key"],
21
+ "heart": ["heart", "love", "life"],
22
+ "star": ["star", "sparkle"],
23
+ "mushroom": ["mushroom", "fungus", "toadstool", "shroom"],
24
+ "ghost": ["ghost", "spirit", "phantom", "spooky"],
25
+ "bomb": ["bomb", "grenade", "explosive"],
26
+ "coin": ["coin", "token", "money", "medal"],
27
+ "gem": ["gem", "diamond", "jewel", "crystal"],
28
+ "cherry": ["cherry", "cherries", "berry", "fruit"],
29
+ "crown": ["crown"],
30
+ "bat": ["bat", "vampire"],
31
+ "cactus": ["cactus", "desert", "succulent"],
32
+ "robot": ["robot", "droid", "android", "mech"],
33
+ }
34
+
35
+ PAD = "<pad>"
36
+
37
+ # Accessory words select a second, overlaid part (see parts.py).
38
+ ACCESSORY_SYNONYMS = {
39
+ "none": [],
40
+ "crown": ["crown", "king", "queen", "royal", "crowned", "prince", "princess"],
41
+ "halo": ["halo", "angel", "angelic", "holy", "saint"],
42
+ "horns": ["horns", "horned", "devil", "demon", "evil"],
43
+ "helmet": ["helmet", "knight", "armor", "armour", "armored"],
44
+ "antenna": ["antenna", "antennae", "bug", "martian"],
45
+ "wings": ["wings", "winged", "fly", "flying", "bird", "fairy", "butterfly"],
46
+ "torch": ["torch", "torches", "fire", "flame", "burning"],
47
+ }
48
+ SIZE_WORDS = {"small": 1, "tiny": 1, "mini": 1, "little": 1}
49
+ # These split a prompt in two: words after them describe the accessory.
50
+ SEPARATORS = ["with", "wearing", "holding", "and", "plus"]
51
+
52
+
53
+ def build_vocab():
54
+ vocab = {PAD: 0}
55
+ groups = [
56
+ set(w for ws in CLASS_SYNONYMS.values() for w in ws),
57
+ set(COLOR_WORDS),
58
+ set(w for ws in ACCESSORY_SYNONYMS.values() for w in ws),
59
+ set(SIZE_WORDS),
60
+ set(SEPARATORS),
61
+ ]
62
+ for g in groups:
63
+ for word in sorted(g):
64
+ if word not in vocab:
65
+ vocab[word] = len(vocab)
66
+ return vocab
67
+
68
+
69
+ VOCAB = build_vocab()
70
+
71
+ _SPLIT = re.compile(r"[^a-z]+")
72
+
73
+
74
+ def tokenize(prompt, vocab=VOCAB):
75
+ words = [w for w in _SPLIT.split(prompt.lower()) if w]
76
+ ids = [vocab.get(w, 0) for w in words]
77
+ return torch.tensor(ids if ids else [0], dtype=torch.long)
78
+
79
+
80
+ def pad_batch(prompts, vocab=VOCAB):
81
+ toks = [tokenize(p, vocab) for p in prompts]
82
+ L = max(t.numel() for t in toks)
83
+ out = torch.zeros(len(toks), L, dtype=torch.long)
84
+ for i, t in enumerate(toks):
85
+ out[i, : t.numel()] = t
86
+ return out
87
+
88
+
89
+ def split_prompt(prompt):
90
+ """'green slime with gold crown' -> ('green slime', 'gold crown').
91
+ No separator -> whole prompt is the body half, accessory half empty."""
92
+ words = [w for w in _SPLIT.split(prompt.lower()) if w]
93
+ for i, w in enumerate(words):
94
+ if w in SEPARATORS:
95
+ return " ".join(words[:i]), " ".join(words[i + 1:])
96
+ return " ".join(words), ""
97
+
98
+
99
+ def pad_batch_split(prompts, vocab=VOCAB):
100
+ halves = [split_prompt(p) for p in prompts]
101
+ return pad_batch([a for a, _ in halves], vocab), pad_batch([b for _, b in halves], vocab)
102
+
103
+
104
+ def word_class(word):
105
+ for cls, ws in CLASS_SYNONYMS.items():
106
+ if word in ws:
107
+ return CLASSES.index(cls)
108
+ return None
pixelgen/weights.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Save and load the hand-written PixelGen state dict.
2
+
3
+ The weights are set in code, so saving is only a convenience: it lets
4
+ generate.py run without re-building the model and gives a file to check
5
+ into version control.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import torch
11
+
12
+ from .model import PixelGen
13
+
14
+ DEFAULT_PATH = Path(__file__).resolve().parent.parent / "weights" / "pixelgen.pt"
15
+
16
+
17
+ def save(path=DEFAULT_PATH, model=None):
18
+ model = model or PixelGen()
19
+ path = Path(path)
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ torch.save(model.state_dict(), path)
22
+ return path
23
+
24
+
25
+ def load(path=DEFAULT_PATH):
26
+ model = PixelGen()
27
+ state = torch.load(path, map_location="cpu", weights_only=True)
28
+ model.load_state_dict(state)
29
+ model.eval()
30
+ return model
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ torch>=2.0
2
+ numpy
3
+ pillow
seeds.png ADDED
showcase2.png ADDED
tests/test_pixel_art.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from pixelgen import weights
5
+ from pixelgen.model import PixelGen
6
+ from pixelgen.palette import COLOR_WORDS, PICO8, RAMP_NAMES
7
+ from pixelgen.templates import CLASSES
8
+
9
+
10
+ @pytest.fixture(scope="module")
11
+ def model():
12
+ return PixelGen()
13
+
14
+
15
+ def colours(img):
16
+ return {tuple(int(v) for v in c) for c in img.reshape(-1, 3)}
17
+
18
+
19
+ def test_output_shape_and_dtype(model):
20
+ img = model.generate(["red potion"], [0], scale=8)
21
+ assert img.shape == (1, 128, 128, 3)
22
+ assert img.dtype == np.uint8
23
+
24
+
25
+ def test_only_pico8_colours(model):
26
+ pico = set(PICO8)
27
+ for prompt in ["red potion", "blue sword", "green shield", "gold crown", ""]:
28
+ for seed in range(3):
29
+ img = model.generate([prompt], [seed], scale=1)[0]
30
+ assert colours(img) <= pico, (prompt, seed)
31
+
32
+
33
+ def test_deterministic_for_same_seed(model):
34
+ a = model.generate(["red potion"], [7], scale=1)
35
+ b = model.generate(["red potion"], [7], scale=1)
36
+ assert np.array_equal(a, b)
37
+
38
+
39
+ def test_sprite_is_horizontally_symmetric(model):
40
+ img = model.generate(["red potion"], [0], scale=1)[0]
41
+ assert np.array_equal(img, img[:, ::-1])
42
+
43
+
44
+ @pytest.mark.parametrize("word,ramp", [("red", "red"), ("blue", "blue"), ("green", "green")])
45
+ def test_colour_word_selects_ramp(model, word, ramp):
46
+ # The ramp's light/mid/dark colours should dominate the body pixels.
47
+ from pixelgen.palette import RAMPS
48
+
49
+ img = model.generate([f"{word} potion"], [0], scale=1)[0]
50
+ ramp_cols = {PICO8[i] for i in RAMPS[ramp]}
51
+ assert ramp_cols & colours(img), f"{word} produced none of {ramp} ramp colours"
52
+
53
+
54
+ def test_every_colour_word_maps_to_a_known_ramp():
55
+ assert set(COLOR_WORDS.values()) <= set(RAMP_NAMES)
56
+
57
+
58
+ def test_every_class_renders(model):
59
+ for cls in CLASSES:
60
+ img = model.generate([cls], [0], scale=1)[0]
61
+ assert img.shape == (16, 16, 3)
62
+ assert img.any(), f"{cls} rendered empty"
63
+
64
+
65
+ def test_save_and_load_round_trip(tmp_path):
66
+ path = weights.save(tmp_path / "pixelgen.pt")
67
+ loaded = weights.load(path)
68
+ fresh = PixelGen()
69
+ a = fresh.generate(["red potion"], [1], scale=1)
70
+ b = loaded.generate(["red potion"], [1], scale=1)
71
+ assert np.array_equal(a, b)
72
+
73
+
74
+ # ---------------------------------------------------------------- PixelGen2
75
+ from pixelgen.compose import PixelGen2 # noqa: E402
76
+ from pixelgen.palette import RAMPS # noqa: E402
77
+
78
+
79
+ @pytest.fixture(scope="module")
80
+ def model2():
81
+ return PixelGen2()
82
+
83
+
84
+ def test_v2_only_pico8_and_symmetric(model2):
85
+ img = model2.generate(["purple ghost with gold crown"], [0], scale=1)[0]
86
+ assert set(map(tuple, img.reshape(-1, 3))) <= set(map(tuple, PICO8))
87
+ mask = img.sum(-1) > 0 # shape is mirrored; lighting is deliberately not
88
+ assert np.array_equal(mask, mask[:, ::-1])
89
+
90
+
91
+ def test_v2_plain_prompt_has_no_accessory(model2):
92
+ a = model2.generate(["green slime"], [0], scale=1)[0]
93
+ b = model2.generate(["green slime with crown"], [0], scale=1)[0]
94
+ assert not np.array_equal(a, b)
95
+ # top rows of a plain slime are background; crown fills them
96
+ assert (a[:3].sum(-1) > 0).sum() == 0 and (b[:3].sum(-1) > 0).sum() > 0
97
+
98
+
99
+ def test_v2_accessory_takes_second_colour(model2):
100
+ img = model2.generate(["green slime with red crown"], [0], scale=1)[0]
101
+ red = {tuple(PICO8[i]) for i in RAMPS["red"]}
102
+ green = {tuple(PICO8[i]) for i in RAMPS["green"]}
103
+ px = set(map(tuple, img.reshape(-1, 3)))
104
+ assert px & red and px & green
105
+
106
+
107
+ def test_v2_crown_word_means_object_in_body_half(model2):
108
+ crown = model2.generate(["crown"], [0], scale=1)[0]
109
+ king = model2.generate(["slime with crown"], [0], scale=1)[0]
110
+ assert not np.array_equal(crown, king)
111
+ assert (crown[:2].sum(-1) > 0).sum() == 0 # the crown object has no hat on it
112
+
113
+
114
+ def test_v2_small_is_smaller(model2):
115
+ big = (model2.generate(["skull"], [0], scale=1)[0].sum(-1) > 0).sum()
116
+ small = (model2.generate(["small skull"], [0], scale=1)[0].sum(-1) > 0).sum()
117
+ assert small < big
118
+
119
+
120
+ def test_v2_no_trainable_params(model2):
121
+ assert all(not p.requires_grad for p in model2.parameters())
weights/pixelgen.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4c5dffd19634d4b956c08250d25aba435f63c98de4211fb0b16a8e24111540eb
3
+ size 45717