diff --git a/tools/.gitignore b/tools/.gitignore new file mode 100644 index 0000000..c6d2d49 --- /dev/null +++ b/tools/.gitignore @@ -0,0 +1,15 @@ +# Python venv (regenerate: python3 -m venv .venv && .venv/bin/pip install pillow numpy opencv-python-headless) +.venv/ +__pycache__/ +*.pyc + +# macOS +.DS_Store + +# Ad-hoc generated render / dither outputs (regenerable). Curated keepers live in pixelart/samples/. +comfy-spike/out_*.png +pixelart/p_*.png +pixelart/crypt_*.png +pixelart/godray_*.png +pixelart/*_x*.png +pixelart/lock/ diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..b8c972c --- /dev/null +++ b/tools/README.md @@ -0,0 +1,49 @@ +# reikhelm tools — AI-render → pixel-art pipeline + +Post-processing tools that turn a depth map into atmospheric, palette-locked pixel +art via a local diffusion render. Two stages, glued by PNG files. + +``` +depth map ──► Stage 1: ComfyUI/Z-Image render ──► Stage 2: dither ──► pixel art +(synthetic now; (comfy-spike/comfy.py) (pixelart/pixelate.py) + real reikhelm + geometry next) +``` + +## Setup +The pixelart tools share a venv (Pillow + numpy + opencv-headless): +``` +python3 -m venv tools/pixelart/.venv +tools/pixelart/.venv/bin/pip install pillow numpy opencv-python-headless +``` +`comfy-spike/comfy.py` is pure stdlib (no venv). LAN calls to ComfyUI need the Bash sandbox disabled. + +## Stage 1 — depth → diffusion render (`comfy-spike/comfy.py`) +Talks to ComfyUI over HTTP (workstation `http://192.168.1.26:8188`). +``` +python3 comfy-spike/comfy.py depth --kind corridor --width 1536 --height 864 --out d.png # synthetic test depth +python3 comfy-spike/comfy.py zrender http://192.168.1.26:8188 --depth d.png --prompt "..." --prefix scene +# also: probe | nodeinfo for introspection +``` +Proven Z-Image depth recipe: `ModelPatchLoader` + `ZImageFunControlnet`, CLIP type `qwen_image`, +`EmptySD3LatentImage`, `res_multistep`/`simple`, 8–12 steps, cfg 1.0, `ModelSamplingAuraFlow` shift 3.0. +Depth strength ~0.6 for freeform; **~0.65–0.85 to honor real geometry**. + +## Stage 2 — render → pixel art (`pixelart/pixelate.py`) +LOCKED "Primordyn" recipe = the defaults: +``` +tools/pixelart/.venv/bin/python pixelart/pixelate.py render.png --palette pixelart/primordyn_v2.json +# → 640×360 · Floyd–Steinberg · OKLab match · linear-light · serpentine · fill +# --size 320x180 = chunky cut; --dither atkinson|jjn|bayer4|none to experiment +``` +Outputs a true indexed PNG (+ optional `--preview-scale` nearest-neighbor preview). Palette loader +auto-detects .json / .gpl / JASC .pal / hex / Paint.NET / .png / `adaptive:N`. + +## Helpers & references +- `pixelart/montage.py` — tile labeled images into a contact sheet. +- `pixelart/samples/` — curated reference: before/after pair + the kernel/size & palette-stretch contact sheets. + +## Next +Wire **real reikhelm geometry** → depth-map export (top-down from the renderer's distance field, then a +height layer) into Stage 1, so we render actual generated dungeons. Later: port the locked Stage-2 dither +into a Rust crate so the engine owns the whole chain. See project memory. diff --git a/tools/comfy-spike/comfy.py b/tools/comfy-spike/comfy.py new file mode 100644 index 0000000..261d365 --- /dev/null +++ b/tools/comfy-spike/comfy.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Throwaway spike: prove reikhelm (Mac) -> ComfyUI (LAN workstation) depth-render works. + +Pure stdlib. No pip installs. Three subcommands: + + depth generate a synthetic depth map PNG (no network) -- our test input + probe ask a ComfyUI server what models / controlnets / nodes it has + render upload a depth PNG, run a depth-ControlNet graph, pull the image back + +This is a validation tool, not the real integration. Once the pipe is proven we +port the proven flow into Rust (reikhelm renders depth -> POST /prompt -> /view). +""" +import argparse +import json +import os +import struct +import sys +import time +import urllib.error +import urllib.request +import zlib + + +# --------------------------------------------------------------------------- # +# Grayscale PNG writer (stdlib only -- avoids a Pillow dependency) +# --------------------------------------------------------------------------- # +def write_gray_png(path, width, height, pixels): + """pixels: a bytes/bytearray of length width*height, one 8-bit gray value each.""" + def chunk(tag, data): + body = tag + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0) # 8-bit, color type 0 (gray) + raw = bytearray() + for y in range(height): + raw.append(0) # filter byte 0 (None) per scanline + raw.extend(pixels[y * width:(y + 1) * width]) + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + f.write(chunk(b"IHDR", ihdr)) + f.write(chunk(b"IDAT", zlib.compress(bytes(raw), 9))) + f.write(chunk(b"IEND", b"")) + + +# --------------------------------------------------------------------------- # +# Synthetic depth maps. ControlNet convention: NEAR = white(255), FAR = black(0). +# --------------------------------------------------------------------------- # +def corridor_depth(w, h, bands=0, falloff=2.2, floor_bias=0.0): + """One-point-perspective stone corridor: bright near the viewer (frame edge), + dark at the vanishing point, SMOOTH by default so no hard ring seams appear. + `falloff` > 1 deepens the tunnel / thins the near rim. `bands` > 0 quantizes + into concentric rings (causes visible stone ridges -- leave 0 for smooth). + `floor_bias` (0..1) lowers the vanishing point so the floor occupies more of + the lower frame, reading as a hall you stand in rather than a symmetric portal.""" + px = bytearray(w * h) + cx = w / 2.0 + cy = h * (0.5 + 0.5 * floor_bias) # push vanishing point upward + for y in range(h): + for x in range(w): + # Chebyshev distance to the (possibly shifted) center -> rectangular tunnel. + dx = abs(x - cx) / cx + dy = abs(y - cy) / max(cy, h - cy) + d = max(dx, dy) + v = d ** falloff # steeper => deeper tunnel, thin near rim + if bands > 0: + v = round(v * bands) / bands # quantize into rings (visible ridges) + px[y * w + x] = max(0, min(255, int(v * 255))) + return px + + +def room_depth(w, h): + """Top-down 'dollhouse' room: tall walls near the camera (bright border), + floor farther away (mid), recessed pool in the middle (dark).""" + px = bytearray(w * h) + bx, by = w // 7, h // 7 + px0, px1, py0, py1 = w // 3, 2 * w // 3, h // 3, 2 * h // 3 + for y in range(h): + for x in range(w): + if x < bx or x >= w - bx or y < by or y >= h - by: + v = 235 # walls: near/bright + elif px0 <= x < px1 and py0 <= y < py1: + v = 40 # recessed pool: far/dark + else: + v = 120 # floor: mid + px[y * w + x] = v + return px + + +def cmd_depth(args): + w = args.width or args.size + h = args.height or args.size + if args.kind == "corridor": + px = corridor_depth(w, h, bands=args.bands, falloff=args.falloff, floor_bias=args.floor_bias) + else: + px = room_depth(w, h) + write_gray_png(args.out, w, h, px) + print(f"wrote {args.out} ({w}x{h}, kind={args.kind}, bands={args.bands}, falloff={args.falloff})") + + +# --------------------------------------------------------------------------- # +# ComfyUI HTTP helpers +# --------------------------------------------------------------------------- # +def _get(base, path, timeout=15): + with urllib.request.urlopen(base + path, timeout=timeout) as r: + return r.read() + + +def _get_json(base, path, timeout=15): + return json.loads(_get(base, path, timeout)) + + +def _node_input_options(info, class_type, input_name): + """Pull the dropdown option list (e.g. available checkpoints) for one node input.""" + node = info.get(class_type) + if not node: + return None + spec = node.get("input", {}) + for group in ("required", "optional"): + if input_name in spec.get(group, {}): + opt = spec[group][input_name] + # ComfyUI encodes a dropdown as [ [list_of_values], {meta} ] or [list_of_values] + if isinstance(opt, list) and opt and isinstance(opt[0], list): + return opt[0] + return None + + +def cmd_probe(args): + base = args.url.rstrip("/") + try: + stats = _get_json(base, "/system_stats") + except urllib.error.URLError as e: + print(f"!! cannot reach {base} -- {e}") + print(" ComfyUI must be started with --listen 0.0.0.0 to accept LAN connections,") + print(" and the firewall must allow the port (default 8188).") + sys.exit(2) + + print(f"== reachable: {base} ==") + sysinfo = stats.get("system", {}) + print(f" comfyui: {sysinfo.get('comfyui_version', '?')} python: {sysinfo.get('python_version', '?')[:7]}") + for d in stats.get("devices", []): + free = d.get("vram_free", 0) / 1e9 + total = d.get("vram_total", 0) / 1e9 + print(f" device: {d.get('name', '?')} VRAM {free:.1f}/{total:.1f} GB free") + + info = _get_json(base, "/object_info", timeout=60) + print(f"\n== installed node classes: {len(info)} ==") + + def show(label, class_type, input_name, filt=None): + opts = _node_input_options(info, class_type, input_name) + if opts is None: + print(f" [{label}] node '{class_type}' NOT present") + return + if filt: + hits = [o for o in opts if any(f in o.lower() for f in filt)] + extra = f" (filtered {len(hits)}/{len(opts)})" if hits != opts else "" + opts = hits or opts + print(f" [{label}]{extra}: {opts}") + else: + print(f" [{label}] ({len(opts)}): {opts}") + + show("checkpoints", "CheckpointLoaderSimple", "ckpt_name") + show("controlnets", "ControlNetLoader", "control_net_name") + show("unet/diffusion (flux/z-image)", "UNETLoader", "unet_name") + show("vae", "VAELoader", "vae_name") + show("clip", "CLIPLoader", "clip_name") + show("dualclip (flux)", "DualCLIPLoader", "clip_name1") + + print("\n== relevant nodes present? ==") + for n in ["ControlNetApplyAdvanced", "ControlNetApply", "LoadImage", "KSampler", + "UNETLoader", "DualCLIPLoader", "FluxGuidance", "VAELoader"]: + print(f" {'yes' if n in info else ' no'} {n}") + # surface any Z-Image-specific nodes by name + zi = sorted(k for k in info if "image" in k.lower() and "z" in k.lower().split("image")[0][-2:]) + flux = sorted(k for k in info if "flux" in k.lower()) + if flux: + print(f" flux nodes: {flux}") + print("\nNext: tell me the checkpoint + controlnet names above and I'll wire the render graph.") + + +# --------------------------------------------------------------------------- # +# Render: upload depth, run an SDXL depth-ControlNet graph, pull the result. +# (SDXL first -- simplest, most-likely-installed. Flux/Z-Image added after probe.) +# --------------------------------------------------------------------------- # +def upload_image(base, path): + boundary = "----comfyspikeboundary" + fn = os.path.basename(path) + with open(path, "rb") as f: + data = f.read() + parts = [ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="image"; filename="{fn}"\r\n'.encode(), + b"Content-Type: image/png\r\n\r\n", data, b"\r\n", + f"--{boundary}\r\n".encode(), + b'Content-Disposition: form-data; name="overwrite"\r\n\r\ntrue\r\n', + f"--{boundary}--\r\n".encode(), + ] + body = b"".join(parts) + req = urllib.request.Request( + base + "/upload/image", data=body, method="POST", + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}) + return json.loads(urllib.request.urlopen(req, timeout=30).read()) + + +def sdxl_depth_graph(a, depth_filename): + return { + "ckpt": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": a.ckpt}}, + "pos": {"class_type": "CLIPTextEncode", "inputs": {"text": a.prompt, "clip": ["ckpt", 1]}}, + "neg": {"class_type": "CLIPTextEncode", "inputs": {"text": a.negative, "clip": ["ckpt", 1]}}, + "img": {"class_type": "LoadImage", "inputs": {"image": depth_filename}}, + "cnet": {"class_type": "ControlNetLoader", "inputs": {"control_net_name": a.controlnet}}, + "apply": {"class_type": "ControlNetApplyAdvanced", "inputs": { + "positive": ["pos", 0], "negative": ["neg", 0], "control_net": ["cnet", 0], + "image": ["img", 0], "strength": a.strength, "start_percent": 0.0, "end_percent": 1.0}}, + "latent": {"class_type": "EmptyLatentImage", "inputs": {"width": a.size, "height": a.size, "batch_size": 1}}, + "ks": {"class_type": "KSampler", "inputs": { + "seed": a.seed, "steps": a.steps, "cfg": a.cfg, "sampler_name": a.sampler, + "scheduler": a.scheduler, "denoise": 1.0, "model": ["ckpt", 0], + "positive": ["apply", 0], "negative": ["apply", 1], "latent_image": ["latent", 0]}}, + "vae": {"class_type": "VAEDecode", "inputs": {"samples": ["ks", 0], "vae": ["ckpt", 2]}}, + "save": {"class_type": "SaveImage", "inputs": {"images": ["vae", 0], "filename_prefix": "comfyspike"}}, + } + + +def _run_graph(base, graph, out_dir, wait): + """Queue a graph, poll history, download any output images. Returns saved paths.""" + try: + resp = urllib.request.urlopen(urllib.request.Request( + base + "/prompt", data=json.dumps({"prompt": graph}).encode(), + headers={"Content-Type": "application/json"}, method="POST"), timeout=30) + except urllib.error.HTTPError as e: + print("!! /prompt rejected the graph (validation error):") + print(e.read().decode()[:3000]) + sys.exit(1) + pid = json.loads(resp.read())["prompt_id"] + print(f"queued prompt {pid} ... waiting (up to {wait}s)") + + deadline = time.time() + wait + while time.time() < deadline: + hist = _get_json(base, "/history/" + pid) + if pid in hist: + entry = hist[pid] + status = entry.get("status", {}) + if status.get("status_str") == "error": + print("!! execution error:") + print(json.dumps(status.get("messages", status), indent=2)[:3000]) + saved = [] + for node_out in entry.get("outputs", {}).values(): + for im in node_out.get("images", []): + q = urllib.parse.urlencode({"filename": im["filename"], + "subfolder": im.get("subfolder", ""), + "type": im.get("type", "output")}) + blob = _get(base, "/view?" + q, timeout=120) + out = os.path.join(out_dir or ".", "out_" + im["filename"]) + with open(out, "wb") as f: + f.write(blob) + saved.append(out) + print("rendered:", *saved) if saved else print("finished but no images -- check the graph") + return saved + time.sleep(2) + print(f"!! timed out after {wait}s") + return [] + + +def cmd_render(args): + base = args.url.rstrip("/") + up = upload_image(base, args.depth) + depth_fn = up["name"] + (f" [{up['subfolder']}]" if up.get("subfolder") else "") + print(f"uploaded depth -> {depth_fn}") + _run_graph(base, sdxl_depth_graph(args, depth_fn), os.path.dirname(args.depth), args.wait) + + +# --------------------------------------------------------------------------- # +# Z-Image-Turbo + DiffSynth Union depth patch (the recommended path). +# Uses model_patches (NOT controlnet/) + the native ZImageFunControlnet node. +# --------------------------------------------------------------------------- # +def zimage_depth_graph(a, depth_filename): + g = { + "unet": {"class_type": "UNETLoader", "inputs": {"unet_name": a.unet, "weight_dtype": "default"}}, + "clip": {"class_type": "CLIPLoader", "inputs": {"clip_name": a.clip, "type": a.clip_type}}, + "vae": {"class_type": "VAELoader", "inputs": {"vae_name": a.vae}}, + "patch": {"class_type": "ModelPatchLoader", "inputs": {"name": a.patch}}, + "depth": {"class_type": "LoadImage", "inputs": {"image": depth_filename}}, + "shift": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["unet", 0], "shift": a.shift}}, + "cnet": {"class_type": "ZImageFunControlnet", "inputs": { + "model": ["shift", 0], "model_patch": ["patch", 0], "vae": ["vae", 0], + "strength": a.strength, "image": ["depth", 0]}}, + "pos": {"class_type": "CLIPTextEncode", "inputs": {"text": a.prompt, "clip": ["clip", 0]}}, + "neg": {"class_type": "CLIPTextEncode", "inputs": {"text": a.negative, "clip": ["clip", 0]}}, + "latent": {"class_type": "EmptySD3LatentImage", "inputs": {"width": a.width or a.size, "height": a.height or a.size, "batch_size": 1}}, + "ks": {"class_type": "KSampler", "inputs": { + "seed": a.seed, "steps": a.steps, "cfg": a.cfg, "sampler_name": a.sampler, + "scheduler": a.scheduler, "denoise": 1.0, "model": ["cnet", 0], + "positive": ["pos", 0], "negative": ["neg", 0], "latent_image": ["latent", 0]}}, + "dec": {"class_type": "VAEDecode", "inputs": {"samples": ["ks", 0], "vae": ["vae", 0]}}, + "save": {"class_type": "SaveImage", "inputs": {"images": ["dec", 0], "filename_prefix": a.prefix}}, + } + return g + + +def cmd_zrender(args): + base = args.url.rstrip("/") + up = upload_image(base, args.depth) + depth_fn = up["name"] + (f" [{up['subfolder']}]" if up.get("subfolder") else "") + print(f"uploaded depth -> {depth_fn}") + _run_graph(base, zimage_depth_graph(args, depth_fn), os.path.dirname(args.depth), args.wait) + + +# --------------------------------------------------------------------------- # +import urllib.parse # noqa: E402 (used in cmd_render) + + +def cmd_nodeinfo(args): + """Dump the exact input/output signature of specific node classes from a live server.""" + base = args.url.rstrip("/") + info = _get_json(base, "/object_info", timeout=60) + for n in args.nodes: + node = info.get(n) + if not node: + print(f"\n## {n} -- NOT PRESENT") + continue + print(f"\n## {n}") + spec = node.get("input", {}) + for group in ("required", "optional"): + for name, val in spec.get(group, {}).items(): + meta = {} + t = val + if isinstance(val, list) and val: + t = val[0] + if len(val) > 1 and isinstance(val[1], dict): + meta = val[1] + if isinstance(t, list): # combo / dropdown + shown = t if len(t) <= 20 else t[:20] + [f"...(+{len(t) - 20})"] + t = f"COMBO{shown}" + hint = {k: meta[k] for k in ("default",) if k in meta} + print(f" {group:8} {name}: {t}" + (f" {hint}" if hint else "")) + outs = node.get("output_name") or node.get("output") + print(f" -> outputs: {outs}") + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + d = sub.add_parser("depth", help="generate a synthetic depth map PNG") + d.add_argument("--kind", choices=["corridor", "room"], default="corridor") + d.add_argument("--size", type=int, default=1024, help="square size if --width/--height omitted") + d.add_argument("--width", type=int, default=0) + d.add_argument("--height", type=int, default=0) + d.add_argument("--bands", type=int, default=0, help="corridor: >0 quantizes into rings (visible ridges); 0=smooth") + d.add_argument("--falloff", type=float, default=2.2, help="corridor: >1 deepens the tunnel / thins the near rim") + d.add_argument("--floor-bias", dest="floor_bias", type=float, default=0.0, + help="corridor: 0..1 lowers the vanishing point (more floor in lower frame)") + d.add_argument("--out", default="sample_depth.png") + d.set_defaults(func=cmd_depth) + + pr = sub.add_parser("probe", help="inspect a ComfyUI server's models/nodes") + pr.add_argument("url", help="e.g. http://192.168.1.50:8188") + pr.set_defaults(func=cmd_probe) + + r = sub.add_parser("render", help="upload depth + run an SDXL depth-ControlNet render") + r.add_argument("url") + r.add_argument("--depth", default="sample_depth.png") + r.add_argument("--ckpt", required=True, help="checkpoint filename from `probe`") + r.add_argument("--controlnet", required=True, help="depth controlnet filename from `probe`") + r.add_argument("--prompt", default="inside an ancient stone dungeon corridor, wet flagstone, " + "flickering torchlight, volumetric fog, moss, dramatic shadows, " + "dark fantasy, highly detailed, cinematic") + r.add_argument("--negative", default="blurry, text, watermark, modern, people, cartoon, flat lighting") + r.add_argument("--strength", type=float, default=0.6) + r.add_argument("--size", type=int, default=1024) + r.add_argument("--steps", type=int, default=28) + r.add_argument("--cfg", type=float, default=6.0) + r.add_argument("--seed", type=int, default=42) + r.add_argument("--sampler", default="dpmpp_2m") + r.add_argument("--scheduler", default="karras") + r.add_argument("--wait", type=int, default=300) + r.set_defaults(func=cmd_render) + + ni = sub.add_parser("nodeinfo", help="dump exact input/output signatures of node classes") + ni.add_argument("url") + ni.add_argument("nodes", nargs="+") + ni.set_defaults(func=cmd_nodeinfo) + + z = sub.add_parser("zrender", help="Z-Image-Turbo depth render via the model_patches union patch") + z.add_argument("url") + z.add_argument("--depth", default="sample_depth.png") + z.add_argument("--unet", default="z_image_turbo_bf16.safetensors") + z.add_argument("--clip", default="qwen_3_4b.safetensors") + z.add_argument("--clip-type", dest="clip_type", default="qwen_image", + help="CLIPLoader type for the Qwen3-4B encoder") + z.add_argument("--vae", default="ae.safetensors") + z.add_argument("--patch", default="Z-Image-Turbo-Fun-Controlnet-Union-2.1-lite-2602-8steps.safetensors") + z.add_argument("--prompt", default="inside an ancient stone dungeon corridor, wet mossy flagstone walls, " + "flickering torchlight, volumetric fog, deep shadows receding into " + "darkness, dark fantasy, cinematic, highly detailed") + z.add_argument("--negative", default="") + z.add_argument("--strength", type=float, default=0.65) + z.add_argument("--size", type=int, default=1024, help="square size if --width/--height omitted") + z.add_argument("--width", type=int, default=0) + z.add_argument("--height", type=int, default=0) + z.add_argument("--steps", type=int, default=8) + z.add_argument("--cfg", type=float, default=1.0) + z.add_argument("--shift", type=float, default=3.0) + z.add_argument("--seed", type=int, default=42) + z.add_argument("--prefix", default="zspike", help="SaveImage filename prefix (names the output)") + z.add_argument("--sampler", default="res_multistep") + z.add_argument("--scheduler", default="simple") + z.add_argument("--wait", type=int, default=300) + z.set_defaults(func=cmd_zrender) + + args = p.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tools/comfy-spike/corr_169.png b/tools/comfy-spike/corr_169.png new file mode 100644 index 0000000..fa7187b Binary files /dev/null and b/tools/comfy-spike/corr_169.png differ diff --git a/tools/pixelart/montage.py b/tools/pixelart/montage.py new file mode 100644 index 0000000..371a942 --- /dev/null +++ b/tools/pixelart/montage.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Tile labeled images into a contact sheet for side-by-side comparison. + +Usage: montage.py --out sheet.png --cols 3 [--scale 0.5] path:label path:label ... +""" +import argparse + +from PIL import Image, ImageDraw, ImageFont + + +def load_font(size): + for p in ("/System/Library/Fonts/Supplemental/Arial.ttf", + "/System/Library/Fonts/Helvetica.ttc", + "/Library/Fonts/Arial.ttf"): + try: + return ImageFont.truetype(p, size) + except OSError: + continue + return ImageFont.load_default() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", required=True) + ap.add_argument("--cols", type=int, default=3) + ap.add_argument("--scale", type=float, default=1.0) + ap.add_argument("--pad", type=int, default=8) + ap.add_argument("--label-h", type=int, default=24, dest="label_h") + ap.add_argument("items", nargs="+", help="each 'path:label'") + a = ap.parse_args() + + imgs, labels = [], [] + for it in a.items: + path, _, lab = it.partition(":") + imgs.append(Image.open(path).convert("RGB")) + labels.append(lab) + + cw = int(max(i.width for i in imgs) * a.scale) + ch = int(max(i.height for i in imgs) * a.scale) + font = load_font(max(12, int(a.label_h * 0.7))) + cols = a.cols + rows = (len(imgs) + cols - 1) // cols + cellw, cellh = cw + a.pad, ch + a.label_h + a.pad + sheet = Image.new("RGB", (cols * cellw + a.pad, rows * cellh + a.pad), (18, 18, 20)) + d = ImageDraw.Draw(sheet) + for k, (im, lab) in enumerate(zip(imgs, labels)): + r, c = divmod(k, cols) + x, y = a.pad + c * cellw, a.pad + r * cellh + d.text((x + 2, y + 4), lab, font=font, fill=(232, 232, 238)) + sheet.paste(im.resize((cw, ch), Image.LANCZOS), (x, y + a.label_h)) + sheet.save(a.out) + print(f"wrote {a.out} ({sheet.width}x{sheet.height})") + + +if __name__ == "__main__": + main() diff --git a/tools/pixelart/pixelate.py b/tools/pixelart/pixelate.py new file mode 100644 index 0000000..995095c --- /dev/null +++ b/tools/pixelart/pixelate.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Crunch an image into a limited-palette pixel-art render. + +Pipeline: load -> resize-to-target (any dims/aspect, fill/fit/pad + focus) + -> palette-constrained dither (OKLab-perceptual match, linear-light + error diffusion) -> indexed PNG + upscaled preview. + +Run with the sibling venv: .venv/bin/python pixelate.py [opts] + +LOCKED Primordyn recipe (2026-06-01): the DEFAULTS are the look. Running + pixelate.py render.png --palette primordyn_v2.json +is exactly --size 640x360 --dither floyd --space oklab --mode fill (serpentine, +strength 1.0). Override --size 320x180 for the chunkier cut. + +The quality knobs that matter: perceptual matching in OKLab (right color, not +just RGB-nearest), error diffusion done in LINEAR light (gamma-correct, no +muddy darkening), and a choice of diffusion kernels (Floyd-Steinberg, Atkinson, +Jarvis, Stucki, Sierra) or ordered Bayer. + +Palettes auto-detected: JSON ({"colors":[[r,g,b],...]} or bare list), GIMP .gpl, +JASC/Aseprite .pal, hex lists (.hex/.txt), Paint.NET, .png swatch, or adaptive:N. +""" +import argparse +import json +import os +import re +import sys + +import numpy as np +from PIL import Image + + +# --------------------------------------------------------------------------- # +# Palette loading (format-agnostic) +# --------------------------------------------------------------------------- # +def _hex_to_rgb(h): + h = h.lstrip("#") + if h.lower().startswith("0x"): + h = h[2:] + if len(h) == 8: # 8 digits: Paint.NET AARRGGBB -> drop alpha + h = h[2:] + return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def _parse_text_palette(text): + lines = text.splitlines() + first = next((ln.strip() for ln in lines if ln.strip()), "") + + if first.lower().startswith("gimp palette"): # GIMP .gpl + out = [] + for ln in lines[1:]: + s = ln.strip() + if not s or s.startswith("#") or s.lower().startswith(("name:", "columns:")): + continue + parts = s.split() + if len(parts) >= 3 and all(p.isdigit() for p in parts[:3]): + out.append(tuple(int(p) for p in parts[:3])) + return out + + if first.upper().startswith("JASC-PAL"): # JASC / Aseprite .pal + return [tuple(int(p) for p in ln.split()[:3]) + for ln in lines[3:] if len(ln.split()) >= 3 and ln.split()[0].isdigit()] + + hexes = re.findall(r"(?:#|0x)?([0-9a-fA-F]{8}|[0-9a-fA-F]{6})\b", text) + if hexes and ("#" in text or "0x" in text.lower() or any(re.search("[a-fA-F]", h) for h in hexes)): + return [_hex_to_rgb(h) for h in hexes] + + out = [] # decimal triples + for ln in lines: + s = ln.strip() + if not s or s.startswith((";", "//")): + continue + nums = [p for p in re.split(r"[\s,]+", s) if p.isdigit()] + if len(nums) >= 3: + out.append(tuple(int(p) for p in nums[:3])) + return out + + +def load_palette(spec, ref_image=None): + if spec.startswith("adaptive:"): + n = int(spec.split(":", 1)[1]) + q = ref_image.convert("RGB").quantize(colors=n, method=Image.MEDIANCUT) + return np.array(q.getpalette()[: n * 3], dtype=np.float32).reshape(-1, 3) + + raw = open(spec, "rb").read() + if spec.lower().endswith(".json") or raw.lstrip()[:1] in (b"{", b"["): + try: + doc = json.loads(raw.decode("utf-8")) + colors = doc.get("colors", doc) if isinstance(doc, dict) else doc + triples = [tuple(c[:3]) for c in colors if isinstance(c, (list, tuple)) and len(c) >= 3] + if triples: + return np.array(triples, dtype=np.float32) + except (ValueError, UnicodeDecodeError): + pass + + if raw[:8] == b"\x89PNG\r\n\x1a\n": + arr = np.array(Image.open(spec).convert("RGB")).reshape(-1, 3) + return np.array(list(dict.fromkeys(map(tuple, arr.tolist()))), dtype=np.float32) + + colors = _parse_text_palette(raw.decode("utf-8", "replace")) + if not colors: + sys.exit(f"!! could not parse any colors from {spec}") + return np.array(colors, dtype=np.float32) + + +# --------------------------------------------------------------------------- # +# Color spaces. Matching happens in a perceptual space; diffusion in linear. +# --------------------------------------------------------------------------- # +def srgb_to_linear(c): + return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) + + +def linear_to_srgb(c): + return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.clip(c, 0, None) ** (1 / 2.4) - 0.055) + + +def linear_to_oklab(c): + r, g, b = c[..., 0], c[..., 1], c[..., 2] + l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = np.cbrt(l), np.cbrt(m), np.cbrt(s) + return np.stack([ + 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, + 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, + 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, + ], -1) + + +def to_match_fn(space): + return {"oklab": linear_to_oklab, "linear": lambda c: c, "srgb": linear_to_srgb}[space] + + +# --------------------------------------------------------------------------- # +# Nearest-color LUT over the linear cube (built via the perceptual metric). +# Lets the sequential error-diffusion loop do O(1) lookups instead of an +# argmin-over-256 per pixel. +# --------------------------------------------------------------------------- # +def build_lut(palette_match, to_match, grid=64): + axis = np.linspace(0.0, 1.0, grid, dtype=np.float32) + gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij") + pts_lin = np.stack([gx, gy, gz], -1).reshape(-1, 3) + pts = to_match(pts_lin) + out = np.empty(pts.shape[0], dtype=np.int32) + step = 4096 + for s in range(0, pts.shape[0], step): + d = ((pts[s:s + step, None, :] - palette_match[None, :, :]) ** 2).sum(2) + out[s:s + step] = d.argmin(1) + return out.reshape(grid, grid, grid) + + +# kernel = (divisor, [(dx, dy, weight), ...]); error spread to *future* pixels. +KERNELS = { + "floyd": (16, [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)]), + "atkinson": (8, [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)]), + "jjn": (48, [(1, 0, 7), (2, 0, 5), (-2, 1, 3), (-1, 1, 5), (0, 1, 7), (1, 1, 5), + (2, 1, 3), (-2, 2, 1), (-1, 2, 3), (0, 2, 5), (1, 2, 3), (2, 2, 1)]), + "stucki": (42, [(1, 0, 8), (2, 0, 4), (-2, 1, 2), (-1, 1, 4), (0, 1, 8), (1, 1, 4), + (2, 1, 2), (-2, 2, 1), (-1, 2, 2), (0, 2, 4), (1, 2, 2), (2, 2, 1)]), + "sierra": (32, [(1, 0, 5), (2, 0, 3), (-2, 1, 2), (-1, 1, 4), (0, 1, 5), (1, 1, 4), + (2, 1, 2), (-1, 2, 2), (0, 2, 3), (1, 2, 2)]), +} + +BAYER = { + 4: np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], np.float32) / 16.0 - 0.5, + 8: (np.array([[0, 32, 8, 40, 2, 34, 10, 42], [48, 16, 56, 24, 50, 18, 58, 26], + [12, 44, 4, 36, 14, 46, 6, 38], [60, 28, 52, 20, 62, 30, 54, 22], + [3, 35, 11, 43, 1, 33, 9, 41], [51, 19, 59, 27, 49, 17, 57, 25], + [15, 47, 7, 39, 13, 45, 5, 37], [63, 31, 55, 23, 61, 29, 53, 21]], np.float32) / 64.0 - 0.5), +} + + +def _lut_lookup(lin_vals, lut, gscale): + gi = np.clip((lin_vals * gscale + 0.5).astype(np.int32), 0, gscale) + return lut[gi[..., 0], gi[..., 1], gi[..., 2]] + + +def error_diffuse(lin, lut, palette_lin, kernel, serpentine, strength): + h, w, _ = lin.shape + work = lin.copy() + idxmap = np.zeros((h, w), np.int32) + div, taps = kernel + gscale = lut.shape[0] - 1 + for y in range(h): + rev = serpentine and (y & 1) + xr = range(w - 1, -1, -1) if rev else range(w) + for x in xr: + v = work[y, x] + gi = np.clip((v * gscale + 0.5).astype(np.int32), 0, gscale) + i = int(lut[gi[0], gi[1], gi[2]]) + idxmap[y, x] = i + err = (v - palette_lin[i]) * strength + for dx, dy, wt in taps: + nx, ny = x + (-dx if rev else dx), y + dy + if 0 <= nx < w and 0 <= ny < h: + work[ny, nx] += err * (wt / div) + return idxmap + + +def ordered(lin, lut, bayer, strength): + h, w, _ = lin.shape + tile = np.tile(bayer, (h // bayer.shape[0] + 1, w // bayer.shape[1] + 1))[:h, :w] + v = np.clip(lin + tile[..., None] * strength, 0.0, 1.0) + return _lut_lookup(v, lut, lut.shape[0] - 1) + + +# --------------------------------------------------------------------------- # +# Resize to target dimensions (ported from convert_image.py's crop logic) +# --------------------------------------------------------------------------- # +def resize_to_target(img, tw, th, mode, fx, fy, resample): + sw, sh = img.size + sa, ta = sw / sh, tw / th + if mode == "pad": + scale = min(tw / sw, th / sh) + nw, nh = max(1, round(sw * scale)), max(1, round(sh * scale)) + canvas = Image.new("RGB", (tw, th), (0, 0, 0)) + canvas.paste(img.resize((nw, nh), resample), ((tw - nw) // 2, (th - nh) // 2)) + return canvas + if mode == "fit": # scale to cover, crop minimal + scale = max(tw / sw, th / sh) + nw, nh = max(tw, round(sw * scale)), max(th, round(sh * scale)) + scaled = img.resize((nw, nh), resample) + x, y = round((nw - tw) * fx), round((nh - th) * fy) + return scaled.crop((x, y, x + tw, y + th)) + # fill (default): crop to target aspect, then scale + if sa > ta: + cw, ch = round(sh * ta), sh + else: + cw, ch = sw, round(sw / ta) + x, y = round((sw - cw) * fx), round((sh - ch) * fy) + return img.crop((x, y, x + cw, y + ch)).resize((tw, th), resample) + + +FOCI = {"center": (.5, .5), "top": (.5, 0), "bottom": (.5, 1), "left": (0, .5), "right": (1, .5), + "top-left": (0, 0), "top-right": (1, 0), "bottom-left": (0, 1), "bottom-right": (1, 1)} +PRESETS = {"primordyn": (640, 360), "640p": (640, 360), "small": (320, 180), "320p": (320, 180), + "vic20": (176, 184), "c64": (320, 200)} + + +def parse_size(s): + if s.lower() in PRESETS: + return PRESETS[s.lower()] + m = re.fullmatch(r"(\d+)\s*[xX]\s*(\d+)", s.strip()) + if not m: + sys.exit(f"!! --size must be WxH or one of {list(PRESETS)}; got '{s}'") + return int(m.group(1)), int(m.group(2)) + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("image") + p.add_argument("--palette", default="primordyn_v2.json", help="palette file or adaptive:N") + p.add_argument("--size", default="640x360", help=f"WxH or preset {list(PRESETS)}") + p.add_argument("--mode", choices=["fill", "fit", "pad"], default="fill", help="aspect handling") + p.add_argument("--focus", default="center", help=f"crop focus: {list(FOCI)} or 'fx,fy' (0..1)") + p.add_argument("--dither", choices=list(KERNELS) + ["bayer4", "bayer8", "none"], default="floyd") + p.add_argument("--space", choices=["oklab", "linear", "srgb"], default="oklab", help="color-match space") + p.add_argument("--strength", type=float, default=1.0, help="dither amount (error-diffusion fraction / bayer scale)") + p.add_argument("--no-serpentine", action="store_true") + p.add_argument("--resample", choices=["lanczos", "box", "bilinear", "hamming", "nearest"], default="lanczos") + p.add_argument("--lut", type=int, default=64, help="nearest-color LUT resolution per axis") + p.add_argument("--preview-scale", type=int, default=2, help="nearest-neighbor upscale for the preview (0=off)") + p.add_argument("--out", default=None) + args = p.parse_args() + + tw, th = parse_size(args.size) + fx, fy = FOCI.get(args.focus.lower(), None) or tuple(float(v) for v in args.focus.split(",")) + rfilt = {"lanczos": Image.LANCZOS, "box": Image.BOX, "bilinear": Image.BILINEAR, + "hamming": Image.HAMMING, "nearest": Image.NEAREST}[args.resample] + + src = Image.open(args.image).convert("RGB") + small = resize_to_target(src, tw, th, args.mode, fx, fy, rfilt) + + palette_srgb = load_palette(args.palette, ref_image=small) + pal01 = palette_srgb / 255.0 + palette_lin = srgb_to_linear(pal01).astype(np.float32) + to_match = to_match_fn(args.space) + palette_match = to_match(palette_lin).astype(np.float32) + + lin = srgb_to_linear(np.asarray(small, np.float32) / 255.0) + + if args.dither in KERNELS: + lut = build_lut(palette_match, to_match, args.lut) + idxmap = error_diffuse(lin, lut, palette_lin, KERNELS[args.dither], + not args.no_serpentine, args.strength) + elif args.dither.startswith("bayer"): + lut = build_lut(palette_match, to_match, args.lut) + idxmap = ordered(lin, lut, BAYER[int(args.dither[5:])], args.strength) + else: # none -> hard nearest, vectorized + flat = to_match(lin).reshape(-1, 3) + out = np.empty(flat.shape[0], np.int32) + for s in range(0, flat.shape[0], 65536): + d = ((flat[s:s + 65536, None, :] - palette_match[None, :, :]) ** 2).sum(2) + out[s:s + 65536] = d.argmin(1) + idxmap = out.reshape(th, tw) + + # --- outputs: true indexed PNG (target res) + upscaled RGB preview --- + base = os.path.splitext(args.out or args.image)[0] + lo_path = args.out or f"{base}_{tw}x{th}_{args.dither}.png" + idx_img = Image.frombytes("P", (tw, th), idxmap.astype(np.uint8).tobytes()) + flat_pal = palette_srgb.astype(np.uint8).reshape(-1).tolist() + idx_img.putpalette(flat_pal + [0] * (768 - len(flat_pal))) + idx_img.save(lo_path) + + used = len(np.unique(idxmap)) + print(f"palette: {len(palette_srgb)} colors ({args.palette}) | used: {used}") + print(f"target: {tw}x{th} via {args.mode}/{args.resample} focus={args.focus}") + print(f"dither: {args.dither} | match-space: {args.space} | strength: {args.strength}" + f"{'' if args.no_serpentine or args.dither in ('none',) or args.dither.startswith('bayer') else ' | serpentine'}") + print(f"wrote {lo_path} (indexed PNG)") + if args.preview_scale > 1: + up_path = os.path.splitext(lo_path)[0] + f"_x{args.preview_scale}.png" + idx_img.convert("RGB").resize((tw * args.preview_scale, th * args.preview_scale), + Image.NEAREST).save(up_path) + print(f"wrote {up_path} (preview)") + + +if __name__ == "__main__": + main() diff --git a/tools/pixelart/primordyn_v2.json b/tools/pixelart/primordyn_v2.json new file mode 100644 index 0000000..cac2b61 --- /dev/null +++ b/tools/pixelart/primordyn_v2.json @@ -0,0 +1,1288 @@ +{ + "name": "Primordyn v2 (Stress Test Extraction)", + "description": "Extracted from sample images using k-means clustering", + "format": "RGB", + "color_count": 256, + "colors": [ + [ + 3, + 5, + 3 + ], + [ + 177, + 114, + 45 + ], + [ + 71, + 180, + 233 + ], + [ + 64, + 58, + 52 + ], + [ + 16, + 5, + 1 + ], + [ + 4, + 104, + 218 + ], + [ + 130, + 127, + 118 + ], + [ + 16, + 47, + 42 + ], + [ + 75, + 33, + 5 + ], + [ + 175, + 149, + 108 + ], + [ + 34, + 16, + 4 + ], + [ + 105, + 143, + 32 + ], + [ + 222, + 236, + 243 + ], + [ + 72, + 42, + 16 + ], + [ + 124, + 102, + 73 + ], + [ + 39, + 44, + 105 + ], + [ + 137, + 161, + 177 + ], + [ + 17, + 105, + 149 + ], + [ + 5, + 15, + 19 + ], + [ + 149, + 65, + 5 + ], + [ + 11, + 15, + 16 + ], + [ + 90, + 58, + 27 + ], + [ + 34, + 103, + 20 + ], + [ + 204, + 112, + 18 + ], + [ + 118, + 107, + 202 + ], + [ + 16, + 73, + 4 + ], + [ + 29, + 5, + 1 + ], + [ + 52, + 37, + 24 + ], + [ + 66, + 77, + 82 + ], + [ + 195, + 194, + 184 + ], + [ + 148, + 221, + 249 + ], + [ + 13, + 38, + 35 + ], + [ + 3, + 10, + 12 + ], + [ + 52, + 27, + 7 + ], + [ + 62, + 41, + 23 + ], + [ + 42, + 46, + 47 + ], + [ + 2, + 5, + 8 + ], + [ + 247, + 158, + 17 + ], + [ + 21, + 9, + 2 + ], + [ + 13, + 24, + 29 + ], + [ + 86, + 126, + 98 + ], + [ + 45, + 137, + 183 + ], + [ + 30, + 37, + 9 + ], + [ + 162, + 99, + 237 + ], + [ + 9, + 67, + 103 + ], + [ + 40, + 164, + 225 + ], + [ + 103, + 36, + 3 + ], + [ + 87, + 179, + 12 + ], + [ + 3, + 146, + 217 + ], + [ + 215, + 71, + 7 + ], + [ + 20, + 66, + 21 + ], + [ + 186, + 226, + 246 + ], + [ + 174, + 131, + 74 + ], + [ + 77, + 62, + 50 + ], + [ + 10, + 27, + 59 + ], + [ + 146, + 148, + 144 + ], + [ + 33, + 60, + 45 + ], + [ + 59, + 85, + 13 + ], + [ + 135, + 90, + 38 + ], + [ + 1, + 133, + 126 + ], + [ + 108, + 89, + 67 + ], + [ + 12, + 58, + 4 + ], + [ + 46, + 74, + 58 + ], + [ + 6, + 39, + 75 + ], + [ + 177, + 226, + 81 + ], + [ + 92, + 88, + 82 + ], + [ + 216, + 194, + 160 + ], + [ + 163, + 83, + 12 + ], + [ + 57, + 146, + 12 + ], + [ + 136, + 115, + 84 + ], + [ + 116, + 168, + 206 + ], + [ + 77, + 55, + 37 + ], + [ + 241, + 193, + 78 + ], + [ + 7, + 33, + 6 + ], + [ + 251, + 245, + 225 + ], + [ + 74, + 128, + 169 + ], + [ + 193, + 180, + 159 + ], + [ + 112, + 204, + 100 + ], + [ + 7, + 8, + 8 + ], + [ + 159, + 112, + 46 + ], + [ + 87, + 16, + 9 + ], + [ + 14, + 122, + 169 + ], + [ + 78, + 99, + 106 + ], + [ + 59, + 143, + 51 + ], + [ + 45, + 40, + 37 + ], + [ + 39, + 43, + 130 + ], + [ + 21, + 48, + 56 + ], + [ + 41, + 8, + 2 + ], + [ + 6, + 20, + 13 + ], + [ + 37, + 62, + 126 + ], + [ + 224, + 210, + 185 + ], + [ + 27, + 12, + 3 + ], + [ + 84, + 77, + 70 + ], + [ + 153, + 140, + 119 + ], + [ + 32, + 66, + 79 + ], + [ + 19, + 18, + 11 + ], + [ + 26, + 34, + 36 + ], + [ + 177, + 41, + 11 + ], + [ + 44, + 30, + 20 + ], + [ + 39, + 86, + 116 + ], + [ + 4, + 14, + 33 + ], + [ + 10, + 5, + 2 + ], + [ + 96, + 82, + 179 + ], + [ + 1, + 45, + 42 + ], + [ + 46, + 59, + 66 + ], + [ + 103, + 189, + 236 + ], + [ + 17, + 13, + 14 + ], + [ + 52, + 19, + 2 + ], + [ + 78, + 105, + 32 + ], + [ + 121, + 56, + 219 + ], + [ + 6, + 6, + 5 + ], + [ + 6, + 20, + 47 + ], + [ + 16, + 79, + 168 + ], + [ + 182, + 95, + 14 + ], + [ + 26, + 18, + 16 + ], + [ + 132, + 177, + 49 + ], + [ + 26, + 83, + 21 + ], + [ + 117, + 143, + 153 + ], + [ + 6, + 55, + 86 + ], + [ + 247, + 235, + 199 + ], + [ + 96, + 148, + 187 + ], + [ + 21, + 21, + 21 + ], + [ + 33, + 47, + 53 + ], + [ + 152, + 129, + 95 + ], + [ + 128, + 63, + 9 + ], + [ + 83, + 170, + 70 + ], + [ + 98, + 50, + 10 + ], + [ + 193, + 141, + 73 + ], + [ + 104, + 100, + 91 + ], + [ + 6, + 24, + 6 + ], + [ + 63, + 77, + 41 + ], + [ + 224, + 222, + 213 + ], + [ + 166, + 142, + 17 + ], + [ + 34, + 29, + 27 + ], + [ + 56, + 49, + 44 + ], + [ + 1, + 61, + 58 + ], + [ + 239, + 114, + 12 + ], + [ + 215, + 164, + 97 + ], + [ + 6, + 81, + 117 + ], + [ + 210, + 179, + 130 + ], + [ + 113, + 91, + 12 + ], + [ + 9, + 93, + 134 + ], + [ + 30, + 19, + 12 + ], + [ + 72, + 49, + 27 + ], + [ + 210, + 145, + 55 + ], + [ + 22, + 28, + 28 + ], + [ + 20, + 35, + 45 + ], + [ + 37, + 120, + 6 + ], + [ + 141, + 218, + 24 + ], + [ + 161, + 208, + 238 + ], + [ + 51, + 54, + 55 + ], + [ + 91, + 66, + 41 + ], + [ + 24, + 219, + 227 + ], + [ + 45, + 55, + 7 + ], + [ + 56, + 42, + 34 + ], + [ + 89, + 72, + 9 + ], + [ + 7, + 32, + 29 + ], + [ + 2, + 84, + 81 + ], + [ + 80, + 102, + 63 + ], + [ + 59, + 9, + 7 + ], + [ + 107, + 59, + 19 + ], + [ + 60, + 96, + 169 + ], + [ + 8, + 44, + 4 + ], + [ + 123, + 92, + 54 + ], + [ + 61, + 54, + 131 + ], + [ + 6, + 2, + 0 + ], + [ + 30, + 41, + 44 + ], + [ + 32, + 91, + 37 + ], + [ + 137, + 187, + 223 + ], + [ + 47, + 59, + 34 + ], + [ + 64, + 26, + 4 + ], + [ + 14, + 51, + 17 + ], + [ + 35, + 24, + 19 + ], + [ + 24, + 15, + 8 + ], + [ + 53, + 69, + 74 + ], + [ + 115, + 51, + 6 + ], + [ + 12, + 46, + 105 + ], + [ + 119, + 81, + 37 + ], + [ + 142, + 27, + 14 + ], + [ + 71, + 68, + 62 + ], + [ + 231, + 181, + 106 + ], + [ + 120, + 70, + 26 + ], + [ + 120, + 114, + 101 + ], + [ + 110, + 22, + 18 + ], + [ + 53, + 32, + 15 + ], + [ + 13, + 19, + 22 + ], + [ + 244, + 206, + 118 + ], + [ + 9, + 60, + 135 + ], + [ + 143, + 104, + 56 + ], + [ + 4, + 14, + 4 + ], + [ + 55, + 26, + 29 + ], + [ + 7, + 21, + 24 + ], + [ + 83, + 50, + 17 + ], + [ + 243, + 212, + 145 + ], + [ + 28, + 25, + 21 + ], + [ + 33, + 46, + 25 + ], + [ + 221, + 165, + 69 + ], + [ + 154, + 95, + 37 + ], + [ + 39, + 118, + 163 + ], + [ + 198, + 151, + 90 + ], + [ + 102, + 129, + 134 + ], + [ + 25, + 94, + 4 + ], + [ + 249, + 228, + 169 + ], + [ + 244, + 207, + 32 + ], + [ + 170, + 158, + 133 + ], + [ + 1, + 173, + 159 + ], + [ + 20, + 30, + 73 + ], + [ + 104, + 70, + 31 + ], + [ + 171, + 229, + 145 + ], + [ + 43, + 26, + 12 + ], + [ + 138, + 114, + 16 + ], + [ + 188, + 170, + 139 + ], + [ + 12, + 9, + 6 + ], + [ + 43, + 20, + 4 + ], + [ + 159, + 117, + 67 + ], + [ + 89, + 169, + 212 + ], + [ + 47, + 80, + 90 + ], + [ + 103, + 79, + 50 + ], + [ + 199, + 160, + 242 + ], + [ + 201, + 173, + 21 + ], + [ + 76, + 38, + 175 + ], + [ + 33, + 207, + 36 + ], + [ + 15, + 30, + 36 + ], + [ + 250, + 251, + 247 + ], + [ + 32, + 56, + 63 + ], + [ + 46, + 89, + 142 + ], + [ + 84, + 67, + 147 + ], + [ + 140, + 77, + 21 + ], + [ + 2, + 8, + 20 + ], + [ + 87, + 40, + 6 + ], + [ + 78, + 30, + 33 + ], + [ + 27, + 36, + 89 + ], + [ + 67, + 89, + 95 + ], + [ + 46, + 15, + 19 + ], + [ + 42, + 35, + 30 + ], + [ + 89, + 72, + 57 + ], + [ + 11, + 33, + 15 + ], + [ + 189, + 130, + 50 + ], + [ + 79, + 118, + 13 + ], + [ + 9, + 11, + 12 + ], + [ + 162, + 180, + 193 + ], + [ + 66, + 111, + 138 + ], + [ + 66, + 153, + 197 + ], + [ + 162, + 168, + 168 + ], + [ + 48, + 62, + 20 + ], + [ + 63, + 35, + 12 + ], + [ + 123, + 206, + 244 + ], + [ + 1, + 0, + 0 + ], + [ + 1, + 2, + 2 + ], + [ + 68, + 16, + 17 + ], + [ + 96, + 111, + 117 + ], + [ + 43, + 117, + 40 + ], + [ + 65, + 50, + 37 + ], + [ + 17, + 11, + 8 + ], + [ + 36, + 21, + 10 + ], + [ + 195, + 209, + 210 + ] + ] +} \ No newline at end of file diff --git a/tools/pixelart/samples/sheet_recipe.png b/tools/pixelart/samples/sheet_recipe.png new file mode 100644 index 0000000..7a77cd7 Binary files /dev/null and b/tools/pixelart/samples/sheet_recipe.png differ diff --git a/tools/pixelart/samples/sheet_vibes.png b/tools/pixelart/samples/sheet_vibes.png new file mode 100644 index 0000000..5996622 Binary files /dev/null and b/tools/pixelart/samples/sheet_vibes.png differ diff --git a/tools/pixelart/samples/swamp_pixelart.png b/tools/pixelart/samples/swamp_pixelart.png new file mode 100644 index 0000000..73c9e74 Binary files /dev/null and b/tools/pixelart/samples/swamp_pixelart.png differ diff --git a/tools/pixelart/samples/treasure_pixelart.png b/tools/pixelart/samples/treasure_pixelart.png new file mode 100644 index 0000000..aa546d5 Binary files /dev/null and b/tools/pixelart/samples/treasure_pixelart.png differ diff --git a/tools/pixelart/samples/treasure_raw.png b/tools/pixelart/samples/treasure_raw.png new file mode 100644 index 0000000..11d684a Binary files /dev/null and b/tools/pixelart/samples/treasure_raw.png differ