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>
This commit is contained in:
1 parent
74afd5988c
commit
5f53a686c8
14 files changed
+499
-398
No files matched your search
+136
-336
@@ -1,385 +1,185 @@
|
||||
# _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.
|
||||
# _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
|
||||
#
|
||||
# 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.
|
||||
# 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 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.
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
# 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 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.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# 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__))
|
||||
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
|
||||
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'
|
||||
|
||||
# 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
|
||||
COURTS = [('x_crowd', 'x_crowd2'), ('x_crowd_c', 'x_crowd_c2'), ('x_crowd_h', 'x_crowd_h2')]
|
||||
|
||||
# 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)
|
||||
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 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 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):
|
||||
v = int(np.bincount(pack(px)).argmax())
|
||||
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 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 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 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)
|
||||
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 (200 <= sizes[i] <= 2200 and 0.55 <= w / max(1, h) <= 2.0 and 14 <= h <= 44 and 14 <= w <= 50):
|
||||
if not (MIN_AREA <= sizes[i] <= MAX_AREA):
|
||||
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
|
||||
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 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 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 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 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. Quantising them apart would move identical pixels to
|
||||
different colours and put the wobble straight back — see the invariant at the top."""
|
||||
"""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):
|
||||
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()}
|
||||
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]
|
||||
|
||||
# 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)')
|
||||
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}%)')
|
||||
|
||||
# 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')
|
||||
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(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}%)')
|
||||
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__':
|
||||
|
||||
Reference in new issue
Block a user