DJLougen commited on
Commit
7190edb
·
verified ·
1 Parent(s): 38205eb
README.md CHANGED
@@ -20,7 +20,7 @@ language:
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 19,269 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
 
@@ -29,7 +29,7 @@ Every weight was typed in by hand. It draws 19,269 distinct sprites from 28 temp
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-19%2C269-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)
@@ -51,7 +51,7 @@ There are two models in the repo:
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 | **19,269** |
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/`.
@@ -85,7 +85,7 @@ v2 inserts four more fixed-weight layers (`pixelgen/compose.py`):
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 — but only where the 3×3 body count ≥ 6, so thin shapes don't speckle. Body pixels touching an accessory become outline (a seam), so a same-colour hat still reads as a hat. | 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:
@@ -105,9 +105,9 @@ The "generative" part is step 3: templates have `?` cells gated by per-seed nois
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 | 19,269 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 `8bbbf458…`, identical across rebuilds | – |
111
 
112
  <details>
113
  <summary>Parameter breakdown (v2; v1 is the first six rows)</summary>
@@ -191,14 +191,14 @@ It never breaks — the original 21 classes stay bit-identical at 10k. But vocab
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, **102× distinct outputs**. 688 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). The first version **hurt thin ones** (invader, bat, robot) — when every pixel is an edge, rim light becomes speckle. The fix was one more hand-written rule: only light pixels whose 3×3 neighbourhood has ≥ 6 body pixels (reusing the outline conv as a thickness detector, 0 new params). It cost 2,597 distinct outputs that were lighting-only variants. Every quality bug so far has been fixable with a rule, but each rule is a special case someone had to notice; a learned prior would find them for you.
202
 
203
  ## Limitations
204
 
@@ -208,7 +208,7 @@ Quality, honestly: structural lighting helps blobby shapes (potion, heart, mushr
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
- - **Shading rules are special cases.** The thickness gate fixed speckle; the next edge case is yours to find.
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
 
 
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
 
 
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)
 
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/`.
 
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:
 
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>
 
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
 
 
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
 
experiments/README.md CHANGED
@@ -7,7 +7,7 @@ Every number below was produced by a script in this folder. No training anywhere
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? | 19,269 distinct images from 28 templates; 102× output for 1.6× params |
11
 
12
  ## 1. Adding one class is a pure append (in function space)
13
 
@@ -69,19 +69,19 @@ Isolation survives. But `interference` — the share of `"wordA wordB"` prompts
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 -> 19,269 distinct images
73
  hand-drawn content: 21 bodies + 7 parts = 28 templates
74
- distinct images per template: 9 -> 688
75
- param growth 1.60x output growth 102x
76
  ```
77
 
78
- (The 4,923 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. The first version **hurt thin ones**: invader, bat, robot turned to speckle because every pixel is an edge. Fix: gate lighting on the 3×3 body count ≥ 6 (the outline conv doubles as a thickness detector; 0 new params). That dropped distinct outputs from 21,866 to 19,269 — the lost ones were lighting-only variants. A fixed kernel has no notion of "too thin to light" until someone writes it in.
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
 
 
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
 
 
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
 
experiments/shading_before_after.png CHANGED
pixelgen/compose.py CHANGED
@@ -31,7 +31,6 @@ from .tokenizer import ACCESSORY_SYNONYMS, CLASS_SYNONYMS, SIZE_WORDS, VOCAB, pa
31
  import numpy as np
32
 
33
  SMALL = 12 # 'small' sprites are 12x12 inside the 16x16 grid
34
- THICK_MIN = 6 # 3x3 body count needed before rim lighting applies (kills speckle on thin shapes)
35
 
36
 
37
  class PixelGen2(PixelGen):
@@ -104,9 +103,8 @@ class PixelGen2(PixelGen):
104
  for r in rows:
105
  band[r, :] = float(s)
106
  m = mask[:, None]
107
- thick = self.outline(m)[:, 0] >= THICK_MIN # enough body neighbours to light
108
- lit = (self.lit(m)[:, 0] < 0.5) & (mask > 0.5) & thick # up-left is empty
109
- dark = (self.shadow(m)[:, 0] < 0.5) & (mask > 0.5) & thick # down-right is empty
110
  shade = band.expand_as(mask).clone()
111
  shade[lit] = 0.0
112
  shade[dark & ~lit] = 2.0
@@ -143,10 +141,7 @@ class PixelGen2(PixelGen):
143
  oh_a = F.one_hot(acc, len(ACCESSORIES)).float()
144
  ahalf = torch.einsum("ba,arh->brh", oh_a, self.acc_bank)
145
  accm = torch.cat([ahalf, ahalf.flip(-1)], dim=-1) # accessory sits on top
146
- # seam: body pixels touching the accessory become outline so a hat
147
- # reads as a separate object even in the body's own colour
148
- seam = self.outline(accm[:, None])[:, 0].clamp(0, 1) * body * (1.0 - accm)
149
- body_only = body * (1.0 - accm) * (1.0 - seam)
150
 
151
  union = torch.maximum(body, accm)
152
  dil = self.outline(union[:, None])[:, 0].clamp(0, 1)
@@ -154,7 +149,7 @@ class PixelGen2(PixelGen):
154
 
155
  colour = self._colour(body_only, ramp, self._shade(body)) \
156
  + self._colour(accm, ramp2, self._shade(accm)) \
157
- + (outline + seam) * float(OUTLINE)
158
  idx = colour.round().long()
159
  return F.one_hot(idx, N_COLORS).permute(0, 3, 1, 2).float()
160
 
 
31
  import numpy as np
32
 
33
  SMALL = 12 # 'small' sprites are 12x12 inside the 16x16 grid
 
34
 
35
 
36
  class PixelGen2(PixelGen):
 
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
 
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)
 
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
 
pixelgen/parts.py CHANGED
@@ -65,12 +65,12 @@ PARTS = {
65
  "........",
66
  ],
67
  "helmet": [
 
68
  "........",
69
  ".....###",
70
- "....#...",
71
- "...#....",
72
- "...#.###",
73
- "...#....",
74
  "........",
75
  "........",
76
  "........",
 
65
  "........",
66
  ],
67
  "helmet": [
68
+ "........",
69
  "........",
70
  ".....###",
71
+ "....####",
72
+ "...#####",
73
+ "...##...",
 
74
  "........",
75
  "........",
76
  "........",
pixelgen/templates.py CHANGED
@@ -383,17 +383,17 @@ TEMPLATES = {
383
  "........",
384
  "........",
385
  "........",
386
- ".......#",
387
- "....####",
388
- "....#..#",
389
- "....####",
390
  "......##",
 
391
  "...#####",
392
- "...#.#.#",
393
  "...#####",
394
  "...#####",
395
- "......##",
396
- ".....###",
 
 
 
397
  "........",
398
  "........",
399
  ],
 
383
  "........",
384
  "........",
385
  "........",
 
 
 
 
386
  "......##",
387
+ ".....###",
388
  "...#####",
389
+ "...#.###",
390
  "...#####",
391
  "...#####",
392
+ "....####",
393
+ "..?#####",
394
+ "...#####",
395
+ "...##.##",
396
+ "...##.##",
397
  "........",
398
  "........",
399
  ],
showcase2.png CHANGED
weights/pixelgen.pt CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:8bbbf4581465fe4b3f32f8fe04d32b35c1bc2ca1907c123174f43adc61d2abe6
3
  size 45717
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4c5dffd19634d4b956c08250d25aba435f63c98de4211fb0b16a8e24111540eb
3
  size 45717