Oliver: "when clapping I barely see any movement at all now.. also all of the art could be colored a little more dim as it is perspective wise farther in the background and should not steal too much attention" — and then, "can't we make an animation sprite sheet of the new crowd?", which is the answer to the first half of the first one. A third roll (_crowdroll.py mid) takes the arms-up frame as its reference and asks for the same standing people with their hands brought together in front of their faces. It drifts by 53% of its pixels and that does not matter: the same filter keeps only what was stand and became person. 100 arm blobs in pose 1, 74 in pose 2, 134 spectators once merged, 28 of them owning BOTH poses — those 28 cycle between two raised poses, which is a clap; the rest alternate with the seated frame, which is arms going up and down. The point is the cycle, not the pose. A raiser is ~8x6 logical px on a phone and one that appears and then holds for a second is not something the eye catches; arms moving at 3.5 Hz is. Measured: 2-3% of the stand band now changes every 90 ms during a won game, against near zero before. The sheets are one file per court now — three frames stacked, graded as a single image so they cannot drift apart in colour — and x_crowd2/_c2/_h2 are deleted. TUNE.standBack 0.22 dims the crowd and the boards on every court before the night court's own dim and the splatter rot, in the one function both the cached backdrop and the live raisers already read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
9.1 KiB
Python
149 lines
9.1 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 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 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(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')
|