gamecult-site/persona-poses/r1/render_r1.py
Meta Vens 83e92c9bf7 Persona poses round 1: 14 expression sheets, crop and matte scripts, records, README results
Sheets for libby, heimdall, kiko, aqua, nibu, eureka (r1a prompt-only and r1b canon-bust reference),
weksa-r1a and void-r1b. weksa-r1b and void-r1a were not rendered (server errors, request allowance spent).
2k originals and full-size cutouts stay out of git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:27:46 +02:00

197 lines
14 KiB
Python

"""Round 1 pose sheets. Key read from F:/Projects/bfl-api.txt inside this process only; never printed, logged or written.
Per-character blocks are taken verbatim from the canon bust records (the .txt beside each canon render),
not retyped. Usage: py -3 render_r1.py [libby-r1a ...] (no args = all 16)
"""
import json, time, urllib.request, urllib.error, urllib.parse, io, sys, datetime, pathlib, base64, concurrent.futures as cf
from PIL import Image
CANON = pathlib.Path(r"F:/Projects/gamecult-site-wt-portraits/cast-portraits")
REC = pathlib.Path(__file__).parent
ORIG = pathlib.Path(r"F:/Projects/cast-art-originals/poses-r1")
ENDPOINT = "https://api.bfl.ai/v1/flux-3-image"
NAMES = ["libby", "heimdall", "kiko", "aqua", "weksa", "nibu", "eureka", "void"]
BUST = {"libby": "r2/libby/libby-r2a", "heimdall": "r2/heimdall/heimdall-r2a", "kiko": "r2/kiko/kiko-r2a",
"aqua": "r4/aqua/aqua-r4a", "weksa": "r2/weksa/weksa-r2a", "nibu": "r3/nibu/nibu-r3a",
"eureka": "r2/eureka/eureka-r2a", "void": "r3/void/void-r3a"}
BUST_ORIG = {"libby": "r2/libby-r2a", "heimdall": "r2/heimdall-r2a", "kiko": "r2/kiko-r2a", "aqua": "r4/aqua-r4a",
"weksa": "r2/weksa-r2a", "nibu": "r3/nibu-r3a", "eureka": "r2/eureka-r2a", "void": "r3/void-r3a"}
# The busts' batch-A shared style block (two heads tall), exactly as the canon records carry it.
STYLE_BUST = (
"Character portrait for a game cast lineup: a bust, head and shoulders cropped at mid-chest by the bottom edge of the frame, "
"the character in three-quarter view turned toward the right side of the frame, the head centred horizontally with its top a small margin "
"below the top edge, the same crop and scale for every character. "
"Super-deformed chibi proportions: the character is two heads tall in total, so the head is very large compared with the small shoulders and narrow torso. "
"Clean 2D anime illustration drawn to be traced into vector art: one bold dark outline of even weight around the whole silhouette, "
"thinner inner lines, flat cel shading with exactly one base tone and one shadow tone per material, light from the upper left. "
"No gradients, no texture, no painterly blending, no glow halos, no pixel art, no 3D rendering. "
"A limited palette of five or six flat colours taken from the character's key colours. Large expressive anime eyes with two white highlights. "
"The background is one perfectly flat, uniform, solid bright chroma-key green from edge to edge: no gradient, no vignette, no floor, "
"no cast shadow, no particles, no props or scenery behind the figure."
)
CROP_BUST = ("a bust, head and shoulders cropped at mid-chest by the bottom edge of the frame, "
"the character in three-quarter view turned toward the right side of the frame, the head centred horizontally with its top a small margin "
"below the top edge, the same crop and scale for every character. ")
CROP_SHEET = ("a sheet of twelve cells, each cell a bust, head and shoulders cropped at mid-chest by the bottom edge of the cell, "
"the character in three-quarter view turned toward the right side of the cell, the head centred horizontally in the cell with its top a small margin "
"below the top edge of the cell, the same crop and scale in every cell. ")
STYLE = STYLE_BUST.replace("Character portrait for a game cast lineup: " + CROP_BUST, "Character expression sheet for a game cast lineup: " + CROP_SHEET)
assert STYLE != STYLE_BUST and "twelve cells" in STYLE
SHEET = ("The sheet is a grid of four columns by three rows, twelve cells of the same character in reading order, left to right and top to bottom, "
"separated by generous empty padding of the same flat green, with no frames, no borders, no divider lines, no labels and no text. "
"Every cell shows the same character with the same design, colours and proportions; the cell description sets that cell's expression and pose, "
"and the character description below gives the design.")
REF = ("Image 1 is the character's canon bust. The character must look exactly like the character in image 1 "
"(same design, colours, proportions and line style) in every cell.")
CORE = [("neutral", "a calm neutral expression, mouth closed, looking ahead"),
("happy", "a broad happy smile with bright eyes"),
("amused", "an amused smirk with one eyebrow raised, a stifled laugh"),
("thinking", "a thoughtful expression, eyes looking up and aside, one hand raised to the chin"),
("concerned", "a concerned expression, brows drawn together and tilted up, a small worried frown"),
("annoyed", "an annoyed expression, flat half-lidded eyes, a pursed frown"),
("surprised", "a surprised expression, wide eyes, mouth open in an O"),
("determined", "a determined expression, narrowed eyes, a firm set mouth, a small nod")]
SIG = {
"libby": [("stamp", "stamping a library due-date stamp down onto a book with a cheerful thump"),
("research", "peering closely at an open old book held in both hands"),
("shush", "one finger raised to her lips in a shushing gesture"),
("schema", "holding up a glowing violet diagram of boxes and arrows, explaining")],
"heimdall": [("badge", "holding up a gold badge of authority toward the viewer"),
("barrier", "one palm raised forward, projecting a blue barrier"),
("protect", "stepping forward with his cape swept out in front, shielding someone behind him"),
("revoke", "pointing the golden key downward with a sharp cross-out gesture of the other hand, revoking access")],
"kiko": [("tinker", "tinkering with a small gadget, tongue out in concentration"),
("confetti", "throwing confetti into the air, cheering"),
("sprint", "leaning forward as if sprinting, hair and tail streaming backward"),
("debug", "squinting through a magnifying lens at a tiny bug held between two fingers")],
"aqua": [("conduct", "conducting with a raised pectoral fin like a baton"),
("listen", "one fin pressed to a headphone ear cup, eyes closed, listening"),
("patch", "plugging a cyan patch cable into a jack"),
("flourish", "a showy DJ flourish with both fins spread wide")],
"weksa": [("map", "unrolling a map scroll and studying it"),
("fieldwork", "brushing dust off a small relic with a soft brush"),
("argue", "arguing, one finger jabbed forward, mouth open mid-sentence"),
("interlingua", "holding two glyph cards side by side, comparing their meanings")],
"nibu": [("console", "typing on a small floating console panel"),
("glitch", "her face and hair split by a few horizontally offset slices, as if glitching"),
("lore", "reciting lore with eyes closed and one hand open before her"),
("triumph", "a quiet triumphant smile with one fist lightly raised")],
"eureka": [("probe", "pointing the orange probe forward"),
("inspect", "leaning in with narrowed eyes to inspect something closely"),
("report", "presenting a thin tablet showing a report"),
("hood", "the hood pulled low, shadowing her eyes, only a knowing smirk visible")],
"void": [("welcome", "arms opened wide in welcome"),
("document", "holding up a sheet of paper, a document"),
("point", "pointing one gloved finger forward"),
("unimpressed", "unimpressed, the eyes flat narrow slits, the head tilted")],
}
VOID_CORE = [("neutral", "a calm neutral look, the two tall white oval eyes at rest"),
("happy", "happy, the white eyes curved into bright upturned crescents"),
("amused", "amused, one white eye narrower than the other, the hood tilted slightly"),
("thinking", "thoughtful, the white eyes looking up and aside, one gloved hand raised to the hood"),
("concerned", "concerned, the white eyes tilted inward and upward, the hood drooping slightly"),
("annoyed", "annoyed, the white eyes flat half-closed slits"),
("surprised", "surprised, the white eyes wide round circles, the hood rising"),
("determined", "determined, the white eyes narrowed and angled inward, the hood set forward")]
def cells_for(n):
return (VOID_CORE if n == "void" else CORE) + SIG[n]
def canon_block(n):
t = (CANON / (BUST[n] + ".txt")).read_text(encoding="utf-8").replace("\r\n", "\n")
body = t.split("## Prompt\n\n", 1)[1].strip()
style, block = body.split("\n\n", 1)
assert style == STYLE_BUST, f"{n}: canon style block differs from batch A"
return block.strip()
def prompt_for(n, suf):
cells = " ".join(f"Cell {i + 1} ({l}): {d}." for i, (l, d) in enumerate(cells_for(n)))
p = STYLE + "\n\n" + SHEET + "\n\n" + canon_block(n) + "\n\n" + cells
return (REF + "\n\n" + p) if suf == "r1b" else p
def ref_image(n):
cut = Image.open(CANON / (BUST[n] + "-cutout.png")).convert("RGBA")
o = Image.open(pathlib.Path(r"F:/Projects/cast-art-originals") / (BUST_ORIG[n] + ".png")).convert("RGB")
px = [o.getpixel(p) for p in ((4, 4), (o.width - 5, 4))]
g = tuple(sum(c[i] for c in px) // 2 for i in range(3))
bg = Image.new("RGB", cut.size, g); bg.paste(cut, (0, 0), cut)
b = io.BytesIO(); bg.save(b, "PNG")
return base64.b64encode(b.getvalue()).decode(), g
HYP = {"r1a": "A sheet keeps the character consistent across twelve cells from the prompt alone: the busts' shared style block adapted for a sheet, the canon character block verbatim, and twelve short cell descriptions.",
"r1b": "Adding the canon bust as a reference image holds canon details the prompt alone drifts on, without pulling every cell into the bust's pose."}
VAR = {"r1a": "Baseline: prompt only, no reference image.",
"r1b": "Against the same character's r1a: the canon bust cutout on its chroma green is passed as `images` and cited as image 1 by one clause prepended to the prompt. Nothing else differs."}
def req(url, key, data=None):
r = urllib.request.Request(url, data=json.dumps(data).encode() if data else None,
headers={"x-key": key, "Content-Type": "application/json", "accept": "application/json"})
return json.load(urllib.request.urlopen(r, timeout=60))
def run(job, key):
n, suf = job
d = REC / n; d.mkdir(parents=True, exist_ok=True); ORIG.joinpath(n).mkdir(parents=True, exist_ok=True)
png = ORIG / n / f"{n}-{suf}.png"
if png.exists():
return f"{n}-{suf}: exists, skipped"
prompt = prompt_for(n, suf)
body = {"prompt": prompt, "aspect_ratio": "4:3", "resolution": "2k"}
gdesc = ""
if suf == "r1b":
b64, g = ref_image(n); body["images"] = [b64]; gdesc = f"; reference ground rgb{g}"
try:
j = req(ENDPOINT, key, body)
except urllib.error.HTTPError as e:
return f"{n}-{suf}: submit HTTP {e.code} {e.read()[:400]!r}"
except Exception as e:
return f"{n}-{suf}: submit transport error {type(e).__name__}"
rid, poll = j.get("id"), j.get("polling_url")
cost = {k: j[k] for k in ("cost", "input_mp", "output_mp") if k in j}
print(f"{n}-{suf}: submitted id {rid} poll host {urllib.parse.urlparse(poll or '').netloc} {cost}", flush=True)
last = None
for i in range(300):
time.sleep(6)
try: s = req(poll, key)
except Exception as e:
m = f"poll error {type(e).__name__} {getattr(e, 'code', '')}"
if getattr(e, "code", 0) == 500:
return f"{n}-{suf}: server-side error (HTTP 500) id {rid}"
if m != last: print(f"{n}-{suf}: {m}", flush=True); last = m
continue
st = s.get("status")
if st != last: print(f"{n}-{suf}: status {st} at {i * 6}s", flush=True); last = st
if st == "Ready":
raw = urllib.request.urlopen(s["result"]["sample"], timeout=120).read()
im = Image.open(io.BytesIO(raw)); fmt = im.format; im.save(png, "PNG")
ref = (f"- Reference: canon bust {BUST[n]}-cutout.png composited on its chroma green, base64 in `images`, cited as image 1{gdesc}\n"
if suf == "r1b" else "- Reference: none; rendered from scratch from the prompt alone\n")
txt = (f"# {n.capitalize()} {suf}\n\n## Hypothesis\n\n{HYP[suf]}\n\n## Variable changed\n\n{VAR[suf]}\n\n"
f"## Render record\n\n- Model: FLUX 3 Image, `POST {ENDPOINT}`\n"
f"- Parameters: aspect_ratio 4:3, resolution 2k, safety_tolerance default, grounding default; no seed field\n{ref}"
f"- Output: {im.size[0]}x{im.size[1]}, source format {fmt}; 2k original kept out of git at "
f"F:/Projects/cast-art-originals/poses-r1/{n}/{n}-{suf}.png\n"
f"- Cost: about $0.10 per request (reported by API: {cost or 'nothing'})\n"
f"- Request id: {rid}\n- Date: {datetime.date.today().isoformat()}\n"
f"- Cell order: {', '.join(l for l, _ in cells_for(n))}\n\n"
f"## Prompt\n\n{prompt}\n")
(d / f"{n}-{suf}.txt").write_text(txt, encoding="utf-8")
return f"{n}-{suf}: Ready {im.size} id {rid}"
if st in ("Error", "Failed", "Content Moderated", "Request Moderated", "Task not found"):
return f"{n}-{suf}: {st} {str(s)[:200]}"
return f"{n}-{suf}: timeout id {rid}"
if __name__ == "__main__":
only = sys.argv[1:]
if only == ["--dry"]:
for n in NAMES:
for s in ("r1a", "r1b"):
p = prompt_for(n, s); print(n, s, len(p))
print(prompt_for("void", "r1b")); sys.exit()
key = pathlib.Path(r"F:/Projects/bfl-api.txt").read_text().strip()
jobs = [(n, s) for n in NAMES for s in ("r1a", "r1b") if not only or f"{n}-{s}" in only]
with cf.ThreadPoolExecutor(max_workers=int(__import__("os").environ.get("WORKERS", "4"))) as ex:
for r in ex.map(lambda j: run(j, key), jobs):
print(r, flush=True)
print("DONE", flush=True)