# _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)