The crowd sheet becomes an animation sheet: three frames, and the stand sits back
Oliver: "when clapping I barely see any movement at all now.. also all of the art could be colored a little more dim as it is perspective wise farther in the background and should not steal too much attention" — and then, "can't we make an animation sprite sheet of the new crowd?", which is the answer to the first half of the first one. A third roll (_crowdroll.py mid) takes the arms-up frame as its reference and asks for the same standing people with their hands brought together in front of their faces. It drifts by 53% of its pixels and that does not matter: the same filter keeps only what was stand and became person. 100 arm blobs in pose 1, 74 in pose 2, 134 spectators once merged, 28 of them owning BOTH poses — those 28 cycle between two raised poses, which is a clap; the rest alternate with the seated frame, which is arms going up and down. The point is the cycle, not the pose. A raiser is ~8x6 logical px on a phone and one that appears and then holds for a second is not something the eye catches; arms moving at 3.5 Hz is. Measured: 2-3% of the stand band now changes every 90 ms during a won game, against near zero before. The sheets are one file per court now — three frames stacked, graded as a single image so they cannot drift apart in colour — and x_crowd2/_c2/_h2 are deleted. TUNE.standBack 0.22 dims the crowd and the boards on every court before the night court's own dim and the splatter rot, in the one function both the cached backdrop and the live raisers already read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ad2898530
commit
0250e9bd20
14 files changed
+333
-198
No files matched your search
+63
-39
@@ -4,9 +4,11 @@
|
||||
# uv run --with pillow --with numpy --with scipy python assets/_crowdpass.py # install
|
||||
# uv run --with pillow --with numpy --with scipy python assets/_crowdpass.py --review # to _refcheck
|
||||
#
|
||||
# INPUT assets/_sheet_crowd3.png + _sheet_crowd3b.png — the two rolls (see `_crowdroll.py`),
|
||||
# tracked, because _refcheck is scratch and these are the only copy of the art
|
||||
# OUTPUT assets/x_crowd{,2}.png · x_crowd_c{,2}.png · x_crowd_h{,2}.png · src/crowd.js
|
||||
# INPUT assets/_sheet_crowd3.png (seated) + _sheet_crowd3b.png (arms high) + _sheet_crowd3c.png
|
||||
# (hands together, mid-clap) — the three rolls (see `_crowdroll.py`), tracked, because
|
||||
# _refcheck is scratch and these are the only copy of the art
|
||||
# OUTPUT assets/x_crowd.png · x_crowd_c.png · x_crowd_h.png — ONE STACKED SPRITE SHEET PER COURT,
|
||||
# three frames tall — plus src/crowd.js
|
||||
#
|
||||
# THE PROBLEM THIS SOLVES. Ask a generator for "the same picture with some arms raised" and about a
|
||||
# third of the pixels come back different — faces, seat backs, the railing, everything. Swap any of
|
||||
@@ -19,12 +21,19 @@
|
||||
# wall above a row, and nothing else. Not the raiser's face, not his shirt, not his seat. It cannot
|
||||
# drift, because 98.8% of the sheet is not copied at all: it IS the seated sheet.
|
||||
#
|
||||
# THE SHEET IS AN ANIMATION SHEET: frame 0 seated, frame 1 arms high, frame 2 hands together in
|
||||
# front of the face. A spectator with material in BOTH raised frames can be cycled between them —
|
||||
# that is a clap, not an appearance — and 26 of them can. The rest alternate with frame 0, which is
|
||||
# arms going up and down: also motion, which is the point. Oliver: "when clapping I barely see any
|
||||
# movement at all now", and then "can't we make an animation sprite sheet of the new crowd?"
|
||||
#
|
||||
# TWO INVARIANTS, and render.js leans on both:
|
||||
# 1. 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
|
||||
# 1. every raised frame is the SEATED frame plus that pose's arms, and nothing else;
|
||||
# 2. every box in src/crowd.js is a WHOLE PERSON, so the game animates people ONE AT A TIME — a
|
||||
# raiser's box drawn over the seated stand moves that spectator and nobody else.
|
||||
# Boxes that overlap are merged into one entry: two neighbours who would always rise together are
|
||||
# one person as far as the animation is concerned, and pretending otherwise reads as a glitch.
|
||||
# Boxes that overlap are merged into one entry (and their frame masks OR'd together): two neighbours
|
||||
# who would always rise together are one person as far as the animation is concerned, and pretending
|
||||
# otherwise reads as a glitch.
|
||||
#
|
||||
# THE THREE COURTS ARE ONE ROLL, GRADED. Each court's look is a per-channel histogram match onto the
|
||||
# OLD sheet for that court, so terracotta clay, night grass and blue hard court all come back — but
|
||||
@@ -106,8 +115,10 @@ def raisers(A, B):
|
||||
|
||||
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]
|
||||
contains half of a neighbour's arms would raise that neighbour too, on the wrong clock. Boxes
|
||||
carry a frame mask, and merging ORs it — which is also how a spectator ends up owning both
|
||||
raised poses and therefore a real clap."""
|
||||
boxes = [b[:4] + [b[4]] for b in boxes]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
@@ -115,7 +126,8 @@ def merge(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[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]),
|
||||
a[4] | b[4]]
|
||||
boxes.pop(j)
|
||||
changed = True
|
||||
return boxes
|
||||
@@ -142,45 +154,57 @@ def save(seated, pa, clap, pb):
|
||||
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)
|
||||
def write_table(boxes, h, w, frames):
|
||||
"""src/crowd.js — the raisers as fractions of ONE FRAME of the sheet, sorted left to right so a
|
||||
wave can walk them in order, each with the mask of the frames its arms appear in."""
|
||||
rows = sorted(((x0 - PAD) / w, (y0 - PAD) / h, (x1 - x0 + 2 * PAD) / w, (y1 - y0 + 2 * PAD) / h, m)
|
||||
for y0, x0, y1, x1, m in boxes)
|
||||
body = ',\n '.join('[' + ', '.join(f'{v:.4f}' for v in r[:4]) + f', {r[4]}]' for r in rows)
|
||||
open(os.path.join(ROOT, 'src', 'crowd.js'), 'w').write(
|
||||
'// GENERATED by assets/_crowdpass.py — do not edit by hand, re-run the tool.\n'
|
||||
'// The 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'
|
||||
'// The crowd sheet is FRAMES frames stacked vertically: 0 seated (the backdrop), 1 arms\n'
|
||||
'// high, 2 hands together in front of the face. RAISERS lists the spectators who can move:\n'
|
||||
'// [x, y, w, h] as fractions of ONE FRAME, then a bitmask of the raised frames that person\n'
|
||||
'// actually appears in (1 = arms high, 2 = mid-clap, 3 = both, and 3 is a real clap cycle).\n'
|
||||
'// Each box holds ONE person and nobody else, and outside these boxes every frame is\n'
|
||||
'// pixel-identical to frame 0 — which is what lets render.js animate them one at a time\n'
|
||||
'// with no seam. See "THE CROWD SHEET" in PROJECT.md.\n'
|
||||
f'export const FRAMES = {frames};\n'
|
||||
f'export const RAISERS = [\n {body},\n];\n')
|
||||
return len(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def main(review):
|
||||
A = np.asarray(Image.open(os.path.join(HERE, '_sheet_crowd3.png')).convert('RGB'))
|
||||
B = np.asarray(Image.open(os.path.join(HERE, '_sheet_crowd3b.png')).convert('RGB'))
|
||||
arms, lab = raisers(A, B)
|
||||
poses = [np.asarray(Image.open(os.path.join(HERE, n)).convert('RGB'))
|
||||
for n in ('_sheet_crowd3b.png', '_sheet_crowd3c.png')]
|
||||
h, w = A.shape[:2]
|
||||
|
||||
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}%)')
|
||||
frames, boxes = [A], []
|
||||
for bit, P in enumerate(poses):
|
||||
arms, lab = raisers(A, P)
|
||||
f = A.copy()
|
||||
for y0, x0, y1, x1, i in arms: # the BLOB, not its box: only the arms move
|
||||
m = lab[y0:y1, x0:x1] == i
|
||||
f[y0:y1, x0:x1][m] = P[y0:y1, x0:x1][m]
|
||||
frames.append(f)
|
||||
boxes += [b[:4] + [1 << bit] for b in arms] # b[4] is the label id; the box carries a FRAME MASK
|
||||
drift = (np.abs(A.astype(int) - P.astype(int)).sum(2) > 30).mean() * 100
|
||||
moved = (np.abs(A.astype(int) - f.astype(int)).sum(2) > 30).mean() * 100
|
||||
print(f' pose {bit + 1}: {len(arms)} arm blobs, {moved:.1f}% of the sheet '
|
||||
f'(the roll itself drifted {drift:.1f}%)')
|
||||
boxes = merge(boxes)
|
||||
rows = write_table(boxes, h, w, len(frames))
|
||||
both = sum(1 for r in rows if r[4] == 3)
|
||||
print(f'{len(rows)} spectators can move, {both} of them with both poses (a real clap)')
|
||||
|
||||
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(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)}')
|
||||
stack = np.concatenate(frames, 0)
|
||||
for a_name, _ in COURTS:
|
||||
lut = grade(A, old_sheet(a_name)) # the LOOK is judged on the seated frame...
|
||||
pa = os.path.join(REFCHECK if review else HERE, a_name + ('_new.png' if review else '.png'))
|
||||
# ...and applied to the whole stack at once, so the frames cannot drift apart in colour
|
||||
Image.fromarray(lut(stack)).quantize(colors=PAL, method=Image.MEDIANCUT).save(pa, optimize=True)
|
||||
print(f' {os.path.basename(pa)} ({stack.shape[1]}x{stack.shape[0]}, {len(frames)} frames)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user