feat(tools): AI-render → pixel-art pipeline (depth render + Primordyn dither)
- comfy-spike/comfy.py: depth → ComfyUI/Z-Image render bridge over LAN - pixelart/pixelate.py: locked Primordyn recipe (Floyd–Steinberg, OKLab, linear-light) limited-256 dither; montage.py helper; primordyn_v2 palette - README + .gitignore; curated before/after + contact-sheet samples Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0aec5e927d
commit
e3a30f6962
12 changed files with 2143 additions and 0 deletions
15
tools/.gitignore
vendored
Normal file
15
tools/.gitignore
vendored
Normal file
|
|
@ -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/
|
||||||
49
tools/README.md
Normal file
49
tools/README.md
Normal file
|
|
@ -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 <url> | nodeinfo <url> <Node...> 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.
|
||||||
416
tools/comfy-spike/comfy.py
Normal file
416
tools/comfy-spike/comfy.py
Normal file
|
|
@ -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()
|
||||||
BIN
tools/comfy-spike/corr_169.png
Normal file
BIN
tools/comfy-spike/corr_169.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
56
tools/pixelart/montage.py
Normal file
56
tools/pixelart/montage.py
Normal file
|
|
@ -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()
|
||||||
319
tools/pixelart/pixelate.py
Normal file
319
tools/pixelart/pixelate.py
Normal file
|
|
@ -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 <image> [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()
|
||||||
1288
tools/pixelart/primordyn_v2.json
Normal file
1288
tools/pixelart/primordyn_v2.json
Normal file
File diff suppressed because it is too large
Load diff
BIN
tools/pixelart/samples/sheet_recipe.png
Normal file
BIN
tools/pixelart/samples/sheet_recipe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
tools/pixelart/samples/sheet_vibes.png
Normal file
BIN
tools/pixelart/samples/sheet_vibes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 724 KiB |
BIN
tools/pixelart/samples/swamp_pixelart.png
Normal file
BIN
tools/pixelart/samples/swamp_pixelart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
BIN
tools/pixelart/samples/treasure_pixelart.png
Normal file
BIN
tools/pixelart/samples/treasure_pixelart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
tools/pixelart/samples/treasure_raw.png
Normal file
BIN
tools/pixelart/samples/treasure_raw.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
Loading…
Reference in a new issue