Oliver: "in the upper parts of the crowd there seems to be a lot more action going on, but I can barely see it because the hud is covering it." Measured, 31 of 134 raisers were being drawn under the board or the blood panel — a quarter of every cheer spent where nobody can see it. The cause is structural: a raise is only detectable where there is empty stand above somebody's head, and the sheet only offers that above the top row of each seating block. One of those rows is at the very top of the picture, which is where the scoreboard lives. So both ends are fixed. The two arms-up rolls now ask for the standers to be in the lower two thirds only, and the pass drops any raiser whose box centre falls under the board or the blood panel. 92 of 101 raisers now sit below the board's bottom edge, against 103 of 134 before — and because "how many join in" is a fraction of the table, pruning the invisible ones makes every cheer denser where it counts. Measured churn during a won game: 2-5.5% of the mid and lower stand changing every 90 ms, against 0.1-1% under the HUD. The re-roll also came back with 41 spectators owning both raised poses, up from 28, so more of them clap rather than just going up and down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
9.4 KiB
Python
153 lines
9.4 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
|
|
)
|
|
|
|
# FRAME 3 — the other half of the clap. Rolled FROM the arms-up frame, not from the seated one, so
|
|
# it is the SAME forty people standing: what changes is where their hands are. Two raised poses per
|
|
# spectator is what turns "his arms appeared" into "he is clapping" (Oliver: "can't we make an
|
|
# animation sprite sheet of the new crowd?").
|
|
MID = (
|
|
"This reference image is a stadium grandstand: most spectators are seated, and about forty are "
|
|
"standing with both arms raised straight above their heads. 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: "
|
|
"every spectator who is standing with their arms up — all of them in the lower two thirds of "
|
|
"the picture — has now brought their HANDS TOGETHER IN "
|
|
"FRONT OF THEIR FACE at chin height, elbows bent and pointing out to the sides, in the middle of "
|
|
"a clap. They are the same standing people in the same places; only their arms have come down "
|
|
"and in. Their hands and elbows still rise above the row in front of them. "
|
|
"EVERYONE WHO WAS SEATED IS COMPLETELY UNCHANGED, identical to the reference. " + 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 sixty of the spectators are now ON THEIR FEET APPLAUDING with both arms raised "
|
|
"above their heads, hands together, clapping. "
|
|
"EVERY ONE OF THEM IS IN THE LOWER TWO THIRDS OF THE PICTURE — the rows nearest the court, "
|
|
"below the horizontal railing — spread evenly from the left edge to the right edge. NOBODY in "
|
|
"the top third of the picture stands up; those rows are exactly as they are in the reference. "
|
|
"Each of those sixty 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(HERE, '_sheet_crowd3.png'), os.path.join(OUT, 'crowd_clap.png'))
|
|
elif what == 'mid':
|
|
roll(MID, os.path.join(HERE, '_sheet_crowd3b.png'), os.path.join(OUT, 'crowd_mid.png'))
|
|
else:
|
|
sys.exit('usage: _crowdroll.py seat|clap|mid')
|