The stand stops wobbling: a real clap frame, a wave, and three spectators who have already turned

The wobble was never the animation. x_crowd2 was a RE-ROLL of x_crowd, not a
pose of it — 13-16% of its pixels differed, over faces, torsos, seat backs and
the railing — so swapping 16 columns at 4.5 Hz re-hashed a strip of crowd twice
a second. assets/_crowdpass.py rebuilds the pair: the clap frame is now the
seated frame plus 36 genuine raises, each contained in ONE of render's columns,
so any column swap is seamless by construction and an empty column is invisible
instead of being a wobble. The three courts are one composition in three
palettes, so a raise found in one court's roll is transplanted into the other
two through a colour map learnt from the aligned seated pair.

The same tool paints the crowd, because at ~8 logical px a spectator detail can
only mean chroma and silhouette: 55 shirts and 44 heads of hair in colours the
court does not own, 28 caps, 4 flags above the heads, 8 emptied seats, 8 people
permanently standing — and 3 who are already turned, grey-green, mid-row, who
are also the only three who never applaud.

Movement, one draw path and three behaviours: applause scatters per column, a
WAVE crosses the stand for a moment big enough to deserve one (crowd.pop >=
0.75), and with nobody cheering ONE column stands for a third of every
TUNE.fidgetEvery, so the stand between points is no longer a photograph.

Paparazzi now scale with the moment: cheer() records the size of it (vol was
already exactly that at every call site) and queues shutters proportional to it,
drained at a fifth of the normal gap. pop is held at the loudest while an
applause is live, so a won rally landing inside a match-point ovation cannot
demote the stand to a polite clap — that rule has a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-21 12:58:26 +02:00
1 parent 76ea1d0d79
commit 74afd5988c
14 files changed
+585 -42

No files matched your search

+386
View File
@@ -0,0 +1,386 @@
# _crowdpass.py — the crowd sheets: an art pass on the SEATED frame, and an honest CLAP frame
# built from it. One tool, because the two jobs share one invariant and must not be done apart.
#
# 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
#
# WHY THIS EXISTS. `x_crowd2` was never "the same crowd with its arms up" — it was a RE-ROLL that
# happens to look similar: 13-16% of its pixels differ from the seated sheet, spread over faces,
# torsos, seat backs and the railing. render() swaps 16 columns between the two at 4.5 Hz, so what
# the stand actually did was re-hash itself twice a second. Oliver: "the crowd wobbles with a client
# side animation right now.. that feels odd." It was not the animation. It was the frame pair.
#
# THE INVARIANT THIS TOOL ESTABLISHES, which render.js now leans on:
# the clap sheet is the seated sheet, EXCEPT inside a set of blobs that are (a) genuinely arms-up
# and (b) each contained in ONE of the 16 columns render draws.
# Everything outside those blobs is pixel-identical, so ANY column-aligned draw from the clap sheet
# — the applause ripple, the wave, a lone fidget between points — is seamless by construction, and a
# column with nobody raising in it is simply invisible instead of being a wobble.
#
# ORDER MATTERS: the clap set is chosen FIRST and reserved, then the art pass paints around it. The
# other way round, a crowd this heavily painted leaves nothing left that is allowed to move.
#
# THE THREE SHEETS ARE ONE COMPOSITION. x_crowd, x_crowd_c and x_crowd_h are the same crowd in three
# palettes, pixel-aligned. Two things fall out of that, and both are load-bearing here:
# - the art pass finds its people ONCE, on the clay sheet (the cleanest of the three — grass
# carries the brightening curve's noise), and applies the same masks to all three;
# - a raise that exists in only ONE court's clap roll can be TRANSPLANTED into the other two
# through a colour map learnt from the aligned seated pair. Per court the honest raises number
# about fifteen. Pooled, the stand has fifty people who can put their arms up.
#
# RE-RUNNING: this reads the sheets on disk and overwrites them, so it is NOT idempotent — the art
# pass would apply twice. `git checkout assets/x_crowd*.png` first, always.
import os, sys
import numpy as np
from PIL import Image
from scipy import ndimage
HERE = os.path.dirname(os.path.abspath(__file__))
COLS = 16 # render.js draws the stand in this many columns. Must match.
SEED = 20260821
PAL = 128 # colours a sheet is quantised to, at load and on save
# court key -> (seated sheet, clap sheet). '' is centre court (grass), the fallback set.
COURTS = {'': ('x_crowd', 'x_crowd2'), 'c': ('x_crowd_c', 'x_crowd_c2'), 'h': ('x_crowd_h', 'x_crowd_h2')}
REF = 'c' # the court the people are found on
SKIN = (243, 152, 80) # ...and the clay sheet's skin tone, which is how they are found
SKIN_D = 150 # manhattan distance that still counts as skin
# WHAT THE PASS ADDS. Oliver: "the crowd could be more detailed (and I don't [mean] the technical
# resolution of the image)". At the size a phone draws this a spectator is ~8 logical px, so detail
# is not finer pixels — it is CHROMA and SILHOUETTE. Every one of these sheets is one hue end to
# end, every head is the same dark blob at the same height, and nothing breaks the top line of a
# row. So: shirts and hair in colours that are not the court's, caps, flags standing above the
# heads, people permanently on their feet, gaps in the rows — and three who have already turned.
ACCENTS = [(214, 58, 52), (240, 198, 58), (66, 148, 222), (238, 238, 228), (118, 202, 92),
(232, 120, 186), (250, 140, 40), (150, 110, 230), (54, 196, 186), (196, 40, 96),
(250, 236, 180), (90, 96, 210)]
HAIRS = [(228, 196, 110), (188, 92, 42), (208, 208, 200), (36, 28, 32), (150, 66, 96)]
N_ACCENT = 70 # shirts recoloured
N_HAIR = 48 # heads whose hair stops being the same brown as everyone else's
N_CAP = 28 # ...of which this many get the top of it in a loud colour instead: a cap
N_FLAG = 10 # little flags on poles, standing above the heads
N_STAND = 8 # spectators baked permanently onto their feet, arms up (borrowed from the clap roll)
N_TURNED = 3 # spectators who are already turned (grey-green, and they never clap)
N_GAP = 8 # ...and seats emptied, so no row ends in a straight line of identical heads
PER_COL = 5 # clappers allowed per column — a column standing as one is a wall, not applause
TURNED_SKIN = (108, 134, 84)
def load(name):
"""Quantised at LOAD, so every sheet in this tool has one small palette and colour identity is
exact — which is what the transplant map and the seamless-composite invariant both rest on."""
im = Image.open(os.path.join(HERE, name + '.png')).convert('RGB')
return np.array(im.quantize(colors=PAL, method=Image.MEDIANCUT).convert('RGB'))
def pack(rgb):
a = rgb.reshape(-1, 3).astype(int)
return (a[:, 0] << 16) + (a[:, 1] << 8) + a[:, 2]
def modal(px):
v = int(np.bincount(pack(px)).argmax())
return np.array([v >> 16, (v >> 8) & 255, v & 255])
def background(ref):
"""The stand behind the people: per 12-row band, whatever colour that band is mostly made of.
Without this, 'the modal colour under his chin' is the WALL for anybody sitting in a gap, and
the accent pass paints rectangles of wall instead of shirts."""
bg = np.zeros(ref.shape[:2], bool)
for y in range(0, ref.shape[0], 12):
band = ref[y:y + 12]
bg[y:y + 12] = np.abs(band.astype(int) - modal(band)).sum(2) < 80
return bg
def solid(m, minw=8, minh=7, fill=0.45):
"""The largest lump in a mask, if it is actually a lump. Without this every mask latches onto
the 1-2 px highlight along a seat rail — which paints the stand with bright horizontal WIRES,
the first version's most obvious tell — because a rail edge is neither skin nor background."""
lab, n = ndimage.label(m)
if not n:
return None
sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1))
i = int(sizes.argmax()) + 1
sl = ndimage.find_objects(lab)[i - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
if h < minh or w < minw or sizes[i - 1] < h * w * fill:
return None
return lab == i
def recolour(rgb, m, target):
"""Repaint a blob, keeping its own shading — the per-pixel luminance ratio rides on top of the
target, so a folded sleeve stays a folded sleeve and each court keeps its own light."""
px = rgb[m].astype(float)
l = px.mean(1)
r = np.clip(l / max(1.0, l.mean()), 0.5, 1.5)[:, None]
rgb[m] = np.clip(np.array(target, float) * r, 0, 255).astype(np.uint8)
# ------------------------------------------------------------------ the clap set ----
def raisers(A0, B0):
"""The blobs where somebody's arms genuinely went up between the two rolls of one court."""
d = np.abs(A0.astype(int) - B0.astype(int)).sum(2) > 30
lab, n = ndimage.label(ndimage.binary_closing(d, np.ones((3, 3))), np.ones((3, 3)))
colw = A0.shape[1] / COLS
lumA, lumB = A0.mean(2), B0.mean(2)
out = []
for i, sl in enumerate(ndimage.find_objects(lab), 1):
m = lab[sl] == i
area = int(m.sum())
if not (150 <= area <= 12000):
continue
if int(sl[1].start // colw) != int((sl[1].stop - 1) // colw):
continue # straddles a column seam — cannot be drawn cleanly
# ARMS UP, not A DIFFERENT PERSON: the change has to ADD bright pixels at the TOP of the
# blob, where the wall was. A re-rolled face is just as different and scores nothing.
top = np.zeros_like(m)
top[:max(1, int(m.shape[0] * 0.45))] = True
up = ((lumB[sl] - lumA[sl] > 24) & m & top).sum() / area
if up < 0.035:
continue
full = np.zeros(lab.shape, bool)
full[sl] = m
out.append((float(up), int(sl[1].start // colw), full))
return out
def colour_map(src, dst):
"""src and dst are two SEATED sheets of the same crowd in different light. Returns a 2**24 LUT
taking a src colour to the colour that court paints the same pixels — so an arm raised in the
clay roll can be lifted into the night court and still be lit like the night court."""
ks, kd = pack(src), pack(dst)
us, si = np.unique(ks, return_inverse=True)
ud, di = np.unique(kd, return_inverse=True)
hits = np.bincount(si * len(ud) + di, minlength=len(us) * len(ud)).reshape(len(us), -1)
best = ud[hits.argmax(1)]
lut = {int(s): int(b) for s, b in zip(us, best)}
srgb = np.stack([us >> 16, (us >> 8) & 255, us & 255], 1)
def apply(px): # px: (n,3) src colours -> (n,3) dst colours
k = pack(px)
out = np.empty(len(k), int)
for i, key in enumerate(k):
v = lut.get(int(key))
if v is None: # a colour only the raised arms use: nearest known
v = lut[int(us[np.abs(srgb - np.array([key >> 16, (key >> 8) & 255, key & 255])).sum(1).argmin()])]
out[i] = v
return np.stack([out >> 16, (out >> 8) & 255, out & 255], 1).astype(np.uint8)
return apply
# ------------------------------------------------------------------- the art pass ----
def find_people(ref):
"""Every spectator the reference sheet will admit to, as (head bbox, cell bbox) + a skin mask.
Skin is a third of this sheet and neighbouring faces touch, so this finds SOME of the people,
not all of them — which is all it has to do: the pass needs a hundred spots, not a census."""
med = np.stack([ndimage.median_filter(ref[:, :, i], size=3) for i in range(3)], 2)
skin = np.abs(med.astype(int) - np.array(SKIN)).sum(2) < SKIN_D
lab, n = ndimage.label(skin)
sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1))
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 (200 <= sizes[i] <= 2200 and 0.55 <= w / max(1, h) <= 2.0 and 14 <= h <= 44 and 14 <= w <= 50):
continue
cell = (slice(max(0, sl[0].start - 4), min(ref.shape[0], sl[0].start + int(h * 2.6))),
slice(max(0, sl[1].start - 5), min(ref.shape[1], sl[1].stop + 5)))
out.append((sl, cell))
return out, skin
def build_jobs(ref, reserved, rng):
"""The art pass, decided once on the reference sheet. Returns paint jobs, not pixels — each
court then applies them to its own palette. A job of (mask, None) is an emptied seat."""
people, skin = find_people(ref)
bg = background(ref)
used = ndimage.binary_dilation(reserved, iterations=2)
jobs = []
def take(m, pad=2):
if m.sum() < 60 or (m & used).any():
return False
used[:] |= ndimage.binary_dilation(m, iterations=pad)
return True
order = list(rng.permutation(len(people)))
def pick(n, minx, miny, fn):
got, placed = 0, []
for i in order:
if got >= n:
break
sl, cell = people[i]
cy, cx = (sl[0].start + sl[0].stop) / 2, (sl[1].start + sl[1].stop) / 2
if any(abs(cx - x) < minx and abs(cy - y) < miny for x, y in placed):
continue
if fn(sl, cell, got):
placed.append((cx, cy))
got += 1
return got
# SHIRTS. The modal colour under the chin that is neither more skin nor the stand behind him.
def shirt(sl, cell, i):
body = (slice(sl[0].stop + 1, cell[0].stop), cell[1])
cloth = ~skin[body] & ~bg[body]
if cloth.sum() < 110:
return False
m = np.zeros(ref.shape[:2], bool)
m[body] = cloth & (np.abs(ref[body].astype(int) - modal(ref[body][cloth])).sum(2) < 70)
m = solid(m, 9, 8)
if m is None or not take(m):
return False
jobs.append((m, ACCENTS[i % len(ACCENTS)]))
return True
# HAIR, and the top of it as a CAP. Hair is what sits on the head and is neither skin nor stand.
def hair(sl, cell, i):
box = (slice(max(0, sl[0].start - 8), sl[0].start + (sl[0].stop - sl[0].start) // 2),
slice(max(0, sl[1].start - 2), sl[1].stop + 2))
m = np.zeros(ref.shape[:2], bool)
m[box] = ~skin[box] & ~bg[box]
m = solid(m, 10, 6, 0.5)
if m is None or not take(m, 1):
return False
if i < N_CAP: # a cap is the top of the hair in a loud colour
cut = np.zeros(ref.shape[:2], bool)
cut[box[0].start:box[0].start + max(3, (box[0].stop - box[0].start) // 2), box[1]] = True
jobs.append((m & ~cut, HAIRS[i % len(HAIRS)]))
jobs.append((m & cut, ACCENTS[(i * 5 + 3) % len(ACCENTS)]))
else:
jobs.append((m, HAIRS[i % len(HAIRS)]))
return True
# THE ONES WHO HAVE ALREADY TURNED. Grey-green skin, in the middle of a row, and nobody around
# them reacts — and they are the only ones in the stand not applauding, because a painted pixel
# is never in the clap set. (A hint at the parked stands-turn-into-zombies step, not that step:
# it costs no code at all, only pixels.)
def turned(sl, cell, i):
m = np.zeros(ref.shape[:2], bool)
m[cell] = skin[cell] # head AND the hands below it: all of him turns
if solid(m, 12, 12, 0.35) is None or not take(m):
return False
jobs.append((m, TURNED_SKIN))
return True
# FLAGS. The one thing in this stand that breaks the top line of a row: a pole and a rag, up
# where until now there was nothing but wall.
def flag(sl, cell, i):
h = sl[0].stop - sl[0].start
y1, x = sl[0].start - 2, (sl[1].start + sl[1].stop) // 2
y0 = y1 - int(h * 1.25)
if y0 < 4:
return False
pole = np.zeros(ref.shape[:2], bool)
pole[y0:y1, x:x + 3] = True
rag = np.zeros(ref.shape[:2], bool)
rag[y0:y0 + int(h * 0.75), x + 3:x + 3 + int(h * 1.1)] = True
if not (bg[y0:y1, x:x + 3 + int(h * 1.1)].mean() > 0.75 and take(pole | rag)):
return False # only where the stand behind him is empty
jobs.append((pole, (58, 42, 34)))
jobs.append((rag, ACCENTS[(i * 3) % len(ACCENTS)]))
return True
# GAPS. A row ending in a straight line of identical heads is what makes this stand read as
# wallpaper instead of as people. Erased, and filled with the stand from directly above the row
# — a colour the sheet itself supplies, not one guessed here.
def gap(sl, cell, i):
h = sl[0].stop - sl[0].start
box = (slice(max(0, sl[0].start - 3), sl[0].start + int(h * 1.7)),
slice(max(0, sl[1].start - 4), sl[1].stop + 4))
if bg[max(0, box[0].start - 26):box[0].stop, max(0, box[1].start - 44):box[1].stop + 44].sum() < 900:
return False # not enough visible stand nearby to fill it with
m = np.zeros(ref.shape[:2], bool)
m[box] = True
if not take(m, 3):
return False
jobs.append((m, None))
return True
n = {'shirts': pick(N_ACCENT, 74, 40, shirt), 'hair': pick(N_HAIR, 74, 40, hair),
'flags': pick(N_FLAG, 150, 999, flag), 'turned': pick(N_TURNED, 300, 150, turned),
'gaps': pick(N_GAP, 170, 90, gap)}
return jobs, n, bg
def art_pass(rgb, jobs, bg):
for m, colour in jobs:
if colour is None:
# AN EMPTIED SEAT, FILLED ROW BY ROW. The stand is banded — wall, seat back, step —
# so one modal colour for the whole hole paints a flat rectangle straight across the
# bands and reads as a bug. Each row takes the stand colour from its OWN row instead.
ys, xs = np.where(m.any(1))[0], np.where(m.any(0))[0]
side = slice(max(0, xs[0] - 70), xs[-1] + 70)
for y in ys:
near = rgb[y, side][bg[y, side]]
if len(near) > 8:
rgb[y][m[y]] = modal(near)
else:
recolour(rgb, m, colour)
def save(seated, pa, clap, pb):
"""ONE shared palette for the pair. Quantising them apart would move identical pixels to
different colours and put the wobble straight back — 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 main(review):
rng = np.random.default_rng(SEED)
seat = {k: load(v[0]) for k, v in COURTS.items()}
clap = {k: load(v[1]) for k, v in COURTS.items()}
# 1. THE CLAP SET, pooled over all three rolls and transplanted into every court.
pool = []
for k in COURTS:
pool += [(up, col, m, k) for up, col, m in raisers(seat[k], clap[k])]
pool.sort(key=lambda p: -p[0])
taken = np.zeros(seat[REF].shape[:2], bool)
per_col, picked = {}, []
for up, col, m, k in pool:
if (m & taken).any() or per_col.get(col, 0) >= PER_COL:
continue # same person found twice, or a full column
per_col[col] = per_col.get(col, 0) + 1
taken |= ndimage.binary_dilation(m, iterations=2)
picked.append((m, k))
stand_at = set(range(1, len(picked), max(2, len(picked) // N_STAND))) # a few are on their feet for good
stand_at = set(list(stand_at)[:N_STAND])
standing = [p for i, p in enumerate(picked) if i in stand_at]
clapping = [p for i, p in enumerate(picked) if i not in stand_at]
print(f'clap set: {len(clapping)} raisers over {len(per_col)}/{COLS} columns, '
f'{len(standing)} baked standing (pooled from {len(pool)} candidates)')
# 2. THE PAINT, everywhere the clap set is not.
jobs, n, bg = build_jobs(seat[REF], taken, rng)
print(f'people painted: {n}')
maps = {(s, d): colour_map(seat[s], seat[d]) for s in COURTS for d in COURTS if s != d}
for k, (a_name, b_name) in COURTS.items():
A0, B0 = seat[k], clap[k]
def lift(m, src): # this court's pixels for a raise found in src
return B0[m] if src == k else maps[(src, k)](clap[src][m])
out = A0.copy()
for m, src in standing:
out[m] = lift(m, src)
art_pass(out, jobs, bg)
cl = out.copy()
for m, src in clapping:
cl[m] = lift(m, src)
was = (np.abs(A0.astype(int) - B0.astype(int)).sum(2) > 30).mean() * 100
now = (np.abs(out.astype(int) - cl.astype(int)).sum(2) > 30).mean() * 100
pa, pb = os.path.join(HERE, a_name + '.png'), os.path.join(HERE, b_name + '.png')
if review:
pa, pb = (os.path.join(HERE, '_refcheck', x + '_new.png') for x in (a_name, b_name))
os.makedirs(os.path.dirname(pa), exist_ok=True)
save(out, pa, cl, pb)
print(f'{a_name}: clap frame moves {now:.1f}% of pixels (the re-roll moved {was:.1f}%)')
if __name__ == '__main__':
main('--review' in sys.argv)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

After

Width:  |  Height:  |  Size: 372 KiB