diff --git a/PROJECT.md b/PROJECT.md
index 3f73d0c..182c091 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -20,9 +20,11 @@ anything, so if you add a `return` for an awkward deal, check the body still run
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
-Last updated 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call
+Last updated 2026-08-21 (THE STAND: the clap frame rebuilt as a real pose of the seated one, which
+is what the "wobble" was; an art pass for chroma and silhouette; the wave, the idle fidget, and
+paparazzi that scale with the moment. See "THE STAND STOPS WOBBLING". Before that 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call
sites, portrait enforced in Info.plist, and swipe-left-to-go-back in the menus — the web build
-unchanged and still primary. See "THE SHELL". Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn
+unchanged and still primary. See "THE SHELL"). Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn
from the front, so his pick dyes the same garment yours does; the setup screen shows that front too.
See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
@@ -345,12 +347,19 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle
animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working),
`x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch).
-- **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on
- its own phase, with **no vertical offset**. A fractional `dy` on a scaled image re-snaps its pixel
- rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard
- flicker" (the board art is 99.9% opaque; nothing was ever showing through it).
- **If you touch one crowd frame you must apply the identical curve to the other**, or the swap
- starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original.
+- **THE CLAP FRAME IS THE SEATED FRAME PLUS FORTY PAIRS OF ARMS — nothing else may differ.**
+ `x_crowd2` is built FROM `x_crowd` by `assets/_crowdpass.py`: identical pixel for pixel except
+ inside blobs that are genuinely arms-up and that each sit **inside one of render's 16 columns**
+ (`COLS_CROWD`, and `COLS` in that script — change one and you must recut the sheets). That is
+ what makes a column swap seamless, and what lets the same machinery drive three behaviours
+ (applause / wave / idle fidget) off one sheet. It was NOT true before 2026-08-21: the clap sheet
+ was a re-roll that differed from the seated one in 13-16% of its pixels, everywhere, so a swapped
+ column re-hashed the crowd rather than raising arms — Oliver, "the crowd wobbles with a client
+ side animation right now.. that feels odd." **Never hand-edit one crowd frame.** Edit the seated
+ sheet and re-run the tool, which rebuilds its partner. Draws stay integer-aligned with **no
+ vertical offset**: a fractional `dy` on a scaled image re-snaps its pixel rows every frame under
+ nearest-neighbour — that boiling beside the HUD was the "scoreboard flicker".
+ `assets/_sheet_crowd1.png` is the pre-brightening original.
## Settled feel decisions — do not regress silently
- **Far bodies draw off a flattened scale curve; the camera holds still.** `TUNE.farK` 0.25
@@ -2298,6 +2307,7 @@ it is the WRONG tool, superseded by the reference-sheet flow. Route by job:
| THE SAME CHARACTER IN SOMEONE ELSE'S CLOTHES, every pose at once | `_frontroll.py` — famroll's trick pointed at the tennis poses: image 1 is the ten `o_*` frames composed from disk, image 2 is the body to dress them in. Two calls made the whole netplay family. Ten `_regen` batches would have been ten chances for the man to drift. |
| Single-sheet remaster, boss roster, any non-family sprite | `_regen.py` + `_regen_batches.json` — the only jobs it is still for. |
| Standalone art (logo, panels) | `_key.py`; coarse legacy sheets, `_slice.py`. |
+| The CROWD sheets — either frame, any court | `_crowdpass.py`. It owns the seated/clap pair as ONE artefact; there is no such thing as editing half of it. Not idempotent: `git checkout assets/x_crowd*.png` before every run. |
**And when installed sprites look WORSE than the `_refcheck/fam_*.png` sheet they came from, the
suspect is the CUT, not the roll** — the sheets on disk are the evidence, and a re-cut is free (see
@@ -2538,6 +2548,69 @@ The gates, and the numbers they were calibrated on:
**SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found
27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet.
+## THE STAND STOPS WOBBLING, AND STARTS BEING A CROWD (2026-08-21)
+
+*"the crowd spritesheet could use some love (resolution and movement wise)... also the crowd wobbles
+with a client side animation right now.. that feels odd. also add with a little bit of humor"* — and,
+mid-build: *"the crowd could be more detailed (and I don't [mean] the technical resolution of the
+image)"*.
+
+**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, spread over faces, torsos, seat backs and the railing. Swapping 16
+columns between them at 4.5 Hz therefore re-hashed a strip of crowd twice a second. Every knob you
+could turn on the animation was the wrong knob.
+
+`assets/_crowdpass.py` rebuilds the pair instead, and establishes the invariant in "Architecture &
+invariants". Three things fall out of the fact that **the three courts are one composition in three
+palettes, pixel-aligned**:
+
+- the art pass finds its people ONCE (on the clay sheet, the cleanest — grass carries the brightening
+ curve's noise) and applies the same masks to all three, so the three crowds stay the same people;
+- a raise that exists in only ONE court's roll is **transplanted** into the other two through a colour
+ map learnt from the aligned seated pair (`colour_map`). Per court the honest raises number about
+ fifteen; pooled, 44 people can get to their feet. Eight of them are baked STANDING into both frames;
+- everything the art pass paints is excluded from the clap set, so no painted pixel can ever flicker.
+ That ordering is load-bearing — the clap set is chosen FIRST and reserved, because a crowd this
+ heavily painted leaves nothing that is allowed to move if you paint first.
+
+**RESOLUTION IS NOT THE PROBLEM AND ADDING PIXELS WOULD NOT HELP.** Measured: the sheet is 1408x768
+and the stand band is ~1080x784 device px on a 3x phone, i.e. already ~1:1 vertically and 0.77x
+horizontally (in landscape it is 1.36x wide by 0.54x tall — the sheet is stretched to whatever band
+exists, so spectators are anamorphic, differently per orientation; see OPEN). At that size a
+spectator is ~8 logical px, so "more detailed" can only mean **chroma and silhouette**, which is what
+the pass adds: 55 shirts and 44 heads of hair in colours the court does not own, 28 of those as caps,
+4 flags on poles above the heads, 8 emptied seats so no row ends in a straight line of identical
+heads, and 8 people permanently standing.
+
+**HUMOUR, and it costs nothing:** three spectators are already TURNED (grey-green skin, mid-row,
+nobody around them reacts) — and because a painted pixel is never in the clap set, they are also the
+only three in the stand who never applaud. It is a hint at the parked stands-turn-into-zombies step,
+not that step.
+
+**MOVEMENT — one draw path, three behaviours** (`drawClapColumns`):
+
+- **applause**: every column on its own phase, 3.5 Hz — a scatter of people rising and sitting;
+- **THE WAVE**: a band ~2.5 columns wide crossing the stand every 1.4 s, and only when `crowd.pop >=
+ 0.75` — the set, the match, and the ones the umpire calls unbelievable. A game won is `cheer(2.4,
+ 0.6)` and a wave on every held serve is a wave nobody looks up for;
+- **the FIDGET**: with nobody cheering, ONE column stands for a third of every `TUNE.fidgetEvery`
+ (4.5 s). Between points the stand used to be a photograph. The column walks `slot * 5 + 3` — five
+ is coprime with 16 so it visits every column, where the first cut (`* 7919`, i.e. `* 15`) moved one
+ seat sideways each time and read as a march.
+
+**PAPARAZZI SCALE WITH THE MOMENT** (Oliver: "increase paparazzi burst during bigger events").
+`cheer(seconds, vol)` now records `crowd.pop` (the size of the moment, 0..1 — vol was already exactly
+that at every call site) and queues `crowd.burst` shutters proportional to it; `tickFlashes` drains
+that queue at a fifth of the normal gap, and outside a burst the rate rides `pop` too. **`pop` is
+held at the loudest while an applause is live** — a won rally landing inside a match-point ovation
+must not demote the stand to a polite clap, and `cheer()` is called from a dozen places that overlap.
+That is the one new state-machine rule here, and it has a test.
+
+**What this did NOT do:** the crowd is still the same drawing. A genuinely richer stand — more
+distinct silhouettes, people doing different things — needs a re-roll of the sheet, which this
+session could not do (no API key in the environment) and which would cost the clap animation
+outright: the raises are lifted from the OLD roll, and a new composition has nothing to lift from.
+
## THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20)
The first three bullets of LATER item 2 ("What the shell buys GAMEPLAY-wise") are built. **The web
@@ -3632,42 +3705,52 @@ Nothing here is broken; these are known gaps and unverified risks.
**If the game ever stutters, `RES` back to 3 is the first move** — not reducing zombie counts.
2. **The game-20 horde fix is unplayed.** The crowd-scaled swish (1.00 s at one body -> 0.22 s at
fourteen) is measured but not yet felt. If it now trivialises hordes, `meleeCoolMin` is the knob.
-3. **`p_idle1`/`p_idle2` and most `o_*` frames are 2x-upscaled, not re-sliced.** They are on the right
+3. **The crowd sheet is stretched, not fitted.** It is drawn to fill whatever stand band the
+ viewport leaves, so on portrait a spectator is squeezed to 0.77x his width and in landscape he is
+ 1.36x wide by 0.54x tall — anamorphic, differently per orientation, and nearest-neighbour on top
+ (one source column in four is simply dropped on a 3x phone, which is the hash on the faces). Fixes
+ are (a) author the sheet at the aspect portrait actually uses, or (b) tile it at its own aspect the
+ way `render()` already tiles the ad strip. **Deliberately not done 2026-08-21**: at ~8 logical px
+ per person nobody sees a 25% narrow spectator, and the same effort spent on chroma and silhouette
+ was worth more. Revisit only if the crowd is ever re-rolled at a chosen size.
+4. **`p_idle1`/`p_idle2` and most `o_*` frames are 2x-upscaled, not re-sliced.** They are on the right
scale but gained no detail in the remaster, and they are the two most-seen poses in the game.
Fix by hand-slicing `_sheet_player2/3` and `_sheet_opp2/3` at `IDLE_TARGET_H` 224 rather than going
through the signature matcher, which assigned those frames elsewhere.
-4. **Boss designs are asymmetric — still, for two of the six.** Designs 1 (GROUNDSKEEPER), 4 (HEAD
+5. **Boss designs are asymmetric — still, for two of the six.** Designs 1 (GROUNDSKEEPER), 4 (HEAD
COACH), 5 (PHYSIO) and 6 (SPONSOR MASCOT) have 3 wound stages and 4 throw frames each; designs 2
(LINE JUDGE) and 3 (CHAIR UMPIRE) still have 1 wound skin and 2 throw frames. Legal — the
`BOSS_ART` indices are clamped — but a chapter-2 boss degrades in one visible step instead of three.
Fixing them is now just two batches in the shape of `boss_coach_wounds` / `boss_coach_throw`.
-5. ~~**Only 3 boss designs, cycling.**~~ CLOSED 2026-08-03 — six now, so a chapter does not repeat a
+6. ~~**Only 3 boss designs, cycling.**~~ CLOSED 2026-08-03 — six now, so a chapter does not repeat a
design until chapter 7. A seventh is still a sheet plus one row in `BOSS_ART`.
-6. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive
+7. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive
height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed
480x270. Portrait is the stated primary target, but landscape has drifted and is untested.
**RETIRED IN THE APP, 2026-08-20** — `Info.plist` lists one orientation, so the shell never rotates
and landscape is officially not-a-surface there. The WEB build can still be turned sideways (iOS
Safari has no orientation lock), so this stays open, at a lower stake: it is now a browser-only
cosmetic gap rather than something a store reviewer will see.
-7. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh
+8. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh
(`TUNE.modalIn`). Offered and not yet requested.
-8. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever
+9. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever
clearing the court means you face the current chapter, not a backlog. Deliberate; noted in case it
ever reads as a bug.
-9. **The runner's four frames were fixed BY HAND and must be regenerated.** `z_f1..4` are currently
+10. **The runner's four frames were fixed BY HAND and must be regenerated.** `z_f1..4` are currently
hip-registered (padded to one width with the hip on the centre line) and frames 3 and 4 are
mirrored copies, because all four were drawn leaning the same way and lifting the same leg. That
is exactly the post-processing Oliver ruled out — it shipped before he stated the rule. The batch
to write asks for a four-frame front-on sprint whose stride and lean ALTERNATE, and the numbers to
check afterwards are the per-frame hip centroid (it was 13 px of lateral jump at 12 frames a
second) and which leg leads.
-10. **Step 6 stays PARKED** by Oliver's decision: the crowd turning into zombies (spawning from the
+11. **Step 6 stays PARKED** by Oliver's decision, and the 2026-08-21 art pass did NOT spend it — the
+ three already-turned spectators in the seated sheet are a hint, not the mechanic. Step 6 is: the
+ crowd turning into zombies (spawning from the
stands — attacks from behind, high spectacle, `x_crowd` sheets already exist) and the ball-boy zombie
that catches your shots. Recorded so the ideas are not lost, not as a to-do.
-11. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the
+12. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the
closest match. Worth one confirmation that no other line is still unwanted.
-12. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing —
+13. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing —
every existing sfx is either synthesised in `audio.js` or a wav in `assets/audio/`):
- the **ball machine** while it moves — a squeaking wheel/castor, so the furniture announces
itself. It already telegraphs its shot (`machWind` 0.9 s); this is the *approach* cue.
diff --git a/assets/_crowdpass.py b/assets/_crowdpass.py
new file mode 100644
index 0000000..79541e6
--- /dev/null
+++ b/assets/_crowdpass.py
@@ -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)
diff --git a/assets/x_crowd.png b/assets/x_crowd.png
index 92e386a..7c24358 100644
Binary files a/assets/x_crowd.png and b/assets/x_crowd.png differ
diff --git a/assets/x_crowd2.png b/assets/x_crowd2.png
index 1428647..603f61f 100644
Binary files a/assets/x_crowd2.png and b/assets/x_crowd2.png differ
diff --git a/assets/x_crowd_c.png b/assets/x_crowd_c.png
index ccd2e2c..9c17c1c 100644
Binary files a/assets/x_crowd_c.png and b/assets/x_crowd_c.png differ
diff --git a/assets/x_crowd_c2.png b/assets/x_crowd_c2.png
index 76f783d..8aa8345 100644
Binary files a/assets/x_crowd_c2.png and b/assets/x_crowd_c2.png differ
diff --git a/assets/x_crowd_h.png b/assets/x_crowd_h.png
index 038b5ed..e57150c 100644
Binary files a/assets/x_crowd_h.png and b/assets/x_crowd_h.png differ
diff --git a/assets/x_crowd_h2.png b/assets/x_crowd_h2.png
index fb32d81..f05e6f7 100644
Binary files a/assets/x_crowd_h2.png and b/assets/x_crowd_h2.png differ
diff --git a/index.html b/index.html
index 1906c10..6bd7f93 100644
--- a/index.html
+++ b/index.html
@@ -661,6 +661,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
-
+