File size: 7,073 Bytes
057c4d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a292e13
057c4d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a292e13
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
"""PixelGen demo Space — a text-to-pixel-art model with 1,257 hand-written
parameters and zero training steps.

The `pixelgen/` package and the `weights/pixelgen.pt` checkpoint are vendored
from https://huggingface.co/DJLougen/pixelgen (MIT). The demo mirrors the
reference implementation (`PixelGen.sprite` / `generate.py`) 1:1: same
tokenizer, same forward pass, same nearest-neighbour upscale — it only adds a
UI. The model is tiny and CPU-bound, so inference runs directly in the web
process; `@spaces.GPU` stays as a portable no-op in case the hardware is ever
moved to ZeroGPU.
"""

import spaces  # imported first; a no-op off ZeroGPU

import gradio as gr
from PIL import Image

from pixelgen import weights as weights_io
from pixelgen.model import PixelGen
from pixelgen.palette import COLOR_WORDS, RAMP_NAMES
from pixelgen.templates import CLASSES
from pixelgen.tokenizer import CLASS_SYNONYMS

MODEL_ID = "DJLougen/pixelgen"
DEFAULT_SEED = 0
DEFAULT_SCALE = 8  # 16 x 8 -> 128x128 PNG, the model's default output


def _load_model() -> PixelGen:
    """Load the shipped checkpoint; fall back to rebuilding weights in code.

    `PixelGen()` sets every weight deterministically in `__init__`, so both
    paths produce bit-identical sprites (the repo's round-trip test covers
    this) — the fallback only guards against a missing/corrupt file.
    """
    try:
        model = weights_io.load()  # resolves to weights/pixelgen.pt
        print(f"loaded {MODEL_ID} from weights/pixelgen.pt")
    except Exception as exc:  # pragma: no cover - robustness fallback
        print(f"checkpoint load failed ({exc!r}); rebuilding weights from source")
        model = PixelGen()
    return model


MODEL = _load_model()
N_PARAMS = sum(p.numel() for p in MODEL.parameters())


@spaces.GPU(duration=30)
def generate(prompt: str, seed: int = DEFAULT_SEED, scale: int = DEFAULT_SCALE):
    """Generate a PICO-8 pixel-art sprite from a short text prompt.

    Args:
        prompt: A colour word (red, blue, green, yellow, purple, orange, pink,
            brown, white — plus synonyms such as gold, crimson, emerald) and a
            class word (potion, sword, shield, ship, invader, slime, skull,
            tree, key, heart, star — plus synonyms such as alien, blade).
            For example "red potion" or "golden key". Unknown words are
            ignored; a prompt with no known class renders the default sprite
            (a green potion).
        seed: Selects the hand-marked "?" pixels, giving small variations of
            the same sprite across seeds.
        scale: Nearest-neighbour upscale factor; the output PNG is
            16*scale x 16*scale pixels (default 8 -> 128x128).

    Returns:
        (sprite, seed_strip): the sprite as a PIL image, plus the same prompt
        rendered at seeds 0-7 to show the seed variation.
    """
    seed, scale = int(seed), int(scale)
    sprite = Image.fromarray(MODEL.generate([prompt], [seed], scale=scale)[0], "RGB")
    strip_arr = MODEL.generate([prompt] * 8, list(range(8)), scale=scale)
    strip = [Image.fromarray(arr, "RGB") for arr in strip_arr]
    return sprite, strip


def build_prompt(body: str, colour: str) -> str:
    """Compose a prompt like 'red potion' from the two dropdowns."""
    return f"{colour} {body}"


def vocab_markdown() -> str:
    """Render the model's fixed 59-word vocabulary as reference tables."""
    cls_rows = "\n".join(f"| {c} | {', '.join(ws)} |" for c, ws in CLASS_SYNONYMS.items())
    by_ramp: dict = {}
    for word, ramp in COLOR_WORDS.items():
        by_ramp.setdefault(ramp, []).append(word)
    col_rows = "\n".join(f"| {r} | {', '.join(ws)} |" for r, ws in by_ramp.items())
    return (
        "Prompts are a bag of words: every known word counts, everything else is "
        "ignored. A prompt with **no known class word** falls back to the default "
        "class (**potion**) and one with **no known colour word** to the default "
        "ramp (**green**) — so `\"dragon\"` gives you a green potion.\n\n"
        "### Sprite classes\n"
        "| class | words |\n|---|---|\n"
        f"{cls_rows}\n\n"
        "### Colour ramps\n"
        "| colour | words |\n|---|---|\n"
        f"{col_rows}"
    )


CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
.gradio-container img { image-rendering: pixelated; }
"""

EXAMPLES = [
    ["red potion", 0],
    ["golden key", 3],
    ["blue invader", 0],
    ["purple slime", 4],
]

with gr.Blocks(title="PixelGen") as demo:
    gr.Markdown(
        f"# 🕹️ PixelGen\n"
        f"A text-to-pixel-art model with **{N_PARAMS:,} parameters — zero of them "
        f"trained**. Every weight was typed in by hand: prompts like "
        f"`\"red potion\"` or `\"blue invader\"` become 16×16 PICO-8 sprites, "
        f"upscaled to crisp pixel art. "
        f"[Model card](https://huggingface.co/{MODEL_ID}) · "
        f"[weight-space programming](https://rikiyat.github.io/blog/weight-space-programming/)"
    )
    with gr.Column(elem_id="col-container"):
        with gr.Row():
            with gr.Column(scale=1):
                prompt = gr.Textbox(
                    label="Prompt", value="red potion", placeholder="e.g. blue invader"
                )
                with gr.Accordion("…or build one", open=False):
                    body_dd = gr.Dropdown(CLASSES, value="potion", label="sprite class")
                    colour_dd = gr.Dropdown(RAMP_NAMES, value="red", label="colour")
                    for dd in (body_dd, colour_dd):
                        dd.change(build_prompt, [body_dd, colour_dd], prompt)
                seed = gr.Slider(0, 99, value=DEFAULT_SEED, step=1, label="seed")
                scale = gr.Slider(
                    4, 16, value=DEFAULT_SCALE, step=1,
                    label="scale (output is 16×scale px)",
                )
                go = gr.Button("Generate", variant="primary")
            with gr.Column(scale=1):
                out = gr.Image(label="sprite", type="pil", height=300)
        gr.Markdown(
            "**Same prompt, seeds 0–7** — a seed only toggles the hand-marked "
            "`?` pixels, so seeds give small variations of the same sprite."
        )
        strip = gr.Gallery(columns=8, height=150, label="seeds")
        with gr.Accordion("Prompt vocabulary", open=False):
            gr.Markdown(vocab_markdown())
        gr.Examples(
            examples=EXAMPLES,
            inputs=[prompt, seed],
            outputs=[out, strip],
            fn=generate,
            cache_examples=True,
            cache_mode="lazy",
        )

    go.click(generate, [prompt, seed, scale], [out, strip], api_name="generate")
    prompt.submit(generate, [prompt, seed, scale], [out, strip])
    demo.load(generate, [prompt, seed, scale], [out, strip])

if __name__ == "__main__":
    # Gradio 6: theme/css are launch() parameters, not Blocks constructor ones.
    demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)