Files
splatter-tennis/assets/_crowdpass.py
T
wuwiandClaude Opus 5 eab0a4e837 The two dims stopped stacking, and centre court's crowd is graded off a lifted target
Oliver: "it is too dim to see anything on the centre court and the dark court."
Both were the same mistake — standBack was ADDED to a court's own dim instead of
being the default it falls back to. Centre court's crowd sheet is a night-lit
navy that is dark to begin with, and the night court dims the hard-court sheet
on top of that, so the pair of them went to mud.

standDim is now court.dim ?? TUNE.standBack, plus the rot. Centre court carries
a 0.06 of its own, the night court comes down 0.62 -> 0.5 (it no longer has the
base added to it), and clay and hard keep the 0.22 that was approved.

Centre court's sheet is also graded off a LIFTED target now — the old crowd it
matches sat at a mean luminance of 39 against clay's 96, dark enough to throw
the new roll's detail away before any dim ran. A 0.72 gamma on the target brings
the sheet to 60 and the faces come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:12:07 +02:00

220 lines
12 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/_sheet_crowd3.png (seated) + _sheet_crowd3b.png (arms high) + _sheet_crowd3c.png
# (hands together, mid-clap) — the three rolls (see `_crowdroll.py`), tracked, because
# _refcheck is scratch and these are the only copy of the art
# OUTPUT assets/x_crowd.png · x_crowd_c.png · x_crowd_h.png — ONE STACKED SPRITE SHEET PER COURT,
# three frames tall — plus 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.
#
# THE SHEET IS AN ANIMATION SHEET: frame 0 seated, frame 1 arms high, frame 2 hands together in
# front of the face. A spectator with material in BOTH raised frames can be cycled between them —
# that is a clap, not an appearance — and 26 of them can. The rest alternate with frame 0, which is
# arms going up and down: also motion, which is the point. Oliver: "when clapping I barely see any
# movement at all now", and then "can't we make an animation sprite sheet of the new crowd?"
#
# TWO INVARIANTS, and render.js leans on both:
# 1. every raised frame is the SEATED frame plus that pose's arms, and nothing else;
# 2. every box in src/crowd.js 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 (and their frame masks OR'd together): 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'
# sheet -> how much its grade TARGET is lifted before the histogram match (a gamma; 1.0 is the old
# sheet as it was). Centre court's old crowd sat at a mean luminance of 39 against clay's 96 — dark
# enough that the detail in the new roll was being thrown away, and then dimmed on top of that:
# Oliver, "it is too dim to see anything on the centre court and the dark court". The night court
# borrows the hard-court sheet and does its own dimming in `COURTS.dim`, so it is fixed there.
COURTS = [('x_crowd', 0.72), ('x_crowd_c', 1.0), ('x_crowd_h', 1.0)]
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
carry a frame mask, and merging ORs it — which is also how a spectator ends up owning both
raised poses and therefore a real clap."""
boxes = [b[:4] + [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]),
a[4] | b[4]]
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, frames):
"""src/crowd.js — the raisers as fractions of ONE FRAME of the sheet, sorted left to right so a
wave can walk them in order, each with the mask of the frames its arms appear in."""
rows = sorted(((x0 - PAD) / w, (y0 - PAD) / h, (x1 - x0 + 2 * PAD) / w, (y1 - y0 + 2 * PAD) / h, m)
for y0, x0, y1, x1, m in boxes)
body = ',\n '.join('[' + ', '.join(f'{v:.4f}' for v in r[:4]) + f', {r[4]}]' 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 crowd sheet is FRAMES frames stacked vertically: 0 seated (the backdrop), 1 arms\n'
'// high, 2 hands together in front of the face. RAISERS lists the spectators who can move:\n'
'// [x, y, w, h] as fractions of ONE FRAME, then a bitmask of the raised frames that person\n'
'// actually appears in (1 = arms high, 2 = mid-clap, 3 = both, and 3 is a real clap cycle).\n'
'// Each box holds ONE person and nobody else, and outside these boxes every frame is\n'
'// pixel-identical to frame 0 — which is what lets render.js animate them one at a time\n'
'// with no seam. See "THE CROWD SHEET" in PROJECT.md.\n'
f'export const FRAMES = {frames};\n'
f'export const RAISERS = [\n {body},\n];\n')
return rows
def main(review):
A = np.asarray(Image.open(os.path.join(HERE, '_sheet_crowd3.png')).convert('RGB'))
poses = [np.asarray(Image.open(os.path.join(HERE, n)).convert('RGB'))
for n in ('_sheet_crowd3b.png', '_sheet_crowd3c.png')]
h, w = A.shape[:2]
frames, boxes = [A], []
for bit, P in enumerate(poses):
arms, lab = raisers(A, P)
f = 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
f[y0:y1, x0:x1][m] = P[y0:y1, x0:x1][m]
frames.append(f)
boxes += [b[:4] + [1 << bit] for b in arms] # b[4] is the label id; the box carries a FRAME MASK
drift = (np.abs(A.astype(int) - P.astype(int)).sum(2) > 30).mean() * 100
moved = (np.abs(A.astype(int) - f.astype(int)).sum(2) > 30).mean() * 100
print(f' pose {bit + 1}: {len(arms)} arm blobs, {moved:.1f}% of the sheet '
f'(the roll itself drifted {drift:.1f}%)')
boxes = merge(boxes)
rows = write_table(boxes, h, w, len(frames))
both = sum(1 for r in rows if r[4] == 3)
print(f'{len(rows)} spectators can move, {both} of them with both poses (a real clap)')
stack = np.concatenate(frames, 0)
for a_name, lift in COURTS:
ref = old_sheet(a_name)
if lift != 1.0:
ref = (255 * (ref / 255.0) ** lift).astype(np.uint8)
lut = grade(A, ref) # the LOOK is judged on the seated frame...
pa = os.path.join(REFCHECK if review else HERE, a_name + ('_new.png' if review else '.png'))
# ...and applied to the whole stack at once, so the frames cannot drift apart in colour
Image.fromarray(lut(stack)).quantize(colors=PAL, method=Image.MEDIANCUT).save(pa, optimize=True)
print(f' {os.path.basename(pa)} ({stack.shape[1]}x{stack.shape[0]}, {len(frames)} frames)')
if __name__ == '__main__':
main('--review' in sys.argv)