diff --git a/art/generated/imp-sprite/PROMPTS.md b/art/generated/imp-sprite/PROMPTS.md new file mode 100644 index 0000000..76d5f91 --- /dev/null +++ b/art/generated/imp-sprite/PROMPTS.md @@ -0,0 +1,48 @@ +# Pit Imp - generated pixel sprite (test for bigger sprites) + +Generated on 2026-11-07 with fal.ai, as a test whether creature sprites bigger than the current +10x10 can come from an image model. The description follows the game's imp (`EK::Worker`, +`sprite_rows` in `src/gfx.rs`): orange skin, black outline, yellow eyes, dark-red tunic, pickaxe. + +## Step 1: sprite sheet + +- Model: `fal-ai/nano-banana-pro` +- Settings: `aspect_ratio: 21:9`, `resolution: 2K`, `num_images: 2`, `output_format: png` +- Results: `raw-1.png` (used: the lower row, compact chibi proportions), `raw-2.png` (more + detailed, too fine for small sprites) + +Prompt: + +```text +Pixel art sprite sheet for a dark-fantasy dungeon strategy game: the "Pit Imp", a small goblin-like worker demon. One single character shown five times in one horizontal row, evenly spaced, same size, same pose (standing, idle), each view in its own equal cell: +1. front view, facing the viewer +2. front three-quarter view, turned to the right +3. side view, facing right +4. back three-quarter view, turned away to the right +5. back view + +The imp: short and stocky, big head, oversized pointed ears and two small black horns, orange skin, glowing yellow eyes, a wide mischievous grin with tiny fangs, a ragged dark-red tunic with a rope belt, bare feet with claws, carrying a small iron pickaxe with a wooden handle over its right shoulder. Thin tail. + +Style: crisp retro pixel art at about 32x32 pixels per character, scaled up with hard square pixels, a one-pixel near-black outline around the figure, limited palette of about 12 colours, flat shading with one highlight and one shadow tone, no anti-aliasing, no gradients, no blur. Flat pure magenta background (#FF00FF) everywhere, no ground, no shadows, no text, no labels, no grid lines. +``` + +## Step 2: pixel grid and palette + +`tools/pixelize_sprite.py` (needs Pillow) removes the magenta background, cuts out the five +views, samples them down to a fixed grid (one scale for all views, standing on the bottom) and +reduces them to one shared palette: + +```bash +python3 tools/pixelize_sprite.py raw-1.png imp --views 5 --size 32 --colors 16 --crop 0,0.27,1,0.72 +python3 tools/pixelize_sprite.py raw-1.png imp --views 5 --size 24 --colors 16 --crop 0,0.27,1,0.72 +``` + +Results: `imp-32.png` / `imp-24.png` (views front, front 3/4, side, back 3/4, back; transparent) +and the 8x previews `imp-32-preview.png` / `imp-24-preview.png`. + +## Findings + +- 32x32 keeps the face (eyes, grin) and the silhouette readable; 24x24 already loses most of the face. +- The pickaxe's iron mostly turns dark; small details would need a manual touch-up. +- The sprites are not used by the game yet: the sprite atlas (`gen_sprites` in `src/gfx.rs`) only + knows 10x10 sprites in 12x12 cells. diff --git a/art/generated/imp-sprite/imp-24-preview.png b/art/generated/imp-sprite/imp-24-preview.png new file mode 100644 index 0000000..3de4b77 Binary files /dev/null and b/art/generated/imp-sprite/imp-24-preview.png differ diff --git a/art/generated/imp-sprite/imp-24.png b/art/generated/imp-sprite/imp-24.png new file mode 100644 index 0000000..713cd84 Binary files /dev/null and b/art/generated/imp-sprite/imp-24.png differ diff --git a/art/generated/imp-sprite/imp-32-preview.png b/art/generated/imp-sprite/imp-32-preview.png new file mode 100644 index 0000000..ad3ab6e Binary files /dev/null and b/art/generated/imp-sprite/imp-32-preview.png differ diff --git a/art/generated/imp-sprite/imp-32.png b/art/generated/imp-sprite/imp-32.png new file mode 100644 index 0000000..bb40b56 Binary files /dev/null and b/art/generated/imp-sprite/imp-32.png differ diff --git a/art/generated/imp-sprite/raw-1.png b/art/generated/imp-sprite/raw-1.png new file mode 100644 index 0000000..13390d3 Binary files /dev/null and b/art/generated/imp-sprite/raw-1.png differ diff --git a/art/generated/imp-sprite/raw-2.png b/art/generated/imp-sprite/raw-2.png new file mode 100644 index 0000000..1a3a887 Binary files /dev/null and b/art/generated/imp-sprite/raw-2.png differ diff --git a/tools/pixelize_sprite.py b/tools/pixelize_sprite.py new file mode 100644 index 0000000..e709e16 --- /dev/null +++ b/tools/pixelize_sprite.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Turn a generated sprite sheet (one figure in several views on a magenta background) into +clean pixel-art sprites: background removed, every view cut out, sampled down to a fixed pixel +grid and reduced to one shared palette. + +Usage: + pixelize_sprite.py raw.png out_prefix --views 5 --size 24 [--crop x0,y0,x1,y1] [--colors 16] + +--crop takes fractions of the image (0..1) to use only a part of it (e.g. one row of a sheet). +Writes out_prefix-.png (the views side by side, size x size each, transparent) and +out_prefix--preview.png (scaled up 8x on a dark background for viewing). +Needs Pillow. +""" +import argparse +import math +from collections import Counter + +from PIL import Image + + +def is_background(p): + r, g, b = p[:3] + # magenta and its darker/greyer purple variants (sheet background and frame lines) + magenta = r > g + 50 and b > g + 50 + frame = abs(r - b) < 40 and r > g + 12 and b > g + 12 and r > 55 + return magenta or frame + + +def figure_boxes(img, views): + w, h = img.size + px = img.load() + mask = [[not is_background(px[x, y]) for x in range(w)] for y in range(h)] + boxes = [] + for v in range(views): + x0, x1 = v * w // views, (v + 1) * w // views + cols = [x for x in range(x0, x1) if sum(mask[y][x] for y in range(h)) > 3] + rows = [y for y in range(h) if sum(mask[y][x] for x in range(x0, x1)) > 3] + boxes.append((min(cols), min(rows), max(cols) + 1, max(rows) + 1)) + return mask, boxes + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("raw") + ap.add_argument("out") + ap.add_argument("--views", type=int, default=5) + ap.add_argument("--size", type=int, default=24) + ap.add_argument("--colors", type=int, default=16) + ap.add_argument("--crop", default=None) + a = ap.parse_args() + + img = Image.open(a.raw).convert("RGB") + if a.crop: + f = [float(v) for v in a.crop.split(",")] + w, h = img.size + img = img.crop((int(f[0] * w), int(f[1] * h), int(f[2] * w), int(f[3] * h))) + mask, boxes = figure_boxes(img, a.views) + px = img.load() + # one scale for all views: the tallest or widest figure fills the cell + step = max(max(b[3] - b[1], b[2] - b[0]) for b in boxes) / a.size + + grids = [] + for (x0, y0, x1, y1) in boxes: + gw, gh = round((x1 - x0) / step), round((y1 - y0) / step) + grid = [[None] * a.size for _ in range(a.size)] + ox, oy = (a.size - gw) // 2, a.size - gh # centred, standing on the bottom + for gy in range(gh): + for gx in range(gw): + # the commonest colour of the block's inner part (edges blur between pixels) + cx0, cy0 = x0 + gx * step, y0 + gy * step + votes, bg = Counter(), 0 + for sy in range(4): + for sx in range(4): + x = int(cx0 + (sx + 0.5) * step / 4) + y = int(cy0 + (sy + 0.5) * step / 4) + if x >= img.size[0] or y >= img.size[1] or not mask[y][x]: + bg += 1 + else: + r, g, b = px[x, y] + votes[(r // 8 * 8, g // 8 * 8, b // 8 * 8)] += 1 + if votes and sum(votes.values()) >= bg and 0 <= gy + oy < a.size and 0 <= gx + ox < a.size: + grid[gy + oy][gx + ox] = votes.most_common(1)[0][0] + grids.append(grid) + + # one shared palette for all views: farthest-point picking weighted by how often a colour + # occurs, so small but distinct details (eyes, the tool's iron) keep their own colour + # instead of melting into the large areas + used = [c for g in grids for row in g for c in row if c] + counts = Counter(used) + cands = [c for c, n in counts.items() if n >= 3] or list(counts) + dist = lambda p, q: sum((x - y) ** 2 for x, y in zip(p, q)) ** 0.5 + palette = [counts.most_common(1)[0][0]] + while len(palette) < min(a.colors, len(cands)): + palette.append(max(cands, key=lambda c: min(dist(c, q) for q in palette) * math.log(1 + counts[c]))) + near = {c: min(palette, key=lambda q: dist(c, q)) for c in counts} + + sheet = Image.new("RGBA", (a.size * a.views, a.size), (0, 0, 0, 0)) + for v, g in enumerate(grids): + for y, row in enumerate(g): + for x, c in enumerate(row): + if c: + sheet.putpixel((v * a.size + x, y), near[c] + (255,)) + sheet.save(f"{a.out}-{a.size}.png") + prev = Image.new("RGBA", sheet.size, (96, 92, 104, 255)) + prev.alpha_composite(sheet) + prev.resize((sheet.width * 8, sheet.height * 8), Image.NEAREST).save(f"{a.out}-{a.size}-preview.png") + print(f"{a.out}-{a.size}.png: {a.views} views, {a.size}px, {len(set(near.values()))} colours") + + +if __name__ == "__main__": + main()