#!/usr/bin/env python3 """Player and NPC art pipeline (PixelLab). Architecture (see docs/CHARACTER_ART.md): 1. One base body per gender: `create-character-with-4-directions` (bald, plain white tee, grey shorts, barefoot, light skin), then `walking-4-frames` template animation for south, east and north. West is east mirrored at runtime. Every body has the same 15-frame layout: 3 directions x [standing, walk 0..3]. 2. Every clothing / hair item is ONE `edit-animation-v2` call per body on all 15 base frames at once ("...in bright pure green..."). The edit keeps the motion, so every edited frame lines up pixel-for-pixel with its base frame. 3. `build` extracts each item from its edit by the stand-in green hue: those pixels become a recolorable shade layer (shade index 1..4), the dark outline and small details around them become fixed-color pixels. The base body is split the same way into skin / tee / shorts shade layers plus fixed pixels (outline, eyes). 4. The game composites body + items per frame and maps shade indices to the chosen colors, so skin tone, colors and every combination of items cost no further API calls. Usage: python tools/character_art.py bases # base bodies + walk animations (cached) python tools/character_art.py items [ids..] # missing item edits (20 generations each per body) python tools/character_art.py henry # Professor Henry NPC + walk + portrait python tools/character_art.py build # extract layers -> assets/characters/, manifest python tools/character_art.py preview # contact sheets in art_src/preview/ """ import json import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import numpy as np from PIL import Image sys.path.insert(0, str(Path(__file__).resolve().parent)) import pixellab_client as plc # noqa: E402 ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "art_src" / "characters" OUT = ROOT / "assets" / "characters" PREVIEW = ROOT / "art_src" / "preview" CANVAS = 68 # PixelLab canvas for 48 px characters CELL = 64 # stored frame size (crop keeps edit calls in the 16-frame tier) CROP = (2, 2) # top-left of the 64x64 crop inside the 68x68 canvas DIRS = ["south", "east", "north"] WALK_FRAMES = 4 FRAMES_PER_DIR = 1 + WALK_FRAMES # standing + walk cycle SHADES = 4 # recolorable shade levels (1 = darkest) BODIES = { "male": { "label": "Masculine", "description": ("young adult man, bald shaved head, kind smiling face with big friendly eyes, plain white " "t-shirt, plain grey shorts, barefoot, light skin, pokemon trainer overworld sprite style"), "seed": 1202, "pron": "his", "obj": "him", }, "female": { "label": "Feminine", "description": ("young adult woman, bald shaved head, kind smiling face with big friendly eyes and eyelashes, " "plain white t-shirt, plain grey shorts, barefoot, light skin, pokemon trainer overworld " "sprite style"), "seed": 2202, "pron": "her", "obj": "her", }, } GREEN = "bright pure green" KEEP = "; keep the face, body, pose and everything else exactly identical" # slot -> z order (higher draws later). The base body is z 0. SLOTS = {"shoes": 10, "bottom": 20, "top": 30, "hair": 40, "hat": 50} # Items generated with PixelLab. `prompt` is formatted with pron/obj; `color` is the default. ITEMS = { "hair_short": ("hair", "Short & Messy", "#5a3a22", "give {obj} short messy hair colored " + GREEN), "hair_spiky": ("hair", "Spiky", "#2b2b33", "give {obj} spiky anime-style hair colored " + GREEN), "hair_long": ("hair", "Long", "#7a4a26", "give {obj} long straight hair down past the shoulders, colored " + GREEN), "hair_ponytail": ("hair", "Ponytail", "#c8902e", "give {obj} hair tied back in a high ponytail, colored " + GREEN), "hair_curly": ("hair", "Curly", "#3a2418", "give {obj} big round curly afro hair colored " + GREEN), "hat_cap": ("hat", "Cap", "#c0392b", "put a " + GREEN + " baseball cap on {pron} head, brim facing forward"), "hat_bucket": ("hat", "Safari Hat", "#b89a5e", "put a " + GREEN + " safari bucket hat with a wide round brim on {pron} head"), "hat_beanie": ("hat", "Beanie", "#2e6da4", "put a " + GREEN + " knit beanie on {pron} head"), "top_hoodie": ("top", "Hoodie", "#4a6fa5", "change {pron} t-shirt into a " + GREEN + " hoodie sweatshirt with long sleeves, hood down"), "top_jacket": ("top", "Jacket", "#a33b3b", "dress {obj} in an open " + GREEN + " zip-up jacket with long sleeves over the white t-shirt"), "top_vest": ("top", "Explorer Vest", "#8a7a4a", "dress {obj} in a " + GREEN + " safari explorer vest with pockets over the white t-shirt"), "top_sweater": ("top", "Sweater", "#6b4c8a", "change {pron} t-shirt into a " + GREEN + " long-sleeved knit sweater"), "bottom_pants": ("bottom", "Long Pants", "#34495e", "change {pron} grey shorts into " + GREEN + " long straight pants that reach the ankles"), "bottom_cargo": ("bottom", "Cargo Pants", "#6d6447", "change {pron} grey shorts into baggy " + GREEN + " cargo pants with side pockets that reach the ankles"), "bottom_skirt": ("bottom", "Skirt", "#9b3d6b", "change {pron} grey shorts into a " + GREEN + " pleated knee-length skirt"), "shoes_sneakers": ("shoes", "Sneakers", "#e8e8e8", "put " + GREEN + " sneakers with white soles on {pron} bare feet"), "shoes_boots": ("shoes", "Boots", "#5b3a1e", "put " + GREEN + " ankle-high hiking boots on {pron} bare feet"), "shoes_sandals": ("shoes", "Sandals", "#a0703c", "put " + GREEN + " strappy sandals on {pron} bare feet"), } # Free items carved out of the base body itself (no API calls). BASE_ITEMS = { "top_tee": ("top", "T-Shirt", "#f2f2f2"), "bottom_shorts": ("bottom", "Shorts", "#6b6b6b"), } SKIN_TONES = ["#ffdcc2", "#f1c27d", "#e0ac69", "#c68642", "#8d5524", "#5c3a21"] PALETTE = ["#f2f2f2", "#2b2b33", "#6b6b6b", "#c0392b", "#e67e22", "#f1c40f", "#27ae60", "#16a085", "#2e6da4", "#4a6fa5", "#6b4c8a", "#9b3d6b", "#e07aa5", "#5a3a22", "#7a4a26", "#c8902e", "#b89a5e", "#6d6447", "#3a2418", "#d9d0b0"] HENRY = { "description": ("kindly elderly professor named Henry, neat grey hair and a short grey beard, round glasses, " "long white lab coat over a green sweater vest, brown trousers, brown shoes, holding nothing, " "pokemon professor overworld sprite style"), "seed": 4242, "portrait": ("portrait bust of a kindly elderly professor with neat grey hair, short grey beard, round glasses, " "white lab coat over a green sweater vest, warm smile, facing the viewer, detailed pixel art, " "transparent background"), } # ----------------------------------------------------------------- helpers def save_json(path, obj): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(obj, indent=1)) def load(path): return Image.open(path).convert("RGBA") def frame_img(im): return {"image": plc.b64(im), "size": {"width": im.width, "height": im.height}} def crop(im): x, y = CROP return im.crop((x, y, x + CELL, y + CELL)) def create_character(client, name, description, seed): d = SRC / name meta = d / "character.json" if meta.exists(): return json.loads(meta.read_text())["character_id"] payload = {"description": description, "image_size": {"width": 48, "height": 48}, "view": "low top-down", "proportions": {"type": "preset", "name": "chibi"}, "outline": "single color black outline", "shading": "basic shading", "detail": "medium detail", "seed": seed} _, res = client.run("create-character-with-4-directions", payload, f"{name}/character") r = res[0] d.mkdir(parents=True, exist_ok=True) for dirn in ["south", "east", "north", "west"]: plc.decode(r["images"][dirn]).save(d / f"rot_{dirn}.png") save_json(meta, {"character_id": r["character_id"], "payload": payload}) print(f"[ok] {name}: character {r['character_id']}") return r["character_id"] def animate_walk(client, name, char_id): d = SRC / name / "walk4" if all((d / f"{dirn}_{WALK_FRAMES - 1}.png").exists() for dirn in DIRS): return missing = [dirn for dirn in DIRS if not (d / f"{dirn}_{WALK_FRAMES - 1}.png").exists()] _, res = client.run("characters/animations", {"character_id": char_id, "mode": "template", "template_animation_id": "walking-4-frames", "animation_name": "walk", "directions": missing}, f"{name}/walk4") d.mkdir(parents=True, exist_ok=True) for r in res: frames = plc.images_in(r["images"]) if len(frames) != WALK_FRAMES: raise plc.ApiError(f"{name} {r['direction']}: expected {WALK_FRAMES} frames, got {len(frames)}") for i, im in enumerate(frames): im.save(d / f"{r['direction']}_{i}.png") print(f"[ok] {name}: walk {missing}") def base_frames(name): """The 15 base frames (68x68) in sheet order: per direction [standing, walk0..3].""" out = [] for dirn in DIRS: out.append(load(SRC / name / f"rot_{dirn}.png")) out += [load(SRC / name / "walk4" / f"{dirn}_{i}.png") for i in range(WALK_FRAMES)] return out def edit_item(client, body, item_id): d = SRC / body / "items" / item_id if (d / "frame_14.png").exists(): return False spec = BODIES[body] _, _, _, prompt = ITEMS[item_id] desc = prompt.format(pron=spec["pron"], obj=spec["obj"]) + KEEP frames = [crop(f) for f in base_frames(body)] _, res = client.run("edit-animation-v2", {"description": desc, "frames": [frame_img(f) for f in frames], "image_size": {"width": CELL, "height": CELL}, "no_background": True, "seed": abs(hash(item_id)) % 100000}, f"{body}/{item_id}") r = res[0] imgs = [plc.decode(x) for x in r.get("images", [])] if len(imgs) != len(frames): raise plc.ApiError(f"{body}/{item_id}: expected {len(frames)} frames, got {len(imgs)}") d.mkdir(parents=True, exist_ok=True) for i, im in enumerate(imgs): im.save(d / f"frame_{i}.png") save_json(d / "edit.json", {"description": desc, "seed": r.get("seed")}) print(f"[ok] {body}/{item_id}") return True # ----------------------------------------------------------------- extraction def hsv(a): rgb = a[..., :3] / 255.0 mx, mn = rgb.max(-1), rgb.min(-1) d = mx - mn h = np.zeros_like(mx) r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] m = d > 1e-6 hr = (mx == r) & m hg = (mx == g) & m & ~hr hb = m & ~hr & ~hg h[hr] = ((g - b)[hr] / d[hr]) % 6 h[hg] = ((b - r)[hg] / d[hg]) + 2 h[hb] = ((r - g)[hb] / d[hb]) + 4 return h * 60.0, np.where(mx > 0, d / np.maximum(mx, 1e-6), 0.0), mx def luma(a): return 0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2] def dilate(m, it=1): for _ in range(it): p = np.pad(m, 1) o = np.zeros_like(m) for dy in range(3): for dx in range(3): o |= p[dy:dy + m.shape[0], dx:dx + m.shape[1]] m = o return m def shade_levels(lum_values, mask): """Quantize luminance inside `mask` to SHADES levels (1..SHADES). `lum_values`/`mask` are (frames, h, w). Each frame's luminance is divided by its own median, so the most common tone becomes the base shade (3) and frames the AI rendered a little brighter or darker still get the same bands. Fixed thresholds (not quantiles) keep large flat areas stable.""" out = np.zeros(mask.shape, np.uint8) if not mask.any(): return out norm = np.zeros(lum_values.shape, float) for i in range(mask.shape[0]): if mask[i].any(): norm[i] = lum_values[i] / max(np.median(lum_values[i][mask[i]]), 1.0) lv = 1 + np.searchsorted(np.array([0.70, 0.88, 1.08]), norm, side="right") out[mask] = np.clip(lv[mask], 1, SHADES) return out def extract_item(base, edit): """(shade mask uint8, fixed RGBA) for one frame. base/edit are 64x64 RGBA arrays.""" h, s, v = hsv(edit.astype(float)) ea, ba = edit[..., 3] > 127, base[..., 3] > 127 changed = ea & (~ba | (np.abs(edit[..., :3].astype(int) - base[..., :3].astype(int)).sum(-1) > 40)) tint = ea & (h >= 65) & (h <= 170) & (s > 0.25) & (v > 0.12) # drop specks: tint pixels with no tint neighbour p = np.pad(tint, 1) neigh = sum(p[dy:dy + 64, dx:dx + 64].astype(int) for dy in range(3) for dx in range(3)) - tint tint &= neigh >= 1 lum = luma(edit.astype(float)) outline = changed & dilate(tint, 1) & ~tint & (lum < 70) detail = changed & dilate(tint, 1) & ~tint & ~outline & ea fixed = np.zeros_like(edit) for m in (outline, detail): fixed[m] = edit[m] fixed[m, 3] = 255 return tint, lum, fixed def classify_body(frame): """Split a base frame into skin / tee / shorts shade masks and fixed pixels.""" h, s, v = hsv(frame.astype(float)) a = frame[..., 3] > 127 lum = luma(frame.astype(float)) skin = a & (((h <= 45) | (h >= 340)) & (s > 0.12) & (v > 0.35)) neutral = a & (s < 0.12) & ~skin ys = np.arange(64)[:, None].repeat(64, 1) tee = neutral & (lum >= 150) shorts = neutral & (lum >= 55) & (lum < 150) # shorts sit below the shirt; grey shading pixels inside the shirt belong to the shirt if tee.any(): tee_rows = ys[tee] hem = np.percentile(tee_rows, 92) shorts_upper = shorts & (ys < hem - 1) tee |= shorts_upper & dilate(tee, 1) shorts &= ~tee fixed = frame.copy() fixed[skin | tee | shorts] = 0 fixed[~a] = 0 return {"skin": skin, "top_tee": tee, "bottom_shorts": shorts}, lum, fixed def sheet(frames): cols = FRAMES_PER_DIR out = np.zeros((CELL * len(DIRS), CELL * cols, 4), np.uint8) for i, f in enumerate(frames): y, x = divmod(i, cols) out[y * CELL:(y + 1) * CELL, x * CELL:(x + 1) * CELL] = f return out def shade_png(levels): """Shade index stored in R (index * 60), alpha 255 where tinted.""" out = np.zeros(levels.shape + (4,), np.uint8) m = levels > 0 out[m, 0] = levels[m] * 60 out[m, 3] = 255 return out def build_body(name): frames = [np.asarray(crop(f)) for f in base_frames(name)] masks = {k: [] for k in ("skin", "top_tee", "bottom_shorts")} lums, fixed = [], [] for f in frames: m, lum, fx = classify_body(f) for k in masks: masks[k].append(m[k]) lums.append(lum) fixed.append(fx) d = OUT / name d.mkdir(parents=True, exist_ok=True) Image.fromarray(sheet(fixed)).save(d / "body.png") for k, ms in masks.items(): stack_m, stack_l = np.stack(ms), np.stack(lums) lv = shade_levels(stack_l, stack_m) Image.fromarray(sheet([shade_png(lv[i]) for i in range(len(frames))])).save( d / ("skin_shade.png" if k == "skin" else f"{k}_shade.png")) if k != "skin": Image.fromarray(np.zeros((CELL * len(DIRS), CELL * FRAMES_PER_DIR, 4), np.uint8)).save(d / f"{k}.png") return frames def build_item(body, item_id, base): d = SRC / body / "items" / item_id if not (d / "frame_14.png").exists(): return False edits = [np.asarray(load(d / f"frame_{i}.png")) for i in range(len(base))] tints, lums, fixed = [], [], [] clip_tops = ITEMS[item_id][0] == "top" for b, e in zip(base, edits): t, lum, fx = extract_item(b, e) if clip_tops: # Tops end at the waist: the edit model sometimes paints the shorts / legs too # (a sweater turning into a robe). Drop anything 2+ rows below the shorts' top edge. shorts = classify_body(b)[0]["bottom_shorts"] if shorts.any(): waist = int(np.nonzero(shorts.any(axis=1))[0].min()) + 2 t[waist:] = False fx[waist:] = 0 tints.append(t) lums.append(lum) fixed.append(fx) lv = shade_levels(np.stack(lums), np.stack(tints)) out = OUT / body Image.fromarray(sheet(fixed)).save(out / f"{item_id}.png") Image.fromarray(sheet([shade_png(lv[i]) for i in range(len(base))])).save(out / f"{item_id}_shade.png") return True def anchor_row(name): """Lowest opaque row (from the top of a 64 px cell) over every standing/walk frame.""" body = np.asarray(Image.open(OUT / name / "body.png")) skin = np.asarray(Image.open(OUT / name / "skin_shade.png")) a = (body[..., 3] > 127) | (skin[..., 3] > 127) rows = np.nonzero(a.reshape(len(DIRS), CELL, FRAMES_PER_DIR, CELL).any(axis=(0, 2, 3)))[0] return int(rows.max()) def build(): manifest = { "cell": CELL, "directions": DIRS, "framesPerDirection": FRAMES_PER_DIR, "walkFrames": WALK_FRAMES, "shades": SHADES, "note": "row = direction, column 0 = standing, 1.. = walk; west = east mirrored", "slots": SLOTS, "skinTones": SKIN_TONES, "palette": PALETTE, "bodies": {}, "items": {}, } for name, spec in BODIES.items(): if not (SRC / name / "walk4" / f"north_{WALK_FRAMES - 1}.png").exists(): print(f"[skip] {name}: no base art yet") continue base = build_body(name) have = [k for k in BASE_ITEMS] for item_id in ITEMS: if build_item(name, item_id, base): have.append(item_id) manifest["bodies"][name] = {"label": spec["label"], "anchorRow": anchor_row(name), "items": have} for item_id, (slot, label, color) in BASE_ITEMS.items(): manifest["items"][item_id] = {"slot": slot, "label": label, "color": color, "fromBody": True} for item_id, (slot, label, color, _) in ITEMS.items(): manifest["items"][item_id] = {"slot": slot, "label": label, "color": color} if (OUT / "henry" / "henry.png").exists(): manifest["npcs"] = {"henry": {"sheet": "henry/henry.png", "portrait": "henry/portrait.png", "anchorRow": henry_anchor()}} save_json(OUT / "manifest.json", manifest) print(f"manifest: {', '.join(manifest['bodies'])} -> {OUT / 'manifest.json'}") # ----------------------------------------------------------------- Professor Henry def henry(client): cid = create_character(client, "henry", HENRY["description"], HENRY["seed"]) animate_walk(client, "henry", cid) d = SRC / "henry" if not (d / "portrait.png").exists(): _, res = client.run("create-image-pixen", {"description": HENRY["portrait"], "image_size": {"width": 128, "height": 128}, "no_background": True, "seed": 77}, "henry/portrait") plc.images_in(res[0])[0].save(d / "portrait.png") print("[ok] henry portrait") out = OUT / "henry" out.mkdir(parents=True, exist_ok=True) frames = [np.asarray(crop(f)) for f in base_frames("henry")] Image.fromarray(sheet(frames)).save(out / "henry.png") load(d / "portrait.png").save(out / "portrait.png") def henry_anchor(): a = np.asarray(Image.open(OUT / "henry" / "henry.png"))[..., 3] > 127 rows = np.nonzero(a.reshape(len(DIRS), CELL, FRAMES_PER_DIR, CELL).any(axis=(0, 2, 3)))[0] return int(rows.max()) # ----------------------------------------------------------------- preview def preview(): """Contact sheet: each item over the base body (shade layer shown in its default color).""" sys.path.insert(0, str(ROOT / "tools")) from character_compose import compose_sheet # noqa: E402 manifest = json.loads((OUT / "manifest.json").read_text()) PREVIEW.mkdir(parents=True, exist_ok=True) for body, info in manifest["bodies"].items(): rows = [] for item_id in info["items"]: slot = manifest["items"][item_id]["slot"] look = {"body": body, "skin": 1, "items": {"top": "top_tee", "bottom": "bottom_shorts"}, "colors": {}} look["items"][slot] = item_id rows.append(compose_sheet(manifest, look)) h = sum(r.height for r in rows) out = Image.new("RGBA", (rows[0].width, h), (88, 132, 84, 255)) y = 0 for r in rows: out.alpha_composite(r, (0, y)) y += r.height out = out.resize((out.width * 2, out.height * 2), Image.NEAREST) out.save(PREVIEW / f"{body}_items.png") print(f"preview -> {PREVIEW / f'{body}_items.png'}") def main(argv): cmd = argv[0] if argv else "build" if cmd == "bases": client = plc.Client() for name, spec in BODIES.items(): cid = create_character(client, name, spec["description"], spec["seed"]) animate_walk(client, name, cid) elif cmd == "items": client = plc.Client() wanted = argv[1:] or list(ITEMS) jobs = [(b, i) for i in wanted for b in BODIES] with ThreadPoolExecutor(max_workers=6) as pool: futs = {pool.submit(edit_item, client, b, i): (b, i) for b, i in jobs} for f in as_completed(futs): try: f.result() except Exception as e: # keep the other jobs running print(f"[fail] {futs[f]}: {e}") elif cmd == "henry": henry(plc.Client()) elif cmd == "build": build() elif cmd == "preview": preview() else: sys.exit(__doc__) if __name__ == "__main__": main(sys.argv[1:])