Files
splatter-tennis/assets/_crowdpass.py
T
wuwiandClaude Opus 5 5f53a686c8 The crowd is drawn again, in detail — and the stand now moves one spectator at a time
Oliver on the first pass: "the new lighter areas read as rather sloppy artwork.
please regenerate the crowd art and make it more detailed. also the animation
also looks pretty artificial. the flashes from the fotographs are too rare and
the whole thing somehow does not translate well to the other courts. also when
the opponent makes a point there is an aww-sample (which has an applaus trail)
so the crowd should still clap but more subtle."

The paint pass is deleted — recolouring blobs of an old drawing is not drawing,
and the reason it looked necessary was a wrong conclusion: the roll scripts read
OPENROUTER_API_KEY from the repo's .env, so the art could be rolled all along.

_crowdroll.py rolls the crowd itself: individually drawn spectators with hats,
sunglasses, drinks, cameras, flags and banners, empty seats, stairways, and
three zombies sitting in the middle of it behaving like furniture. ONE roll now
serves all three courts through a per-channel histogram match onto each court's
old palette, so anything the art gains shows up on clay and at night too.

_crowdpass.py keeps only what was empty stand in the seated roll and became a
person in the clap roll — the raised arms, 1.2% of the sheet — so the clap frame
cannot drift by construction, and writes a box around each pair of arms into
src/crowd.js. render.js raises ONE SPECTATOR AT A TIME out of that table, each
on their own period: the 16-column strip swap moved a seventeenth of the crowd
as a block, which is what read as machinery. crowd.pop is now a head count, so
the same code covers a dozen people and the whole stand, and past 0.75 the wave
travels person by person.

aww() sets a small crowd.pop of its own — the sample is a groan that settles
into applause, and the stand has to match what you can hear. flashEvery 5.0 ->
1.9, because at a five-second mean over a stand this wide nobody ever saw one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:30:34 +02:00

187 lines
9.4 KiB
Python

# _crowdpass.py — turn the two crowd ROLLS into the six sheets the game ships, plus the table of
# who in them can stand up.
#
# uv run --with pillow --with numpy --with scipy python assets/_crowdpass.py # install
# uv run --with pillow --with numpy --with scipy python assets/_crowdpass.py --review # to _refcheck
#
# INPUT assets/_refcheck/crowd_seat.png + crowd_clap.png (see `_crowdroll.py`)
# OUTPUT assets/x_crowd{,2}.png · x_crowd_c{,2}.png · x_crowd_h{,2}.png · src/crowd.js
#
# THE PROBLEM THIS SOLVES. Ask a generator for "the same picture with some arms raised" and about a
# third of the pixels come back different — faces, seat backs, the railing, everything. Swap any of
# that in and the stand re-hashes itself rather than applauding, which is exactly what the shipped
# sheets used to do: Oliver, "the crowd wobbles with a client side animation right now.. that feels
# odd." So the drift is thrown away here and only the arms are kept.
#
# AND "THE ARMS" IS MEANT LITERALLY. The clap frame is the seated frame plus the pixels that were
# STAND in the seated roll and became PERSON in the clap roll — arms and hands raised into the empty
# wall above a row, and nothing else. Not the raiser's face, not his shirt, not his seat. It cannot
# drift, because 98.8% of the sheet is not copied at all: it IS the seated sheet.
#
# TWO INVARIANTS, and render.js leans on both:
# 1. the clap sheet is the seated sheet EXCEPT inside the boxes listed in src/crowd.js;
# 2. every one of those boxes is a WHOLE PERSON, so the game animates people ONE AT A TIME — a
# raiser's box drawn over the seated stand moves that spectator and nobody else.
# Boxes that overlap are merged into one entry: two neighbours who would always rise together are
# one person as far as the animation is concerned, and pretending otherwise reads as a glitch.
#
# THE THREE COURTS ARE ONE ROLL, GRADED. Each court's look is a per-channel histogram match onto the
# OLD sheet for that court, so terracotta clay, night grass and blue hard court all come back — but
# the crowd underneath is one drawing, so anything added to it shows up on every court. (Before this
# the three were separate rolls; Oliver: "the whole thing somehow does not translate well to the
# other courts, there the difference is barely visible".) THE SAME LUT IS APPLIED TO BOTH FRAMES OF
# A PAIR — grading them apart would move identical pixels to different colours and put the wobble
# straight back.
import os, subprocess, sys
import numpy as np
from PIL import Image
from scipy import ndimage
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, '..')
REFCHECK = os.path.join(HERE, '_refcheck')
PAL = 128 # colours a sheet is quantised to on save
# The court LOOKS are matched to the sheets as they were BEFORE any of this — a pinned commit, not
# HEAD, so re-running the tool after installing its own output cannot drift the palettes.
GRADE_REF = '76ea1d0'
COURTS = [('x_crowd', 'x_crowd2'), ('x_crowd_c', 'x_crowd_c2'), ('x_crowd_h', 'x_crowd_h2')]
MIN_AREA, MAX_AREA = 150, 3000 # an arm blob: one pair of raised arms, or one arm
ARM_H, ARM_W = (10, 70), (8, 90) # ...and its shape, in px of the 1408x768 sheet
BAND = 8 # rows the background colour is sampled over
PAD = 3 # px of margin around a raiser's box
def old_sheet(name):
"""The pre-roll sheet for a court, read out of git — the grade's target, and nothing else."""
p = subprocess.run(['git', 'show', f'{GRADE_REF}:assets/{name}.png'], cwd=ROOT, capture_output=True)
if p.returncode:
sys.exit(f'cannot read {GRADE_REF}:assets/{name}.png — {p.stderr.decode()[:200]}')
tmp = os.path.join(REFCHECK, f'_old_{name}.png')
open(tmp, 'wb').write(p.stdout)
return np.asarray(Image.open(tmp).convert('RGB'))
def modal(px):
a = px.reshape(-1, 3).astype(int)
v = int(np.bincount((a[:, 0] << 16) + (a[:, 1] << 8) + a[:, 2]).argmax())
return np.array([v >> 16, (v >> 8) & 255, v & 255])
def stand(im):
"""The STAND itself — wall, steps, seat backs — as whatever each band of rows is mostly made of.
Being able to say "this pixel is empty stand" is the whole trick: a raise is empty stand that
turned into a person."""
m = np.zeros(im.shape[:2], bool)
for y in range(0, im.shape[0], BAND):
band = im[y:y + BAND]
m[y:y + BAND] = np.abs(band.astype(int) - modal(band)).sum(2) < 70
return m
def raisers(A, B):
"""The arms. Blobs of stand-turned-person, shaped like a pair of raised arms, sitting directly
on top of somebody who is in the seated sheet — which is what stops a re-rolled hat or a shifted
railing highlight being mistaken for applause."""
new = stand(A) & ~stand(B)
lab, n = ndimage.label(ndimage.binary_closing(new, np.ones((3, 3))), np.ones((3, 3)))
sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1))
bgA = stand(A)
out = []
for i, sl in enumerate(ndimage.find_objects(lab)):
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
if not (MIN_AREA <= sizes[i] <= MAX_AREA):
continue
if not (ARM_H[0] <= h <= ARM_H[1] and ARM_W[0] <= w <= ARM_W[1]):
continue
# IT HAS TO BE ON SOMEBODY. The strip just below the blob must be a spectator in the SEATED
# sheet; arms that rise out of empty stand belong to nobody and read as floating hands.
if bgA[sl[0].stop:min(A.shape[0], sl[0].stop + 6), sl[1]].mean() > 0.55:
continue
out.append([sl[0].start, sl[1].start, sl[0].stop, sl[1].stop, i + 1])
return out, lab
def merge(boxes):
"""Two raisers whose boxes overlap are ONE entry: the game draws a box at a time, so a box that
contains half of a neighbour's arms would raise that neighbour too, on the wrong clock."""
boxes = [b[:4] for b in boxes]
changed = True
while changed:
changed = False
for i in range(len(boxes)):
for j in range(len(boxes) - 1, i, -1):
a, b = boxes[i], boxes[j]
if a[0] - PAD < b[2] and b[0] - PAD < a[2] and a[1] - PAD < b[3] and b[1] - PAD < a[3]:
boxes[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])]
boxes.pop(j)
changed = True
return boxes
def grade(src, ref):
"""Per-channel histogram match: the roll's colours redistributed onto a court's own look.
Returns a function, so the pair can be graded with ONE transform."""
luts = []
for ch in range(3):
s, r = src[:, :, ch].ravel(), ref[:, :, ch].ravel()
sv, sc = np.unique(s, return_counts=True)
rv, rc = np.unique(r, return_counts=True)
mapped = np.interp(np.cumsum(sc) / s.size, np.cumsum(rc) / r.size, rv)
# colours the seated frame never uses (the clap frame has a few) borrow their neighbours'
luts.append(np.interp(np.arange(256), sv, mapped).astype(np.uint8))
return lambda im: np.stack([luts[c][im[:, :, c]] for c in range(3)], 2)
def save(seated, pa, clap, pb):
"""ONE shared palette for the pair — see the invariant at the top."""
sa = Image.fromarray(seated).quantize(colors=PAL, method=Image.MEDIANCUT)
Image.fromarray(clap).quantize(palette=sa, dither=Image.NONE).save(pb, optimize=True)
sa.save(pa, optimize=True)
def write_table(boxes, h, w):
"""src/crowd.js — the raisers as fractions of the sheet, sorted left to right so a wave can walk
them in order."""
rows = sorted(((x0 - PAD) / w, (y0 - PAD) / h, (x1 - x0 + 2 * PAD) / w, (y1 - y0 + 2 * PAD) / h)
for y0, x0, y1, x1 in boxes)
body = ',\n '.join('[' + ', '.join(f'{v:.4f}' for v in r) + ']' for r in rows)
open(os.path.join(ROOT, 'src', 'crowd.js'), 'w').write(
'// GENERATED by assets/_crowdpass.py — do not edit by hand, re-run the tool.\n'
'// The spectators who can stand up: [x, y, w, h] as fractions of the crowd sheet, sorted\n'
'// left to right. Each box holds ONE person whose arms are raised in <crowd>2 and nobody\n'
'// else; everything outside these boxes is identical in the two sheets, which is what lets\n'
'// render.js raise them one at a time with no seam. See "THE CLAP FRAME" in PROJECT.md.\n'
f'export const RAISERS = [\n {body},\n];\n')
return len(rows)
def main(review):
A = np.asarray(Image.open(os.path.join(REFCHECK, 'crowd_seat.png')).convert('RGB'))
B = np.asarray(Image.open(os.path.join(REFCHECK, 'crowd_clap.png')).convert('RGB'))
arms, lab = raisers(A, B)
h, w = A.shape[:2]
clap = A.copy()
for y0, x0, y1, x1, i in arms: # the BLOB, not its box: only the arms move
m = lab[y0:y1, x0:x1] == i
clap[y0:y1, x0:x1][m] = B[y0:y1, x0:x1][m]
boxes = merge(arms)
was = (np.abs(A.astype(int) - B.astype(int)).sum(2) > 30).mean() * 100
now = (np.abs(A.astype(int) - clap.astype(int)).sum(2) > 30).mean() * 100
n = write_table(boxes, h, w)
print(f'{n} raisers kept; the clap frame moves {now:.1f}% of the sheet (the roll moved {was:.1f}%)')
for a_name, b_name in COURTS:
lut = grade(A, old_sheet(a_name))
pa, pb = (os.path.join(HERE, x + '.png') for x in (a_name, b_name))
if review:
pa, pb = (os.path.join(REFCHECK, x + '_new.png') for x in (a_name, b_name))
save(lut(A), pa, lut(clap), pb)
print(f' {os.path.basename(pa)} + {os.path.basename(pb)}')
if __name__ == '__main__':
main('--review' in sys.argv)