revert to 6fbb543
Browse files- README.md +9 -9
- experiments/README.md +6 -6
- experiments/shading_before_after.png +0 -0
- pixelgen/compose.py +4 -9
- pixelgen/parts.py +4 -4
- pixelgen/templates.py +7 -7
- showcase2.png +0 -0
- weights/pixelgen.pt +1 -1
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
|
| 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 |

|
| 32 |
-

|
| 34 |

|
| 35 |

|
|
@@ -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 | **
|
| 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
|
| 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 |
|
| 109 |
| Output | 16×16 → 128×128 RGB, every pixel ∈ PICO-8 | same |
|
| 110 |
-
| `weights/pixelgen.pt` (v1) | 45.7 KB, SHA-256 `
|
| 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, **
|
| 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)
|
| 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 |
-
- **
|
| 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 |

|
| 32 |
+

|
| 33 |

|
| 34 |

|
| 35 |

|
|
|
|
| 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? |
|
| 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 ->
|
| 73 |
hand-drawn content: 21 bodies + 7 parts = 28 templates
|
| 74 |
-
distinct images per template: 9 ->
|
| 75 |
-
param growth 1.60x output growth
|
| 76 |
```
|
| 77 |
|
| 78 |
-
(The
|
| 79 |
|
| 80 |
### Quality, honestly
|
| 81 |
|
| 82 |

|
| 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
|
| 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 |

|
| 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 |
-
|
| 108 |
-
|
| 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 |
-
|
| 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 |
-
+
|
| 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:
|
| 3 |
size 45717
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4c5dffd19634d4b956c08250d25aba435f63c98de4211fb0b16a8e24111540eb
|
| 3 |
size 45717
|