131 lines
7.7 KiB
Python
131 lines
7.7 KiB
Python
# _crowdroll.py — roll the crowd sheet itself: the SEATED master, and the arms-up frame of it.
|
|
#
|
|
# uv run --with pillow --with requests python assets/_crowdroll.py seat # -> _refcheck/crowd_seat.png
|
|
# uv run --with pillow --with requests python assets/_crowdroll.py clap # from that seated roll
|
|
#
|
|
# _refcheck is SCRATCH. A roll you decide to keep is copied to `assets/_sheet_crowd3.png` (seated)
|
|
# and `_sheet_crowd3b.png` (arms up) — those two are the tracked art, they are what `_crowdpass.py`
|
|
# reads, and they are the only copy there is. Then `_crowdpass.py` turns them into an honest pair
|
|
# and grades them into the three courts.
|
|
#
|
|
# WHY A SECOND SCRIPT. `_regen.py` cuts SPRITES out of a magenta field — every line of its canvas
|
|
# block is about isolating figures on a key colour. The crowd is the opposite job: one continuous
|
|
# backdrop image that is drawn edge to edge with nothing keyed out, so it needs its own prompt and
|
|
# its own aspect. The one thing it borrows is the lesson those rolls paid for: SAY THE ART
|
|
# RESOLUTION IN FIGURE HEIGHTS, never in pixels — the generator picks its own canvas, so "90 pixels
|
|
# tall" is a proportion it can act on and "1408 wide" is not.
|
|
#
|
|
# The seated roll takes the OLD sheet as image 1 for LAYOUT ONLY (tiers, railing, camera angle,
|
|
# people-per-row) and is told in as many words that the drawing itself is to be redone finer. The
|
|
# clap roll takes the NEW seated master as image 1 and asks for the same picture with some of those
|
|
# people on their feet — it WILL drift, which is fine and expected: `_crowdpass.py` keeps only the
|
|
# blobs where arms genuinely went up and throws the drift away.
|
|
import base64, os, sys
|
|
import requests
|
|
from PIL import Image
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
OUT = os.path.join(HERE, '_refcheck')
|
|
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
|
|
SIZE = (1408, 768) # what the game ships; the roll comes back bigger and is boxed down to it
|
|
|
|
|
|
def key():
|
|
if os.environ.get('OPENROUTER_API_KEY'):
|
|
return os.environ['OPENROUTER_API_KEY']
|
|
for line in open(os.path.join(HERE, '..', '.env')):
|
|
if line.startswith('OPENROUTER_API_KEY='):
|
|
return line.split('=', 1)[1].strip().strip('"').strip("'")
|
|
sys.exit('no OPENROUTER_API_KEY')
|
|
|
|
|
|
# The stand is drawn STRETCHED into whatever band the viewport leaves, and at that size a spectator
|
|
# is ~8-14 logical px. So the prompt spends everything on the two things that survive: how many
|
|
# DIFFERENT people there are, and what breaks the top line of a row.
|
|
STYLE = (
|
|
"16-bit SNES-era pixel art, crisp hard-edged pixels, a limited palette, dark outlines separating "
|
|
"figures, flat blocks of colour with a little dithered shading. No photographic blur, no soft "
|
|
"anti-aliased haze, no gradients, no lens effects, no letters, words, numbers or logos anywhere "
|
|
"in the image. "
|
|
)
|
|
|
|
SEAT = (
|
|
"Pixel art of a PACKED TENNIS STADIUM GRANDSTAND seen straight on from the court, filling the "
|
|
"entire frame edge to edge: a wall of spectators in tiered rows, one above the other. "
|
|
"USE THE REFERENCE IMAGE FOR LAYOUT ONLY — the same head-on camera, the same tiers of seating, "
|
|
"the same horizontal railing running across the picture, the same darker band of wall above the "
|
|
"top row, the same rows running right off both edges with no border or margin anywhere. "
|
|
"REDRAW EVERYTHING ELSE AT MUCH FINER PIXEL RESOLUTION WITH FAR MORE DETAIL than the reference: "
|
|
"build each spectator out of roughly 90 pixels of height instead of the reference's 40, and "
|
|
"spend all of it on making them DIFFERENT PEOPLE. "
|
|
"Every spectator is an individual: a wide range of skin tones; hair that is short, long, bald, "
|
|
"tied back, curly, grey, blond, ginger, black; beards; caps and sun hats in bright colours; "
|
|
"sunglasses; visors; headbands. Clothing in many different saturated colours — tennis whites, "
|
|
"striped polos, jerseys, summer dresses, jackets tied over shoulders, vest tops. "
|
|
"They are DOING things: some clap, some hold drinks or ice creams, some hold a camera with a "
|
|
"long lens up to one eye, some wave a small flag on a stick, some hold up a plain hand-painted "
|
|
"banner of flat colour with NO writing on it, some lean together talking, some shade their eyes, "
|
|
"a few stand with both arms raised. "
|
|
"Break the rows up: scattered EMPTY SEATS with the seat back showing, and a couple of vertical "
|
|
"stairway gaps between blocks of seating. Not every head at the same height. "
|
|
"THREE OF THE SPECTATORS ARE ZOMBIES, sitting in the middle of the crowd among everybody else: "
|
|
"grey-green skin, sunken dark eye sockets, slack jaw, slumped and staring — and the ordinary "
|
|
"people sitting next to them are behaving completely normally, paying them no attention. "
|
|
"Bright neutral daylight, evenly lit across the whole picture. " + STYLE
|
|
)
|
|
|
|
CLAP = (
|
|
"This reference image is a stadium grandstand full of seated spectators. Redraw it EXACTLY — the "
|
|
"same crowd, the same people in the same seats, the same clothes, hair, hats and colours, the "
|
|
"same rows, railing, empty seats and stairways, the same lighting, pixel for pixel — WITH ONE "
|
|
"CHANGE: about forty of the spectators, scattered evenly all over the picture, are now ON THEIR "
|
|
"FEET APPLAUDING with both arms raised above their heads, hands together, clapping. "
|
|
"Each of those forty is the SAME PERSON as in the reference — same face, same hair, same "
|
|
"clothing — just standing and reaching up, so their head and hands rise above the row in front. "
|
|
"EVERYONE ELSE IN THE PICTURE IS COMPLETELY UNCHANGED, identical to the reference. "
|
|
"The three grey-green zombie spectators do NOT move and do NOT applaud. " + STYLE
|
|
)
|
|
|
|
|
|
def u(p):
|
|
return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode()
|
|
|
|
|
|
def roll(prompt, ref, out):
|
|
r = requests.post('https://openrouter.ai/api/v1/images',
|
|
headers={'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'},
|
|
# 4K and 16:9 spelled out: the endpoint defaults to 1K and snaps the aspect to an enum.
|
|
# 16:9 is 1.78 against the sheet's 1.83 — a 3% squeeze on the way down, invisible at the
|
|
# size the stand is drawn.
|
|
json={'model': MODEL, 'prompt': prompt, 'resolution': '4K', 'aspect_ratio': '16:9',
|
|
'input_references': [{'type': 'image_url', 'image_url': {'url': u(ref)}}]},
|
|
timeout=1800)
|
|
if r.status_code != 200:
|
|
sys.exit(f'HTTP {r.status_code} {r.text[:300]}')
|
|
d = r.json().get('data') or []
|
|
if not d:
|
|
sys.exit('no image returned')
|
|
b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
|
|
raw = out.replace('.png', '_raw.png')
|
|
open(raw, 'wb').write(base64.b64decode(b))
|
|
im = Image.open(raw).convert('RGB')
|
|
# BOX, not NEAREST: coming DOWN from 4K, averaging is what keeps the fine detail the prompt paid
|
|
# for. The sheet is nearest-neighboured on the way back up by the game, which is the only place
|
|
# hard pixel edges matter.
|
|
im.resize(SIZE, Image.BOX).save(out)
|
|
print(f'{out} (roll was {im.size})', flush=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
os.makedirs(OUT, exist_ok=True)
|
|
what = sys.argv[1] if len(sys.argv) > 1 else 'seat'
|
|
if what == 'seat':
|
|
# the layout reference defaults to the seated master in the repo, so a re-roll keeps the
|
|
# stand's geometry rather than inventing a new one every time
|
|
ref = sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, '_sheet_crowd3.png')
|
|
roll(SEAT, ref, os.path.join(OUT, 'crowd_seat.png'))
|
|
elif what == 'clap':
|
|
roll(CLAP, os.path.join(OUT, 'crowd_seat.png'), os.path.join(OUT, 'crowd_clap.png'))
|
|
else:
|
|
sys.exit('usage: _crowdroll.py seat|clap')
|