Brandling Safari: LAN safari-zone creature catching on the HD-2D engine

- Authoritative 30 Hz server (ENet, SQLite): accounts, characters, wild AI, tall grass /
  water / wandering spawns, fishing, captures, follower chains, clock + weather, LAN beacon
- Client: title / join / character creator / play screens, prediction + reconciliation,
  interpolated players and parties, HD-2D UI (dialogue with Professor Henry, HUD, collection)
- Shared deterministic zone generator, movement, protocol, roster, appearance
- Paper-doll character art pipeline (PixelLab base walk + per-item animation edits)
- Brandling roster from the company list (logo colors, jev types), Blender safari props
- Headless test bot
This commit is contained in:
Gabe
2026-10-02 15:20:27 -05:00
commit 238cd9c6c3
3409 changed files with 100493 additions and 0 deletions

1099
tools/blender_safari.py Normal file

File diff suppressed because it is too large Load Diff

450
tools/build_roster.py Executable file
View File

@@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""Build the Brandling roster from the web-design client export.
python tools/build_roster.py --csv ../clickup_web_design_export.csv [--limit N] [--jobs 16]
Pipeline (every network step is cached in art_src/creatures/cache/, so reruns only do new work):
1. tools/roster_csv.py reads the CSV: company name, domain, staging-site URL, services and key
highlights, colour words of the logo note. Nothing else leaves the CSV. Non-companies are
dropped and duplicates (same normalised name) merged.
2. Logos: Company_Sprite_Gen/logo_fetch.py looks on the domain's homepage, then on the
staging site, then site icons and favicon services. cache/logos/<domain>.png + .json.
A logo shared by 2+ companies is a template placeholder (e.g. the agency's own logo on an
unfinished staging site) and is ignored.
3. Types: Company_Sprite_Gen/business_pokemon_type.py (OpenRouter jev classifier) on
"name + services + highlights". cache/types.json keeps choice + probabilities per slug.
4. Sprites: Company_Sprite_Gen/generate_sprite.generate(..., write_files=False) with the
logo's colours, else the colours named in the logo note, else a per-creature shade of
the type's colour (type_variant_colors).
cache/render.json skips sprites whose inputs didn't change (--rerender redoes all).
5. Writes assets/creatures/roster.json, sprites/<slug>.png, contact_sheet.png and
art_src/creatures/report.json.
Species ids are permanent (players' saves store them; the game requires id == index):
the first run (no roster.json yet) numbers species in slug order. Every later run keeps each
slug's id, appends new slugs after the last id (in slug order) and keeps companies that left
the CSV in the roster with "retired": true, their sprite kept, so no id is ever reused; a
retired company that comes back gets its old id. A --limit run leaves the species it didn't
sample untouched instead of retiring them. Every entry has "retired" (false for live ones).
OPENROUTER_API_KEY comes from the environment or ../.env (next to this repo).
"""
import argparse
import colorsys
import hashlib
import json
import os
import random
import re
import sys
import threading
import time
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from pathlib import Path
TOOLS = Path(__file__).resolve().parent
ROOT = TOOLS.parent
CSG = Path(os.environ.get("COMPANY_SPRITE_GEN", ROOT.parent / "Company_Sprite_Gen")).resolve()
ENV_FILE = ROOT.parent / ".env"
SRC = ROOT / "art_src" / "creatures"
CACHE = SRC / "cache"
LOGOS = CACHE / "logos"
TYPES_CACHE = CACHE / "types.json"
RENDER_CACHE = CACHE / "render.json"
REPORT = SRC / "report.json"
OUT = ROOT / "assets" / "creatures"
SPRITES = OUT / "sprites"
LIBRARY = "pixellab"
SPRITE_SIZE = 64
SHARED_LOGO_LIMIT = 2 # the same logo image on this many companies = template placeholder
TYPE2_MIN = 0.25
RARITY_CUTS = (("common", 0.60), ("uncommon", 0.87), ("rare", 0.97), ("legendary", 1.0))
RARITY_RANK = {r: i for i, (r, _) in enumerate(RARITY_CUTS)}
RATES = {"common": (0.55, 0.08), "uncommon": (0.40, 0.12), "rare": (0.25, 0.18), "legendary": (0.12, 0.25)}
BOTH_HABITAT_TYPES = {"ice", "flying", "poison"}
BOTH_HABITAT_CHANCE = 0.4
CONTACT_COUNT = 300
def log(msg):
print(msg, flush=True)
def setup_paths():
"""Make Company_Sprite_Gen importable. Its part registries resolve their art folders
relative to the working directory, so the process works from there (all our paths are
absolute)."""
for p in (str(TOOLS), str(CSG)):
if p not in sys.path:
sys.path.insert(0, p)
os.chdir(CSG)
def load_env():
if os.environ.get("OPENROUTER_API_KEY") or not ENV_FILE.exists():
return
for line in ENV_FILE.read_text().splitlines():
key, sep, value = line.strip().partition("=")
if sep and key.strip() == "OPENROUTER_API_KEY":
os.environ["OPENROUTER_API_KEY"] = value.strip().strip("'\"")
def unit(slug, salt):
"""Deterministic number in [0, 1) from the slug."""
return int(hashlib.sha256(f"{salt}:{slug}".encode()).hexdigest()[:13], 16) / 16 ** 13
def read_json(path, default):
try:
return json.loads(Path(path).read_text())
except (OSError, ValueError):
return default
def write_json(path, data, indent=1):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=indent, sort_keys=True) + "\n")
tmp.replace(path)
# ============================================================ logos
def fetch_logos(companies, jobs):
"""slug -> (Path or None, method or None) for every company."""
import logo_fetch
out = {c.slug: (None, None) for c in companies}
todo = [c for c in companies if c.domain or c.staging]
t0 = time.time()
with ThreadPoolExecutor(jobs) as ex:
futs = {ex.submit(logo_fetch.fetch_logo_info, c.domain, LOGOS, [c.staging] if c.staging else [],
None if c.domain else f"name-{c.slug}"): c for c in todo}
for i, f in enumerate(as_completed(futs), 1):
c = futs[f]
try:
out[c.slug] = f.result()
except Exception as e: # one broken site must not stop the run
log(f" ! logo {c.slug}: {type(e).__name__}: {e}")
if i % 100 == 0 or i == len(todo):
log(f" logos {i}/{len(todo)} ({time.time() - t0:.0f}s)")
return out
def logo_digest(path):
"""Fingerprint that survives re-encoding / resizing (catches one template on many sites)."""
from PIL import Image
img = Image.open(path).convert("RGBA").resize((16, 16), Image.BILINEAR)
return hashlib.sha1(bytes(v >> 4 for v in img.tobytes())).hexdigest()
def drop_shared_logos(logos):
digests = {slug: logo_digest(p) for slug, (p, _) in logos.items() if p}
counts = Counter(digests.values())
shared = {slug for slug, d in digests.items() if counts[d] >= SHARED_LOGO_LIMIT}
for slug in shared:
logos[slug] = (None, None)
return shared, digests
# ============================================================ types
def description(c):
d = f"{c.name}."
if c.services:
d += " Services: " + ", ".join(c.services[:10]) + "."
if c.highlights:
d += " " + "; ".join(c.highlights[:3]) + "."
return d[:600]
def classify_retry(get_type, desc, attempts=6):
import requests
for i in range(attempts):
try:
ans = get_type(desc)
if ans.get("choice"):
return ans
except requests.HTTPError as e:
code = e.response.status_code if e.response is not None else 0
if code in (400, 401, 402, 403):
raise
except (requests.RequestException, ValueError, KeyError):
pass
time.sleep(min(30, 2 ** i) + random.random())
return None
def classify_all(companies, jobs):
from sprite_parts import TYPES
cache = read_json(TYPES_CACHE, {})
todo = [c for c in companies if cache.get(c.slug, {}).get("desc") != description(c)]
if todo:
load_env()
if not os.environ.get("OPENROUTER_API_KEY"):
sys.exit(f"OPENROUTER_API_KEY is not set (environment or {ENV_FILE})")
from business_pokemon_type import get_business_type
lock = threading.Lock()
t0 = time.time()
with ThreadPoolExecutor(jobs) as ex:
futs = {ex.submit(classify_retry, get_business_type, description(c)): c for c in todo}
for i, f in enumerate(as_completed(futs), 1):
c = futs[f]
ans = f.result()
if ans and ans["choice"].lower() in TYPES:
with lock:
cache[c.slug] = {"desc": description(c), "choice": ans["choice"].lower(),
"probabilities": {k.lower(): v for k, v in ans.get("probabilities", {}).items()}}
else:
log(f" ! no type for {c.slug} (will retry next run)")
if i % 100 == 0 or i == len(todo):
with lock:
write_json(TYPES_CACHE, cache)
log(f" types {i}/{len(todo)} ({time.time() - t0:.0f}s)")
return cache
# ============================================================ sprites
_lib = None
def _worker_init():
setup_paths()
def render_one(name, ptype, probs, logo, colors):
"""(info, png bytes, anchor row) for one creature. Runs in a worker process."""
global _lib
import io
import numpy as np
import generate_sprite as gs
if _lib is None:
_lib = gs.load_library(LIBRARY)
info, sprite = gs.generate(_lib, name, None, ptype, probs, logo=logo, colors=colors, write_files=False)
if sprite.size != (SPRITE_SIZE, SPRITE_SIZE):
raise ValueError(f"{name}: sprite is {sprite.size}, expected {SPRITE_SIZE}x{SPRITE_SIZE}")
rows = np.nonzero(np.array(sprite)[:, :, 3].any(axis=1))[0]
buf = io.BytesIO()
sprite.save(buf, "PNG", optimize=True)
keep = {"plan": info["plan"], "attributes": info["attributes"],
"colors": {"main": info["colors"]["main"].lower(), "accent": info["colors"]["accent"].lower()}}
return keep, buf.getvalue(), int(rows[-1]) if len(rows) else SPRITE_SIZE - 1
def render_all(jobs_in, jobs, rerender):
"""jobs_in: {slug: (name, ptype, probs, logo_path, colors, digest)} -> {slug: render entry}."""
cache = {} if rerender else read_json(RENDER_CACHE, {})
SPRITES.mkdir(parents=True, exist_ok=True)
out, todo = {}, []
for slug, (name, ptype, probs, logo, colors, digest) in jobs_in.items():
key = hashlib.sha1(json.dumps([LIBRARY, name, ptype, digest, colors]).encode()).hexdigest()
hit = cache.get(slug)
if hit and hit.get("key") == key and (SPRITES / f"{slug}.png").exists():
out[slug] = hit
else:
todo.append((slug, key, name, ptype, probs, logo, colors))
if todo:
t0 = time.time()
with ProcessPoolExecutor(jobs, initializer=_worker_init) as ex:
futs = {ex.submit(render_one, name, ptype, probs, str(logo) if logo else None, colors): (slug, key)
for slug, key, name, ptype, probs, logo, colors in todo}
for i, f in enumerate(as_completed(futs), 1):
slug, key = futs[f]
info, png, anchor = f.result()
(SPRITES / f"{slug}.png").write_bytes(png)
out[slug] = cache[slug] = {"key": key, "anchorRow": anchor, **info}
if i % 200 == 0 or i == len(todo):
log(f" sprites {i}/{len(todo)} ({time.time() - t0:.0f}s)")
write_json(RENDER_CACHE, {s: e for s, e in cache.items() if s in jobs_in})
return out
# ============================================================ roster
def type_variant_colors(slug, ptype):
"""Colours for a creature with no logo and no colour note: its type colour, shifted per
slug in hue / saturation / lightness so the many logo-less creatures of one type don't
all share one palette."""
from sprite_parts import TYPE_COLORS
h = TYPE_COLORS[ptype].lstrip("#")
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
hue, light, sat = colorsys.rgb_to_hls(r, g, b)
hue = (hue + (unit(slug, "hue") - 0.5) * 0.16) % 1.0
sat = min(max(sat * (0.75 + unit(slug, "sat") * 0.6), 0.25), 0.95)
light = min(max(light + (unit(slug, "light") - 0.5) * 0.24, 0.25), 0.72)
rgb = colorsys.hls_to_rgb(hue, light, sat)
return ["#" + "".join(f"{round(v * 255):02x}" for v in rgb)]
def rarity_for(slug, ptype):
u = unit(slug, "rarity")
rarity = next(r for r, cut in RARITY_CUTS if u < cut)
if ptype == "dragon" and RARITY_RANK[rarity] < RARITY_RANK["rare"]:
rarity = "rare"
return rarity
def rates_for(slug, rarity):
catch, flee = RATES[rarity]
catch += (unit(slug, "catch") - 0.5) * 0.06
flee += (unit(slug, "flee") - 0.5) * 0.04
return round(min(max(catch, 0.0), 1.0), 3), round(min(max(flee, 0.0), 1.0), 3)
def habitat_for(slug, ptype):
if ptype == "water":
return "water"
if ptype in BOTH_HABITAT_TYPES and unit(slug, "habitat") < BOTH_HABITAT_CHANCE:
return "both"
return "grass"
def second_type(ptype, probs):
ranked = sorted(((p, t) for t, p in (probs or {}).items() if t != ptype), reverse=True)
return ranked[0][1] if ranked and ranked[0][0] >= TYPE2_MIN else None
def assign_ids(current, previous, partial=False):
"""Species ids are stored in players' saves, so they never change once assigned.
current: {slug: entry without id} for this run; previous: the species list of the existing
roster.json ([] on a first run). Every slug keeps its old id; a slug that is gone stays
in the list as its old entry with "retired": true (ids stay contiguous, id == index, and
a retired slug that comes back gets its id back); new slugs get the next ids in slug order.
With partial=True (a --limit run) slugs missing from this run keep their old entry as is.
Returns (species, new id count, retired count)."""
species = []
for i, old in enumerate(sorted(previous, key=lambda s: s["id"])):
if old["id"] != i:
sys.exit(f"roster.json is corrupt: species {old.get('slug')} has id {old['id']} at index {i}")
entry = current.get(old["slug"])
if entry:
species.append({"id": i, **entry, "retired": False})
else:
species.append({**old, "retired": bool(old.get("retired")) if partial else True})
known = {s["slug"] for s in species}
new = sorted(slug for slug in current if slug not in known)
for slug in new:
species.append({"id": len(species), **current[slug], "retired": False})
return species, len(new), sum(1 for s in species if s["retired"])
def write_roster(species):
lines = ",\n".join(" " + json.dumps(s, separators=(", ", ": ")) for s in species)
text = f'{{"version": 1, "spriteSize": {SPRITE_SIZE}, "species": [\n{lines}\n]}}\n'
OUT.mkdir(parents=True, exist_ok=True)
(OUT / "roster.json").write_text(text)
def contact_sheet(species, path, count=CONTACT_COUNT, cols=20, scale=2):
from PIL import Image, ImageDraw, ImageFont
pick = random.Random(7).sample(species, min(count, len(species)))
cell_w, cell_h, label = SPRITE_SIZE * scale + 8, SPRITE_SIZE * scale + 30, 28
rows = (len(pick) + cols - 1) // cols
sheet = Image.new("RGB", (cols * cell_w + 8, rows * cell_h + 8), (58, 66, 62))
d = ImageDraw.Draw(sheet)
font = ImageFont.load_default(size=11)
for i, s in enumerate(pick):
x, y = 8 + (i % cols) * cell_w, 8 + (i // cols) * cell_h
d.rectangle((x - 2, y - 2, x + SPRITE_SIZE * scale + 1, y + SPRITE_SIZE * scale + 1),
fill=(78, 90, 84), outline=(222, 200, 120) if s["logo"] else (78, 90, 84))
spr = Image.open(OUT / s["sprite"]).convert("RGBA")
spr = spr.resize((SPRITE_SIZE * scale, SPRITE_SIZE * scale), Image.NEAREST)
sheet.paste(spr, (x, y), spr)
name = s["name"] if len(s["name"]) <= 22 else s["name"][:21] + "…"
d.text((x, y + SPRITE_SIZE * scale + 2), name, fill=(240, 240, 232), font=font)
d.text((x, y + SPRITE_SIZE * scale + 2 + label // 2), f"{s['type']}{'/' + s['type2'] if s['type2'] else ''}"
f" {s['rarity'][0].upper()}", fill=(180, 190, 180), font=font)
sheet.save(path)
def main():
ap = argparse.ArgumentParser(description="Build assets/creatures/ from the client CSV")
ap.add_argument("--csv", required=True, help="ClickUp web-design export")
ap.add_argument("--limit", type=int, help="Only this many companies (a fixed random sample, for testing)")
ap.add_argument("--jobs", type=int, default=16, help="Parallel logo downloads and sprite renders")
ap.add_argument("--type-jobs", type=int, default=8, help="Parallel classifier calls")
ap.add_argument("--rerender", action="store_true", help="Render every sprite again")
a = ap.parse_args()
csv_path = Path(a.csv).resolve()
setup_paths()
import roster_csv
log(f"reading {csv_path.name}")
companies, stats = roster_csv.load_companies(csv_path)
if a.limit:
companies = sorted(random.Random(0).sample(companies, min(a.limit, len(companies))), key=lambda c: c.slug)
log(f" {stats['rows']} rows -> {stats['kept']} companies "
f"({stats['not_company']} not companies, {stats['duplicates']} duplicates); using {len(companies)}")
log("logos")
logos = fetch_logos(companies, a.jobs)
shared, digests = drop_shared_logos(logos)
log("types")
types = classify_all(companies, a.type_jobs)
companies = [c for c in companies if c.slug in types]
log("sprites")
jobs_in = {}
for c in companies:
logo = logos[c.slug][0]
colors = None if logo else (c.colors or type_variant_colors(c.slug, types[c.slug]["choice"]))
jobs_in[c.slug] = (c.name, types[c.slug]["choice"], types[c.slug]["probabilities"], logo, colors,
digests.get(c.slug) if logo else None)
renders = render_all(jobs_in, a.jobs, a.rerender)
current = {}
for c in companies:
t = types[c.slug]
r = renders[c.slug]
rarity = rarity_for(c.slug, t["choice"])
catch, flee = rates_for(c.slug, rarity)
current[c.slug] = {
"slug": c.slug, "name": c.name, "domain": c.domain,
"type": t["choice"], "type2": second_type(t["choice"], t["probabilities"]),
"rarity": rarity, "catchRate": catch, "fleeRate": flee, "habitat": habitat_for(c.slug, t["choice"]),
"colors": r["colors"], "sprite": f"sprites/{c.slug}.png", "anchorRow": r["anchorRow"],
"body": r["plan"], "attributes": r["attributes"], "logo": logos[c.slug][0] is not None,
}
species, new_ids, retired = assign_ids(current, read_json(OUT / "roster.json", {}).get("species", []),
partial=bool(a.limit))
write_roster(species)
live = {Path(s["sprite"]).name for s in species}
stale = [p for p in SPRITES.glob("*.png") if p.name not in live]
for p in stale:
p.unlink()
active = [s for s in species if not s["retired"]]
contact_sheet(active, OUT / "contact_sheet.png")
methods = Counter("no-domain" if not (c.domain or c.staging) else "shared-template" if c.slug in shared
else (logos[c.slug][1] or "not-found") for c in companies)
report = {
"rows": stats["rows"], "notCompanies": stats["not_company"], "duplicates": stats["duplicates"],
"companies": stats["kept"], "kept": len(active), "newIds": new_ids, "retired": retired,
"withDomain": sum(1 for c in companies if c.domain),
"withStagingSite": sum(1 for c in companies if c.staging),
"logoSearched": sum(1 for c in companies if c.domain or c.staging),
"logosFound": sum(1 for s in active if s["logo"]),
"logosByMethod": dict(methods.most_common()),
"logosRejectedShared": len(shared),
"colorsFromLogoNote": sum(1 for c in companies if not logos[c.slug][0] and c.colors),
"colorsFromType": sum(1 for c in companies if not logos[c.slug][0] and not c.colors),
"types": dict(Counter(s["type"] for s in active).most_common()),
"type2": dict(Counter(s["type2"] for s in active if s["type2"]).most_common()),
"rarity": dict(Counter(s["rarity"] for s in active).most_common()),
"habitat": dict(Counter(s["habitat"] for s in active).most_common()),
"bodies": dict(Counter(s["body"] for s in active).most_common()),
}
write_json(REPORT, report, indent=2)
log(f"{len(species)} species ({len(active)} active, {retired} retired, {new_ids} new ids) -> "
f"{OUT / 'roster.json'} ({len(stale)} stale sprites removed)")
log(json.dumps({k: report[k] for k in ("logosFound", "logosByMethod", "types")}))
if __name__ == "__main__":
main()

485
tools/character_art.py Executable file
View File

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

View File

@@ -0,0 +1,72 @@
"""Reference compositor for player sprites (the C++ client mirrors this in client/CharacterSprite.cpp).
look = {"body": "male", "skin": 0..5, "items": {slot: item_id or None}, "colors": {slot: "#rrggbb"}}
"""
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
CHAR_DIR = ROOT / "assets" / "characters"
def hex_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def ramp(hex_color, shades=4):
"""Shade 1 (darkest) .. 4 (highlight) for one base color. Must match colorRamp() in
client/CharacterSprite.cpp. Multiplicative per channel (cool, slightly saturated shadows,
warm highlights), so white, black and very light colors all shade sensibly."""
c = hex_rgb(hex_color)
table = [(0.50, (0.96, 0.90, 1.06)), (0.73, (0.99, 0.95, 1.03)), (1.0, (1.0, 1.0, 1.0))]
out = []
for k, tint in table:
out.append(tuple(max(0, min(255, round(ch * k * t))) for ch, t in zip(c, tint)))
warm = (255, 250, 232)
out.append(tuple(round(ch + (w - ch) * 0.34) for ch, w in zip(c, warm)))
return out[:shades]
def _layer(path):
return np.asarray(Image.open(path).convert("RGBA"))
def _paint_shade(canvas, shade, color, shades):
lv = (shade[..., 0].astype(int) + 30) // 60
m = shade[..., 3] > 127
pal = ramp(color, shades)
for i in range(1, shades + 1):
sel = m & (lv == i)
canvas[sel, :3] = pal[i - 1]
canvas[sel, 3] = 255
def _paint_fixed(canvas, fixed):
m = fixed[..., 3] > 127
canvas[m] = fixed[m]
def compose_sheet(manifest, look):
body = look["body"]
d = CHAR_DIR / body
shades = manifest["shades"]
items = manifest["items"]
chosen = look.get("items", {})
colors = look.get("colors", {})
body_img = _layer(d / "body.png")
canvas = np.zeros_like(body_img)
_paint_shade(canvas, _layer(d / "skin_shade.png"), manifest["skinTones"][look.get("skin", 0)], shades)
for base_item, slot in (("top_tee", "top"), ("bottom_shorts", "bottom")):
color = colors.get(slot) if chosen.get(slot) == base_item else None
_paint_shade(canvas, _layer(d / f"{base_item}_shade.png"), color or items[base_item]["color"], shades)
_paint_fixed(canvas, body_img)
for slot, _z in sorted(manifest["slots"].items(), key=lambda kv: kv[1]):
item_id = chosen.get(slot)
if not item_id or items.get(item_id, {}).get("fromBody"):
continue
_paint_shade(canvas, _layer(d / f"{item_id}_shade.png"), colors.get(slot) or items[item_id]["color"], shades)
_paint_fixed(canvas, _layer(d / f"{item_id}.png"))
return Image.fromarray(canvas)

171
tools/pixellab_client.py Normal file
View File

@@ -0,0 +1,171 @@
"""Minimal PixelLab v2 client for the Brandling Safari art tools.
- Token from PIXELLAB_API_KEY (environment, or a .env in this repo or its parent folder).
- Retries rate limits / transient errors, polls background jobs.
- Appends one line per paid call to art_src/pixellab_ledger.jsonl (`python tools/pixellab_client.py`
prints the spend so far and the account balance).
"""
import base64
import io
import json
import os
import random
import sys
import time
from pathlib import Path
import requests
from PIL import Image
API = "https://api.pixellab.ai/v2"
ROOT = Path(__file__).resolve().parent.parent
LEDGER = ROOT / "art_src" / "pixellab_ledger.jsonl"
PENDING = ROOT / "art_src" / ".pending"
POLL_SECONDS = 5
JOB_TIMEOUT_SECONDS = 1200
class ApiError(Exception):
pass
def load_key(name="PIXELLAB_API_KEY"):
if os.environ.get(name):
return os.environ[name]
for env in (ROOT / ".env", ROOT.parent / ".env"):
if env.exists():
for line in env.read_text().splitlines():
k, sep, v = line.strip().partition("=")
if sep and k.strip() == name:
return v.strip().strip("'\"")
sys.exit(f"{name} not set (environment, {ROOT / '.env'} or {ROOT.parent / '.env'})")
def b64(img: Image.Image) -> dict:
buf = io.BytesIO()
img.save(buf, format="PNG")
return {"type": "base64", "base64": base64.b64encode(buf.getvalue()).decode(), "format": "png"}
def decode(field) -> Image.Image:
data = field["base64"] if isinstance(field, dict) else field
if data.startswith("data:"):
data = data.split(",", 1)[1]
return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGBA")
def images_in(obj):
"""Every image in a response, depth-first in document order."""
out = []
if isinstance(obj, dict):
if obj.get("type") == "base64" and "base64" in obj:
return [decode(obj)]
if "base64" in obj and isinstance(obj["base64"], str) and len(obj["base64"]) > 64:
return [decode(obj)]
for v in obj.values():
out += images_in(v)
elif isinstance(obj, list):
for v in obj:
out += images_in(v)
elif isinstance(obj, str) and obj.startswith("data:image"):
out.append(decode(obj))
return out
def redact(obj):
if isinstance(obj, dict):
if "base64" in obj:
return "<image>"
return {k: redact(v) for k, v in obj.items()}
if isinstance(obj, list):
return [redact(v) for v in obj]
return obj
class Client:
def __init__(self):
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {load_key()}"
def call(self, method, path, payload=None):
for attempt in range(8):
try:
r = self.session.request(method, f"{API}/{path}", json=payload, timeout=300)
except requests.RequestException as e:
if attempt == 7:
raise ApiError(str(e))
time.sleep(5 + attempt * 5)
continue
if r.status_code in (429, 502, 503, 529) and attempt < 7:
time.sleep(min(60, 6 * (attempt + 1)) + random.uniform(0, 3))
continue
if not r.ok:
raise ApiError(f"{method} {path}: {r.status_code} {r.text[:600]}")
return r.json() if r.content else {}
def balance(self):
return self.call("GET", "balance")
def wait_job(self, job_id):
deadline = time.time() + JOB_TIMEOUT_SECONDS
while True:
if time.time() > deadline:
raise ApiError(f"job {job_id} still running after {JOB_TIMEOUT_SECONDS}s")
status = self.call("GET", f"background-jobs/{job_id}")
if status.get("status") == "failed":
raise ApiError(f"job {job_id} failed: {json.dumps(redact(status))[:600]}")
if status.get("status") == "completed":
return status
time.sleep(POLL_SECONDS)
def run(self, endpoint, payload, label):
"""POST and wait for every background job it starts. Returns (first response, job results).
The job ids are written to art_src/.pending/<label>.json before waiting, so if this process
is interrupted the next run with the same label picks the paid-for jobs up instead of
starting (and paying for) new ones."""
pending = PENDING / (label.replace("/", "__") + ".json")
resp = None
if pending.exists():
saved = json.loads(pending.read_text())
if saved.get("endpoint") == endpoint:
resp = saved["response"]
print(f"[resume] {label}: waiting for {len(saved.get('jobs', []))} earlier job(s)", flush=True)
if resp is None:
resp = self.call("POST", endpoint, payload)
jobs = list(resp.get("background_job_ids") or [])
if resp.get("background_job_id"):
jobs.append(resp["background_job_id"])
if jobs:
PENDING.mkdir(parents=True, exist_ok=True)
pending.write_text(json.dumps({"endpoint": endpoint, "jobs": jobs, "response": redact(resp)}))
results, usages = [resp], [resp.get("usage")]
for job in jobs:
st = self.wait_job(job)
results.append(st.get("last_response") or {})
usages.append(st.get("usage"))
self.log(label, endpoint, [u for u in usages if u])
pending.unlink(missing_ok=True)
return resp, results[1:] if jobs else results
def log(self, label, endpoint, usage):
LEDGER.parent.mkdir(parents=True, exist_ok=True)
with LEDGER.open("a") as f:
f.write(json.dumps({"time": round(time.time()), "label": label, "endpoint": endpoint, "usage": usage}) + "\n")
def spend():
total, by = 0.0, {}
if LEDGER.exists():
for line in LEDGER.read_text().splitlines():
d = json.loads(line)
g = sum(float(u.get("generations", 0) or 0) for u in d["usage"] if isinstance(u, dict))
total += g
by[d["endpoint"]] = by.get(d["endpoint"], 0.0) + g
return total, by
if __name__ == "__main__":
total, by = spend()
print(f"ledger: {total:.0f} generations", json.dumps(by))
print(json.dumps(Client().balance()))

218
tools/roster_csv.py Normal file
View File

@@ -0,0 +1,218 @@
"""Read the web-design client export (ClickUp CSV) into a clean company list.
Only the company name, its domain, the staging-site URL (used to look for a logo, never
stored), the services / key-highlights text (for type classification) and the colour words
of the logo note are extracted. Every other field (logins, contacts, addresses, client names)
is ignored.
"""
import csv
import re
import unicodedata
from dataclasses import dataclass, field
csv.field_size_limit(1 << 30)
# Lines that start a new section of the task text (end of a services / highlights list).
SECTION = re.compile(r"^(additional information|key highlights|account manager|client name|business address|"
r"domain|site pages|logo|contact|login|theme|target city|business hours|phone|email|"
r"do we have|uploaded logo|client desired|if “other”|if \"other\")\b", re.I)
BOILERPLATE = {"locally owned", "family owned", "reliable", "free estimates", "free estimate", "licensed",
"insured", "licensed and insured", "licensed & insured", "veteran owned", "woman owned",
"women owned", "locally owned, family owned", "bonded", "affordable", "trusted",
"satisfaction guaranteed", "24/7", "emergency services", "competitive pricing", "quality work"}
PRIVATE = re.compile(r"@|https?:|www\.|\d[\d\s().-]{6,}\d|password|username|login", re.I)
LEGAL = re.compile(r"[\s,]+(?:l\.?\s?l\.?\s?c\.?|inc\.?|incorporated|corp\.?|corporation|ltd\.?|limited|"
r"p\.?\s?l\.?\s?l\.?\s?c\.?|l\.?\s?l\.?\s?p\.?|p\.?\s?c\.?|p\.?\s?a\.?|l\.?c\.?|co\.?)\s*$", re.I)
NOT_COMPANY = re.compile(r"^(test|testing|template|copy of|untitled|new task|sample|demo|example|tbd|n/?a|"
r"none|unknown|duplicate|delete|do not use)\b", re.I)
# Short all-caps words that are real words (title-cased when fixing a shouted name);
# every other word of 1-3 letters is kept as an acronym (HVAC-style initials).
SHORT_WORDS = {"the", "and", "of", "for", "all", "pro", "top", "one", "big", "new", "sun", "bay", "oak", "art",
"car", "dog", "fix", "man", "men", "way", "day", "air", "hot", "red", "sky", "sea", "ace", "job",
"bee", "bug", "cut", "hub", "go", "my", "on", "to", "in", "at", "by", "a", "an", "or", "us", "we",
"box", "fox", "elk", "owl", "ant", "pet", "pup", "joy", "sol", "max", "tri", "bro", "now", "get",
"our", "you", "pine", "key", "inn", "spa", "tub", "pool", "dry", "wet", "mud", "ice", "tile",
"it", "is", "git", "don", "co", "do", "no", "up", "me", "dad", "mom", "son", "sons",
"two", "six", "ten", "own", "pal", "rad", "fun", "eco", "zen", "gem", "pop", "wow", "yes", "hey"}
COLOR_WORDS = [ # longest phrases first
("light purple", "#B39DDB"), ("dark purple", "#4A148C"), ("light blue", "#4FC3F7"), ("baby blue", "#81D4FA"),
("sky blue", "#4FC3F7"), ("royal blue", "#2747C8"), ("dark blue", "#0D2B6B"), ("navy blue", "#1A237E"),
("light green", "#7CB342"), ("lime green", "#7CB342"), ("dark green", "#1B5E20"), ("forest green", "#1B5E20"),
("hunter green", "#2E5A3A"), ("dark red", "#8B1A1A"), ("light grey", "#BDBDBD"), ("light gray", "#BDBDBD"),
("dark grey", "#4A4A4A"), ("dark gray", "#4A4A4A"), ("light pink", "#F8BBD0"), ("hot pink", "#E91E63"),
("navy", "#1A237E"), ("maroon", "#7B1E2B"), ("burgundy", "#7B1E2B"), ("crimson", "#B71C1C"),
("red", "#C62828"), ("orange", "#EF6C00"), ("yellow", "#FBC02D"), ("gold", "#D4A017"), ("golden", "#D4A017"),
("green", "#2E7D32"), ("lime", "#9CCC65"), ("olive", "#6B7A2A"), ("teal", "#00897B"),
("turquoise", "#26C6DA"), ("aqua", "#26C6DA"), ("cyan", "#00BCD4"), ("blue", "#1565C0"),
("purple", "#6A1B9A"), ("violet", "#7E57C2"), ("lavender", "#B39DDB"), ("magenta", "#C2185B"),
("pink", "#EC407A"), ("brown", "#6D4C41"), ("tan", "#C8A97E"), ("beige", "#D7C4A3"), ("cream", "#F3E5AB"),
("copper", "#B87333"), ("bronze", "#CD7F32"), ("rust", "#B7410E"), ("charcoal", "#36454F"),
("black", "#212121"), ("white", "#FAFAFA"), ("grey", "#9E9E9E"), ("gray", "#9E9E9E"), ("silver", "#BDBDBD"),
]
@dataclass
class Company:
name: str # display name
slug: str
key: str # dedupe key (normalised name)
domain: str | None = None
staging: str | None = None # staging site root URL (logo search only; never written out)
services: list = field(default_factory=list)
highlights: list = field(default_factory=list)
colors: list = field(default_factory=list) # hex colours named in the logo note
has_company_line: bool = False
richness: tuple = ()
def slugify(s):
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def _fix_case(name):
"""Title-case a shouted (ALL-CAPS) or all-lowercase name. In a shouted name, runs of 1-3
letters stay uppercase (initials like KCS, HVAC-style) unless they are common words."""
letters = [c for c in name if c.isalpha()]
if not letters:
return name
shouted = all(c.isupper() for c in letters) and len(letters) >= 4 and \
any(len(w) >= 4 for w in re.findall(r"[^\W\d_]+", name))
if not (shouted or all(c.islower() for c in letters)):
return name
def run(m):
w = m.group(0)
if shouted and len(w) <= 3 and w.lower() not in SHORT_WORDS:
return w
return w[:1].upper() + w[1:].lower()
name = re.sub(r"[^\W\d_]+", run, name)
return re.sub(r"(['’])S\b", r"\1s", name)
def clean_name(raw):
"""Display name: drop legal suffixes and quotes, fix ALL-CAPS or all-lowercase names."""
s = unicodedata.normalize("NFKC", raw or "").replace("\u00a0", " ")
s = re.sub(r"\s+", " ", s).strip().strip("\"'“”*").strip()
s = re.sub(r"\s*\((?:redesign|old|new|copy|duplicate|dupe|2nd|second|rebuild|canceled|cancelled)[^)]*\)\s*$", "", s, flags=re.I)
for _ in range(3):
m = LEGAL.search(s)
if not m:
break
rest = s[:m.start()].rstrip(" ,&-")
# "Co." only goes when a real name is left ("Health Co." keeps it)
if re.fullmatch(r"[\s,]+co\.?\s*", m.group(0), re.I) and len(rest.split()) < 2:
break
s = rest
s = s.strip(" ,.-&")
return _fix_case(s)
def _value(text, label):
m = re.search(rf"^\s*{label}\s*:\s*(.*)$", text, re.I | re.M)
return m.group(1).strip() if m else ""
def _section(lines, start_pat, limit=14):
out, on = [], False
for line in lines:
s = line.strip()
if not on:
if re.match(start_pat, s, re.I):
on = True
rest = re.sub(start_pat, "", s, flags=re.I).strip(" :")
if rest:
out.append(rest)
continue
if not s or SECTION.match(s):
if out or SECTION.match(s):
break
continue
out.append(s)
if len(out) >= limit:
break
items = []
for s in out:
s = re.sub(r"^[-•*·\d.)\s]+", "", s).strip(" ,;")
if not s or PRIVATE.search(s) or len(s) > 80:
continue
items.append(s)
return items
def _domain(text):
"""The client's desired domain, else the site being redesigned. clickwise.dev never counts."""
from logo_fetch import normalize_domain # Company_Sprite_Gen (on sys.path)
values = [_value(text, "Client desired domain name")]
m = re.search(r"is a redesign of\s*:?\s*(\S+)", text, re.I)
if m:
values.append(m.group(1))
for v in values:
for tok in re.findall(r"(?:https?://)?(?:www\.)?[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}", v):
d = normalize_domain(tok)
if d and not d.endswith("clickwise.dev") and not d.endswith("jotform.com"):
return d
return None
def _colors(text):
note = _value(text, r"Do we have their logo\? If not what needs to be done")
note = note.lower()
found = []
for word, hexv in COLOR_WORDS:
for m in re.finditer(rf"\b{re.escape(word)}\b", note):
found.append((m.start(), word, hexv))
note = note[:m.start()] + " " * len(word) + note[m.end():]
return [h for _, _, h in sorted(found)][:3]
def parse_row(row):
text = (row.get("Task Content") or "").replace("\\n", "\n")
company_line = _value(text, "Company name")
raw = company_line if company_line and company_line.upper() not in ("NA", "N/A") else row.get("Task Name", "")
name = clean_name(raw)
if not name or sum(ch.isalpha() for ch in name) < 3 or NOT_COMPANY.match(name) or not slugify(name):
return None
lines = text.split("\n")
staging = None
m = re.search(r"https?://([a-z0-9-]+)\.clickwise\.dev", text, re.I)
if m:
staging = f"https://{m.group(1).lower()}.clickwise.dev/"
services = _section(lines, r"^\(?services\)?\s*:?")
highlights = [h for h in _section(lines, r"^key highlights\s*:?", limit=8) if h.lower() not in BOILERPLATE]
c = Company(name=name, slug=slugify(name), key=re.sub(r"^the-", "", slugify(name)).replace("-", ""),
domain=_domain(text), staging=staging, services=services, highlights=highlights,
colors=_colors(text), has_company_line=bool(company_line))
c.richness = (c.has_company_line, bool(c.domain), len(c.services), bool(c.staging), len(text))
return c
def load_companies(csv_path):
"""(companies sorted by slug, stats). Duplicates (same normalised name) keep the richest
row, filling its missing domain / staging / colours from the others."""
stats = {"rows": 0, "not_company": 0, "duplicates": 0}
by_key = {}
with open(csv_path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
stats["rows"] += 1
c = parse_row(row)
if c is None:
stats["not_company"] += 1
continue
old = by_key.get(c.key)
if old is None:
by_key[c.key] = c
continue
stats["duplicates"] += 1
best, other = (c, old) if c.richness > old.richness else (old, c)
best.domain = best.domain or other.domain
best.staging = best.staging or other.staging
best.colors = best.colors or other.colors
best.services = best.services or other.services
by_key[c.key] = best
companies = sorted(by_key.values(), key=lambda c: c.slug)
stats["kept"] = len(companies)
return companies, stats

331
tools/safari_textures.py Executable file
View File

@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""Paints the high-resolution, hand-painted-style textures for the Brandling
Safari 3D props (fences, gate, dock, tent, crates...). Output:
assets/models/src/*.png.
Same approach as the engine's Vibe-HD-2D/tools/scenery_textures.py: tileable
FFT noise mapped through warm colour ramps, no baked lighting (the engine
lights everything; ambient occlusion goes into the models' vertex colours).
python3 tools/safari_textures.py (needs numpy + Pillow)
Run tools/blender_safari.py afterwards to rebuild assets/models/safari.glb.
"""
import math
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "assets" / "models" / "src"
# ----------------------------------------------------------------- helpers --
def fft_noise(h, w, power=2.0, stretch=(1.0, 1.0), seed=0):
"""Tileable fractal noise in [0, 1] (1/f^power spectrum); stretch[0] > 1
elongates features along y (vertical fibres)."""
rng = np.random.default_rng(seed)
fy = np.fft.fftfreq(h)[:, None] * stretch[0]
fx = np.fft.fftfreq(w)[None, :] * stretch[1]
f = np.sqrt(fx * fx + fy * fy)
f[0, 0] = 1.0
spectrum = (rng.normal(size=(h, w)) + 1j * rng.normal(size=(h, w))) / f**power
spectrum[0, 0] = 0
n = np.real(np.fft.ifft2(spectrum))
n -= n.min()
return n / n.max()
def lerp_colors(t, stops):
"""Maps t in [0,1] (array) through a list of (pos, (r,g,b)) stops."""
t = np.clip(t, 0.0, 1.0)
out = np.zeros(t.shape + (3,))
pos = [p for p, _ in stops]
for c in range(3):
out[..., c] = np.interp(t, pos, [col[c] for _, col in stops])
return out
def smoothstep(a, b, x):
t = np.clip((x - a) / (b - a), 0.0, 1.0)
return t * t * (3 - 2 * t)
def rgb(col):
return Image.fromarray(np.clip(col, 0, 255).astype(np.uint8), "RGB")
def wrap_dist(a, b, period):
d = np.abs(a - b) % period
return np.minimum(d, period - d)
# ------------------------------------------------------------------- wood --
WOOD_RAMP = [(0.0, (70, 44, 28)), (0.3, (112, 74, 46)), (0.6, (150, 106, 68)), (0.85, (182, 140, 96)),
(1.0, (206, 170, 124))]
def grain_field(h, w, seed, lines=18, warp=2.2):
"""Wood grain along v: thin, gently wavy, tileable dark grain lines in
[0, 1] plus the warp noise used to drive them."""
xx = np.arange(w)[None, :] / w
wn = fft_noise(h, w, power=2.6, stretch=(4.0, 1.0), seed=seed)
phase = (xx * lines + (wn - 0.5) * warp) * math.tau
s = np.sin(phase)
fine = np.sin(phase * 3.0 + wn * 5.0)
return np.clip(smoothstep(0.55, 0.98, s) + 0.45 * smoothstep(0.7, 1.0, fine), 0, 1), wn
def add_knots(col, rng, count, h, w, size=(10, 18), dark=(70, 42, 26)):
"""Small elliptical knots with a few dark rings, wrapped so the tile stays seamless."""
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
for _ in range(count):
cx, cy = rng.uniform(0, w), rng.uniform(0, h)
r = rng.uniform(*size)
dx, dy = wrap_dist(xx, cx, w), wrap_dist(yy, cy, h)
d = np.sqrt((dx / r) ** 2 + (dy / (r * 1.8)) ** 2)
ring = smoothstep(0.55, 0.95, np.sin(d * 7.0))[..., None] * smoothstep(2.4, 1.0, d)[..., None] * 0.55
core = smoothstep(0.7, 0.3, d)[..., None] * 0.75
col = col * (1 - ring) + np.array(dark) * ring
col = col * (1 - core) + np.array(dark) * core
return col
def wood(w=512, h=1024, seed=21):
"""Weathered, sun-warmed timber with the grain along v (posts, rails, beams, deck boards)."""
rng = np.random.default_rng(seed)
rings, wn = grain_field(h, w, seed, lines=11)
fibre = fft_noise(h, w, power=1.0, stretch=(10.0, 1.0), seed=seed + 1)
broad = fft_noise(h, w, power=2.2, seed=seed + 2)
v = 0.5 + 0.2 * (broad - 0.5) * 2 + 0.16 * (fibre - 0.5) * 2 - 0.26 * rings
col = lerp_colors(v, WOOD_RAMP)
# Silvery sun-bleached streaks: weathered, not polished.
bleach = smoothstep(0.62, 0.85, fft_noise(h, w, power=1.4, stretch=(6.0, 1.0), seed=seed + 3))[..., None] * 0.3
col = col * (1 - bleach) + np.array([176, 160, 136]) * bleach
cracks = smoothstep(0.1, 0.0, fibre)[..., None] * 0.7 # long dark checks along the grain
col = col * (1 - cracks * 0.6)
col = add_knots(col, rng, 2, h, w)
return rgb(col)
def planks(size=1024, seed=31, boards=4, nails=True):
"""Boards running along v with dark gaps and nail heads (crates, barrel staves)."""
rng = np.random.default_rng(seed)
h = w = size
rings, wn = grain_field(h, w, seed, lines=16)
fibre = fft_noise(h, w, power=1.0, stretch=(10.0, 1.0), seed=seed + 1)
broad = fft_noise(h, w, power=2.0, seed=seed + 2)
xx = np.arange(w)[None, :].repeat(h, 0).astype(np.float32)
bw = w / boards
b = (xx // bw).astype(int)
lx = xx - b * bw
tone = rng.uniform(0.82, 1.12, boards)[b]
v = (0.5 + 0.18 * (broad - 0.5) * 2 + 0.16 * (fibre - 0.5) * 2 - 0.24 * rings) * tone
col = lerp_colors(v, WOOD_RAMP)
edge = np.minimum(lx, bw - lx)
gap = smoothstep(7.0, 2.5, edge)[..., None]
col = col * (1 - gap) + np.array([34, 22, 14]) * gap
rim = smoothstep(22.0, 6.0, edge)[..., None] * (1 - gap)
col = col * (1 - 0.28 * rim)
col = add_knots(col, rng, 3, h, w, size=(8, 14))
img = rgb(col)
if nails:
d = ImageDraw.Draw(img)
for i in range(boards):
for y in (0.08, 0.58):
for k in (0.3, 0.7):
cx = (i + k) * bw + rng.uniform(-6, 6)
cy = (y + rng.uniform(-0.02, 0.02)) * h
r = 7
d.ellipse([cx - r - 2, cy - r - 2, cx + r + 2, cy + r + 2], fill=(52, 36, 24))
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(78, 70, 64))
d.ellipse([cx - r + 3, cy - r + 2, cx + 1, cy - 1], fill=(120, 108, 96))
return img
def painted(size=1024, seed=41, paint=(222, 196, 140)):
"""Sun-faded painted boards with chipped paint showing the wood (sign boards)."""
base = np.asarray(planks(size, seed, boards=3, nails=False)).astype(np.float64)
brush = fft_noise(size, size, power=1.1, stretch=(1.0, 8.0), seed=seed + 1) # horizontal brush strokes
fade = fft_noise(size, size, power=2.0, seed=seed + 2)
pcol = np.array(paint) * (0.86 + 0.18 * brush[..., None] + 0.08 * (fade[..., None] - 0.5))
chip_n = fft_noise(size, size, power=1.5, seed=seed + 3)
chip = smoothstep(0.66, 0.72, chip_n)[..., None]
gaps = (base.mean(axis=2) < 50)[..., None]
col = pcol * (1 - chip) + base * chip
col = np.where(gaps, base, col)
edge = smoothstep(0.62, 0.66, chip_n)[..., None] * (1 - chip) # dark lip around chips
col = col * (1 - 0.25 * edge)
return rgb(col)
def log_bark(w=512, h=1024, seed=51):
"""Chunky warm bark plates for log pillars, benches and bollards (tiles horizontally)."""
n = fft_noise(h, w, power=1.35, stretch=(5.0, 1.0), seed=seed)
fine = fft_noise(h, w, power=0.9, stretch=(3.0, 1.0), seed=seed + 1)
plates = fft_noise(h, w, power=1.8, stretch=(2.5, 1.0), seed=seed + 2)
v = n * 0.55 + fine * 0.2 + plates * 0.25
col = lerp_colors(v, [(0.0, (40, 26, 18)), (0.35, (78, 52, 34)), (0.62, (118, 82, 54)), (1.0, (164, 124, 86))])
crev = smoothstep(0.36, 0.22, n)[..., None]
col = col * (1 - 0.7 * crev)
lichen = smoothstep(0.8, 0.9, fft_noise(h, w, power=1.2, seed=seed + 3))[..., None] * 0.3
col = col * (1 - lichen) + np.array([128, 140, 92]) * lichen
return rgb(col)
def end_grain(size=512, seed=61):
"""Sawn log end: growth rings around the centre, radial checks, bark rim at
the edge. Mapped planar onto log caps with the circle touching the borders."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
c = size / 2
dx, dy = (xx - c) / c, (yy - c) / c
r = np.sqrt(dx * dx + dy * dy)
ang = np.arctan2(dy, dx)
wob = fft_noise(size, size, power=2.0, seed=seed)
rr = r + (wob - 0.5) * 0.08
rings = 0.5 + 0.5 * np.sin(rr * 46.0)
fine = fft_noise(size, size, power=0.9, seed=seed + 1)
v = 0.62 - 0.22 * rings ** 2 + 0.18 * (fine - 0.5) - 0.12 * r
col = lerp_colors(v, [(0.0, (92, 60, 36)), (0.4, (150, 108, 66)), (0.7, (198, 156, 104)), (1.0, (226, 192, 140))])
rng = np.random.default_rng(seed)
for a in rng.uniform(-math.pi, math.pi, 5): # radial drying checks
da = np.abs((ang - a + math.pi) % math.tau - math.pi)
crack = smoothstep(0.035, 0.0, da * np.maximum(r, 0.05) * 4) * (r > 0.25) * (r < 0.85)
col = col * (1 - 0.6 * crack[..., None])
pith = smoothstep(0.06, 0.0, r)[..., None]
col = col * (1 - 0.4 * pith)
bark = smoothstep(0.86, 0.92, rr)[..., None]
col = col * (1 - bark) + np.array([62, 40, 26]) * bark
return rgb(col)
# ----------------------------------------------------------------- fabric --
def weave(size, seed, strength=0.08):
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
k = size / 4 # 256 threads per tile at 1024
t = 0.5 + 0.25 * np.sin(xx / size * k * math.tau) + 0.25 * np.sin(yy / size * k * math.tau)
return 1.0 + (t - 0.5) * strength * 2
def canvas(size=1024, seed=71, color=(224, 206, 166), seams=4, dirt=(150, 124, 86)):
"""Cotton duck canvas: fine weave, stitched panel seams along v, soft stains."""
blot = fft_noise(size, size, power=2.0, seed=seed)
mottle = fft_noise(size, size, power=1.2, seed=seed + 1)
col = np.array(color) * (0.9 + 0.12 * blot[..., None] + 0.06 * (mottle[..., None] - 0.5))
col = col * weave(size, seed)[..., None]
stain = smoothstep(0.7, 0.86, fft_noise(size, size, power=1.8, seed=seed + 2))[..., None] * 0.35
col = col * (1 - stain) + np.array(dirt) * stain
img = rgb(col)
d = ImageDraw.Draw(img)
sw = size / seams
for i in range(seams):
x = i * sw
d.rectangle([x - 5, 0, x + 5, size], fill=tuple(int(c * 0.8) for c in color)) # overlapped panel edge
d.line([(x - 5, 0), (x - 5, size)], fill=tuple(int(c * 0.6) for c in color), width=2)
for y in range(0, size, 18): # stitches
d.line([(x + 1, y), (x + 1, y + 9)], fill=tuple(int(c * 0.55) for c in color), width=2)
return img.filter(ImageFilter.SMOOTH)
def canvas_stripes(size=1024, seed=81, colors=((224, 206, 166), (58, 112, 74)), stripes=4):
"""Awning canvas with broad vertical stripes (stripes along v)."""
base = np.asarray(canvas(size, seed, color=(255, 255, 255), seams=1)).astype(np.float64) / 255.0
xx = np.arange(size)[None, :].repeat(size, 0)
s = (xx // (size // (stripes * 2))) % 2
col = np.where(s[..., None] == 0, np.array(colors[0]), np.array(colors[1])) * base
return rgb(col)
def rope(size=256, seed=91):
"""Twisted three-strand rope, strands winding diagonally (length along v)."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
phase = (xx / size * 6 + yy / size * 12) * math.tau
strand = 0.5 + 0.5 * np.sin(phase)
fibre = fft_noise(size, size, power=0.8, stretch=(1.0, 3.0), seed=seed)
v = 0.1 + 0.8 * strand ** 0.45 + 0.2 * (fibre - 0.5)
col = lerp_colors(v, [(0.0, (60, 44, 26)), (0.45, (150, 120, 76)), (1.0, (216, 190, 138))])
return rgb(col)
def hay(size=1024, seed=101, count=4200):
"""Packed golden straw: thousands of short strokes, mostly along u, wrapped
at ±size so the tile is seamless."""
rng = np.random.default_rng(seed)
base = fft_noise(size, size, power=1.6, seed=seed)
col = lerp_colors(base, [(0.0, (120, 92, 38)), (0.5, (170, 138, 62)), (1.0, (204, 174, 92))])
img = rgb(col * 0.8)
d = ImageDraw.Draw(img)
pal = [(150, 116, 48), (188, 154, 70), (214, 184, 98), (232, 206, 128), (130, 104, 46), (176, 160, 84)]
for _ in range(count):
x, y = rng.uniform(0, size), rng.uniform(0, size)
a = rng.normal(0, 0.35)
L = rng.uniform(40, 140)
c = pal[rng.integers(len(pal))]
wdt = int(rng.integers(3, 7))
dx, dy = math.cos(a) * L, math.sin(a) * L
for ox in (-size, 0, size):
for oy in (-size, 0, size):
p0 = (x + ox, y + oy)
p1 = (x + ox + dx, y + oy + dy)
d.line([p0, p1], fill=tuple(int(k * 0.78) for k in c), width=wdt + 2)
d.line([p0, p1], fill=c, width=wdt)
return img.filter(ImageFilter.SMOOTH)
def stone(size=1024, seed=131):
"""Warm grey field stone for the boulders at the gate's feet (moss comes from vertex colour)."""
base = fft_noise(size, size, power=1.8, seed=seed)
fine = fft_noise(size, size, power=0.9, seed=seed + 1)
ridge = 1.0 - np.abs(fft_noise(size, size, power=1.5, seed=seed + 2) * 2 - 1)
v = np.clip((base * 0.6 + fine * 0.4 - 0.5) * 1.5 + 0.5, 0, 1)
col = lerp_colors(v, [(0.0, (66, 60, 56)), (0.4, (118, 110, 100)), (0.7, (158, 148, 132)), (1.0, (202, 190, 170))])
cracks = smoothstep(0.96, 0.99, ridge)[..., None]
col = col * (1 - 0.5 * cracks)
return rgb(col)
# ------------------------------------------------------------------ metal --
def iron(size=512, seed=111):
v = fft_noise(size, size, power=1.3, seed=seed)
col = lerp_colors(v, [(0.0, (26, 24, 24)), (0.6, (56, 52, 50)), (1.0, (96, 76, 58))]) # rust in highlights
rust = smoothstep(0.7, 0.85, fft_noise(size, size, power=1.6, seed=seed + 1))[..., None] * 0.5
col = col * (1 - rust) + np.array([118, 64, 34]) * rust
return rgb(col)
def lantern_glass(size=256, seed=121):
"""Emissive lantern pane (mapped 0..1 per pane): hot flame-yellow centre
fading to amber at the rim, with faint soot at the top."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) / size
dx, dy = (xx - 0.5) / 0.5, (yy - 0.55) / 0.6
r = np.sqrt(dx * dx + dy * dy)
flick = fft_noise(size, size, power=2.0, seed=seed)
v = np.clip(1.0 - r * 0.85 + (flick - 0.5) * 0.12, 0, 1)
col = lerp_colors(v, [(0.0, (150, 60, 14)), (0.4, (232, 128, 38)), (0.75, (255, 196, 92)), (1.0, (255, 240, 190))])
soot = smoothstep(0.82, 1.0, 1 - yy)[..., None] * 0.5 # image row 0 = top of pane (v = 1)
col = col * (1 - soot)
return rgb(col)
def main():
OUT.mkdir(parents=True, exist_ok=True)
wood().save(OUT / "wood.png")
planks().save(OUT / "planks.png")
planks(seed=35, nails=False).save(OUT / "staves.png")
painted().save(OUT / "painted_wood.png")
log_bark().save(OUT / "log_bark.png")
end_grain().save(OUT / "end_grain.png")
canvas().save(OUT / "canvas.png")
canvas(seed=75, color=(70, 112, 70), dirt=(52, 64, 40)).save(OUT / "canvas_green.png")
canvas_stripes().save(OUT / "canvas_stripes.png")
rope().save(OUT / "rope.png")
hay().save(OUT / "hay.png")
iron().save(OUT / "iron.png")
stone().save(OUT / "stone.png")
lantern_glass().save(OUT / "lantern_glass.png")
print("wrote", ", ".join(sorted(p.name for p in OUT.glob("*.png"))))
if __name__ == "__main__":
main()