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:
1099
tools/blender_safari.py
Normal file
1099
tools/blender_safari.py
Normal file
File diff suppressed because it is too large
Load Diff
450
tools/build_roster.py
Executable file
450
tools/build_roster.py
Executable 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
485
tools/character_art.py
Executable 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:])
|
||||
72
tools/character_compose.py
Normal file
72
tools/character_compose.py
Normal 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
171
tools/pixellab_client.py
Normal 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
218
tools/roster_csv.py
Normal 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
331
tools/safari_textures.py
Executable 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()
|
||||
Reference in New Issue
Block a user