Windpaint
Recipes

Game asset set

Generate a matching set of game item icons from one style prompt, pick a candidate for each, cut out the background and pack them into a sprite atlas your engine can load.

This recipe makes a set of inventory icons for a game: a sword, a shield, a potion and so on, all in the same style. Each asset gets one image.generate job with four candidates. You pick one per asset, and the script cuts out the background, scales each icon to a fixed cell size, and packs them into atlas.png with an atlas.json frame map.

Three things keep a set consistent and repeatable:

  • One style template. Every prompt is the same sentence with only the subject swapped in, so medium, outline, lighting and framing stay fixed across the set.
  • A fixed seed per asset. The seed is derived from the asset’s name, so re-running the same prompt gives the same four candidates. Change one asset’s wording and only that asset changes.
  • A manifest. manifest.json records each asset’s request, Idempotency-Key and request_id, so a re-run skips finished assets and a retried submit never bills twice.

z-image-turbo doesn’t output transparency, so the prompt asks for a flat magenta background and the script keys it out locally with Pillow.

Prerequisites

  • An API key exported as WINDPAINT_API_KEY (API keys).
  • Python 3.10+ with httpx and Pillow (pip install httpx pillow).

How it fits together

  1. generate: for each asset, POST /v1/generation/capabilities/image.generate with the style template, num_outputs: 4, aspect_ratio: "1:1" and the asset’s seed. The key and request_id go into the manifest as soon as they exist.
  2. Poll each job’s status until it’s completed, then download every candidate to candidates/.
  3. Look at the candidates and write your choice per asset into picks.json. Anything you don’t pick defaults to the first candidate.
  4. pack: key out the magenta, trim to the object, scale it into a 128 px cell, and write sprites/<name>.png, atlas.png and atlas.json. This step runs locally and costs nothing, so run it as often as you like.

The script

game_assets.py
"""A matching set of game item icons, packed into a sprite atlas. Safe to re-run.

    python game_assets.py generate   # submit, wait, download candidates
    python game_assets.py pack       # cut out picks and build the atlas
"""

import json
import math
import os
import sys
import time
import uuid
import zlib
from pathlib import Path

import httpx
from PIL import Image

API = os.environ.get("WINDPAINT_API_URL", "https://api.windpaint.ai").rstrip("/") + "/v1"
TERMINAL = {"completed", "failed", "nsfw", "canceled"}

STYLE = (
    "hand-painted fantasy RPG inventory icon of {subject}, a single object centered with empty space around it, "
    "three-quarter view, bold dark outline, soft cel shading, warm light from the top left, "
    "isolated on a solid flat pure magenta background, no ground shadow, no text"
)
ASSETS = {
    "sword": "a short iron sword with a leather-wrapped grip",
    "shield": "a round wooden shield with an iron rim",
    "potion": "a small glass bottle of glowing red potion with a cork",
    "scroll": "a rolled parchment scroll tied with a blue ribbon",
    "boots": "a pair of worn brown leather boots",
    "key": "an ornate brass key",
}
CANDIDATES = 4  # num_outputs, 1 to 4, billed per image delivered
CELL = 128  # atlas cell size in px
PADDING = 6
KEY_COLOR = (255, 0, 255)
TOLERANCE = 120  # colour distance below which a pixel counts as background

OUT = Path("assets")
MANIFEST = OUT / "manifest.json"
PICKS = OUT / "picks.json"

api = httpx.Client(base_url=API, headers={"Authorization": f"Bearer {os.environ['WINDPAINT_API_KEY']}"}, timeout=60)


def check(response: httpx.Response) -> dict:
    if response.is_success:
        return response.json()
    try:
        error = response.json()["error"]
    except (ValueError, KeyError):
        error = response.text
    if isinstance(error, dict):
        sys.exit(f"{response.status_code} {error['code']}: {error['message']}")
    sys.exit(f"{response.status_code}: {error}")


def load(path: Path) -> dict:
    return json.loads(path.read_text()) if path.exists() else {}


def save(path: Path, data: dict) -> None:
    path.write_text(json.dumps(data, indent=2))


def seed_for(name: str) -> int:
    return zlib.crc32(name.encode())  # stable across runs and machines


def submit(body: dict, key: str) -> str:
    """Submit, retrying with the same Idempotency-Key so a retry never creates a second job."""
    for attempt in range(5):
        try:
            r = api.post("/generation/capabilities/image.generate", json=body, headers={"Idempotency-Key": key})
        except httpx.TransportError:
            time.sleep(2 ** attempt)  # the job may exist; the same key finds it
            continue
        in_progress = r.status_code == 409 and r.json()["error"]["code"] == "idempotency.in_progress"
        if r.status_code == 429 or r.status_code >= 500 or in_progress:
            time.sleep(float(r.headers.get("Retry-After") or 2 ** attempt))
            continue
        job = check(r)
        print(f"  submitted {job['request_id']} ({job['credits_estimate']} credits held)")
        return job["request_id"]
    sys.exit("submit kept failing; re-run within 24 hours to retry with the same key")


def wait(request_id: str) -> dict:
    delay = 1.5
    while True:
        status = check(api.get(f"/generation/requests/{request_id}/status"))
        if status["status"] in TERMINAL:
            return status
        time.sleep(delay)
        delay = min(delay * 1.5, 15)


def download(asset_id: str, path: Path) -> None:
    url = check(api.get(f"/generation/assets/{asset_id}/url"))["data"]["url"]
    response = httpx.get(url, timeout=300)  # signed URL: no Authorization header
    response.raise_for_status()
    path.write_bytes(response.content)


def generate() -> None:
    (OUT / "candidates").mkdir(parents=True, exist_ok=True)
    manifest = load(MANIFEST)
    for name, subject in ASSETS.items():
        body = {
            "prompt": STYLE.format(subject=subject),
            "aspect_ratio": "1:1",
            "resolution": "1k",
            "num_outputs": CANDIDATES,
            "seed": seed_for(name),
        }
        entry = manifest.get(name)
        if not entry or entry["body"] != body:  # new asset, or its prompt changed
            entry = manifest[name] = {"body": body, "key": str(uuid.uuid4())}
            save(MANIFEST, manifest)  # persist the key before the first attempt
        if not entry.get("request_id"):
            print(name)
            entry["request_id"] = submit(body, entry["key"])
            save(MANIFEST, manifest)

    for name in ASSETS:
        entry = manifest[name]
        if entry.get("files"):
            continue
        status = wait(entry["request_id"])
        if status["status"] != "completed":
            print(f"{name}: {status['status']} ({status['error']}); re-run to try again")
            del manifest[name]  # a new attempt needs a new key; the old one replays this job
            save(MANIFEST, manifest)
            continue
        files = []
        for i, output in enumerate(status["outputs"]):
            path = OUT / "candidates" / f"{name}-{i}.png"
            download(output["id"], path)
            files.append(str(path))
        entry.update(files=files, credits=status["credits"]["actual"])
        save(MANIFEST, manifest)
        print(f"{name}: {len(files)} candidates, {entry['credits']} credits")

    picks = load(PICKS)
    for name in ASSETS:
        picks.setdefault(name, 0)
    save(PICKS, picks)
    print(f"look through {OUT / 'candidates'}, set your choices in {PICKS}, then run: python {sys.argv[0]} pack")


def cut_out(image: Image.Image) -> tuple[Image.Image, float]:
    """Key out the background colour with a soft edge. Returns the sprite and the share of pixels removed."""
    kr, kg, kb = KEY_COLOR
    pixels = []
    removed = 0
    for r, g, b, _ in image.convert("RGBA").getdata():
        distance = abs(r - kr) + abs(g - kg) + abs(b - kb)
        alpha = max(0, min(255, (distance - TOLERANCE) * 255 // TOLERANCE))
        removed += alpha == 0
        pixels.append((r, g, b, alpha))
    sprite = Image.new("RGBA", image.size)
    sprite.putdata(pixels)
    bbox = sprite.getchannel("A").getbbox()
    if bbox:
        sprite = sprite.crop(bbox)
    sprite.thumbnail((CELL - 2 * PADDING, CELL - 2 * PADDING), Image.Resampling.LANCZOS)
    return sprite, removed / (image.width * image.height)


def pack() -> None:
    manifest, picks = load(MANIFEST), load(PICKS)
    names = [n for n in ASSETS if manifest.get(n, {}).get("files")]
    if not names:
        sys.exit(f"nothing to pack; run: python {sys.argv[0]} generate")
    (OUT / "sprites").mkdir(exist_ok=True)
    cols = math.ceil(math.sqrt(len(names)))
    rows = math.ceil(len(names) / cols)
    atlas = Image.new("RGBA", (cols * CELL, rows * CELL))
    frames = {}
    for i, name in enumerate(names):
        files = manifest[name]["files"]
        pick = min(int(picks.get(name, 0)), len(files) - 1)
        sprite, removed = cut_out(Image.open(files[pick]))
        if removed < 0.25:
            print(f"{name}: only {removed:.0%} of the background keyed out; try another candidate")
        cell = Image.new("RGBA", (CELL, CELL))
        cell.paste(sprite, ((CELL - sprite.width) // 2, (CELL - sprite.height) // 2))
        cell.save(OUT / "sprites" / f"{name}.png")
        x, y = (i % cols) * CELL, (i // cols) * CELL
        atlas.paste(cell, (x, y))
        frames[f"{name}.png"] = {
            "frame": {"x": x, "y": y, "w": CELL, "h": CELL},
            "rotated": False,
            "trimmed": False,
            "spriteSourceSize": {"x": 0, "y": 0, "w": CELL, "h": CELL},
            "sourceSize": {"w": CELL, "h": CELL},
        }
    atlas.save(OUT / "atlas.png")
    save(OUT / "atlas.json", {
        "frames": frames,
        "meta": {"image": "atlas.png", "format": "RGBA8888", "size": {"w": atlas.width, "h": atlas.height}, "scale": "1"},
    })
    print(f"packed {len(names)} sprites into {OUT / 'atlas.png'} ({atlas.width}x{atlas.height})")


if __name__ == "__main__":
    commands = {"generate": generate, "pack": pack}
    if len(sys.argv) != 2 or sys.argv[1] not in commands:
        sys.exit(f"usage: python {sys.argv[0]} generate|pack")
    commands[sys.argv[1]]()

Run it

export WINDPAINT_API_KEY=aak_...
python game_assets.py generate
sword
  submitted 0f6c1d2e-8a3b-4c5d-9e7f-1a2b3c4d5e6f (0.32 credits held)
shield
  submitted 7b2e9f10-3c4d-4e5f-8a6b-2c3d4e5f6a7b (0.32 credits held)
...
sword: 4 candidates, 0.32 credits
shield: 4 candidates, 0.32 credits
...
look through assets/candidates, set your choices in assets/picks.json, then run: python game_assets.py pack

Open assets/candidates/, where each asset has <name>-0.png to <name>-3.png. Put the index you want into picks.json:

assets/picks.json
{
  "sword": 2,
  "shield": 0,
  "potion": 1,
  "scroll": 0,
  "boots": 3,
  "key": 0
}

Then pack:

python game_assets.py pack
packed 6 sprites into assets/atlas.png (384x256)

assets/ ends up with:

FileWhat it is
candidates/<name>-<n>.pngEvery generated candidate, 1024 × 1024, on magenta.
sprites/<name>.pngThe picked candidate, cut out and centered in a 128 × 128 transparent cell.
atlas.pngAll sprites in one sheet, one cell per sprite.
atlas.jsonFrame rectangles keyed by <name>.png, in the TexturePacker JSON hash layout that Phaser and PixiJS load directly.
manifest.jsonPer-asset request body, Idempotency-Key, request_id, candidate files and credits.
picks.jsonYour choice per asset.

A second generate does nothing new for finished assets. Add an asset to ASSETS, or change one’s wording, and only that asset is submitted.

In Phaser, for example:

this.load.atlas("items", "assets/atlas.png", "assets/atlas.json");
this.add.image(64, 64, "items", "potion.png");

Getting a clean cut-out

The key is a simple colour-distance test with a soft edge, so it works best when the background really is flat magenta:

  • Check the warning. pack prints a warning when less than a quarter of an image keyed out, which usually means the model painted a scene or a gradient instead of a flat background. Pick another candidate, or reword that asset.
  • Avoid the key colour in the subject. A pink or purple item will lose parts of itself. Switch KEY_COLOR and the prompt to flat green ((0, 255, 0), “solid flat pure green background”) for those, and back to magenta for anything green.
  • Edges. Anti-aliased edges keep a faint magenta tint at partial alpha. At 128 px it’s rarely visible; if it is, raise TOLERANCE or add a despill pass that pulls red and blue down toward green on semi-transparent pixels.
  • Pixel art. For a pixel-art look, ask for it in STYLE and pack at a small CELL such as 32. Then resize the sprites with Image.Resampling.NEAREST rather than LANCZOS, so edges stay hard.

mask.segment, which will cut out a subject without a key colour, is listed in the catalog but has no model yet.

Adjusting the set

  • Your own style. STYLE is the whole look. Keep one sentence for the set and change only {subject}. Name the medium, the view, the outline and the light; Prompting has more on what z-image-turbo responds to.
  • Fewer candidates. Set CANDIDATES = 1 once the style is settled. You pay per image delivered: four candidates at 1k cost 0.32 credits per asset, one costs 0.08.
  • Other shapes. For character portraits, use "aspect_ratio": "3:4" and a non-square CELL. For backgrounds and title screens, skip pack and use the candidates directly at 16:9 or 21:9.
  • Isometric tiles and props. The isometric-illustration preset gives a consistent isometric look without writing the style yourself. It has its own background, so it suits props you place on a scene rather than icons you key out.
  • Animate key art (coming soon). Once video is live, pass a candidate’s asset id as the start_frame of a video.generate job for a 5 second trailer shot or menu loop. See the image to video pipeline.
  • Run them concurrently. generate submits every asset before it waits on any, so jobs already run in parallel up to your concurrent job limit (5 on tier 1). A submit above the limit gets 429 and the script waits and retries. For hundreds of assets, see the batch recipe.

Next steps