DJLougen commited on
Commit
50942d8
·
verified ·
1 Parent(s): dd06946

PixelGen2 demo

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
README.md CHANGED
@@ -1,13 +1,18 @@
1
  ---
2
- title: Pixelgen2
3
- emoji: 🌖
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.31.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ title: PixelGen2
3
+ emoji: 🕹️
4
+ colorFrom: green
5
+ colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.31.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: 6,747 hand-written params, 0 training steps
12
+ models:
13
+ - DJLougen/pixelgen2
14
  ---
15
 
16
+ # PixelGen2
17
+
18
+ Text-to-pixel-art with hand-written weights and zero training steps. Model card, code and experiments: [DJLougen/pixelgen2](https://huggingface.co/DJLougen/pixelgen2).
app.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PixelGen2 demo Space: hand-written weights, zero training."""
2
+
3
+ import gradio as gr
4
+ from PIL import Image
5
+
6
+ from pixelgen.compose import PixelGen2
7
+ from pixelgen.model import PixelGen
8
+ from pixelgen.palette import RAMP_NAMES
9
+ from pixelgen.parts import ACCESSORIES
10
+ from pixelgen.templates import CLASSES
11
+
12
+ V2, V1 = PixelGen2(), PixelGen()
13
+ N_PARAMS = sum(p.numel() for p in V2.parameters())
14
+
15
+
16
+ def render(prompt, seed, scale, model):
17
+ m = V1 if model == "v1 (original)" else V2
18
+ return Image.fromarray(m.generate([prompt], [int(seed)], scale=int(scale))[0])
19
+
20
+
21
+ def seeds(prompt, scale):
22
+ return [Image.fromarray(V2.generate([prompt], [s], scale=int(scale))[0]) for s in range(8)]
23
+
24
+
25
+ def build(body, colour, acc, acc_colour, small):
26
+ p = f"{'small ' if small else ''}{colour} {body}"
27
+ if acc != "none":
28
+ p += f" with {acc_colour} {acc}"
29
+ return p
30
+
31
+
32
+ EXAMPLES = [
33
+ ["green slime with gold crown", 0], ["purple ghost with horns", 3], ["red potion", 0],
34
+ ["small blue invader", 1], ["angel skull with white halo", 2], ["knight robot", 0],
35
+ ["bat with orange fire", 5], ["martian alien", 0], ["pink cherry", 4],
36
+ ]
37
+
38
+ with gr.Blocks(title="PixelGen2") as demo:
39
+ gr.Markdown(
40
+ f"# 🕹️ PixelGen2\n"
41
+ f"A text-to-pixel-art model with **{N_PARAMS:,} parameters, zero of them trained**. "
42
+ f"Every weight was written by hand; it composes 21 bodies × 7 accessories × 9 colours into 21,866 distinct sprites. "
43
+ f"[Model card & experiments](https://huggingface.co/DJLougen/pixelgen2) · "
44
+ f"[weight-space programming](https://rikiyat.github.io/blog/weight-space-programming/)\n\n"
45
+ f"Grammar: `[small] [colour] <body> [with [colour] <accessory>]`"
46
+ )
47
+ with gr.Row():
48
+ with gr.Column(scale=1):
49
+ prompt = gr.Textbox(label="Prompt", value="green slime with gold crown")
50
+ with gr.Accordion("…or build one", open=False):
51
+ body = gr.Dropdown(CLASSES, value="slime", label="body")
52
+ colour = gr.Dropdown(RAMP_NAMES, value="green", label="colour")
53
+ acc = gr.Dropdown(ACCESSORIES, value="crown", label="accessory")
54
+ acc_colour = gr.Dropdown(RAMP_NAMES, value="yellow", label="accessory colour")
55
+ small = gr.Checkbox(label="small")
56
+ for c in (body, colour, acc, acc_colour, small):
57
+ c.change(build, [body, colour, acc, acc_colour, small], prompt)
58
+ seed = gr.Slider(0, 99, value=0, step=1, label="seed")
59
+ scale = gr.Slider(4, 16, value=12, step=1, label="scale")
60
+ model = gr.Radio(["v2 (compositional)", "v1 (original)"], value="v2 (compositional)", label="model")
61
+ go = gr.Button("Generate", variant="primary")
62
+ with gr.Column(scale=1):
63
+ out = gr.Image(label="sprite", type="pil", height=300)
64
+ gr.Markdown("**Same prompt, seeds 0–7** — variation only toggles the hand-marked `?` pixels.")
65
+ strip = gr.Gallery(columns=8, height=140, label="seeds")
66
+ gr.Examples(EXAMPLES, [prompt, seed])
67
+
68
+ inputs = [prompt, seed, scale, model]
69
+ go.click(render, inputs, out).then(seeds, [prompt, scale], strip)
70
+ prompt.submit(render, inputs, out).then(seeds, [prompt, scale], strip)
71
+ demo.load(render, inputs, out).then(seeds, [prompt, scale], strip)
72
+
73
+ if __name__ == "__main__":
74
+ demo.launch()
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