One roll, one character: the whole zombie family from a single sheet

Nine sheets meant nine chances for the character to drift, and all day it took
every one of them — a brute that was three different brutes, a corpse that came
back a living man. Asking for the WHOLE family in one image removes that by
construction: one roll, one light, one palette, 31 figures.

What was missing was the way back down. _fine.py cuts a single row; a family
sheet is nine. _zsheet.py is that step and nothing else — it reuses _fine's
keyer, block detection and column cutter, and adds the two things that have to
be GLOBAL or the family breaks:

  SCALE    taken once from the play row's standing frame. Per-band scaling would
           make the serve strike the same height as the idle stance, and the game
           sizes a sprite off its own PNG height. Measured after: serve 319,
           prone fall 83 — the racket family's are 307 and 85.
  PALETTE  taken once from the largest figure anywhere, so a wound row cannot
           quantise onto slightly different colours than the row above it.

The enclosed magenta rule is INVERTED here, deliberately. _fine.py repaints
magenta trapped inside a figure because the generator used to fill string beds
with the backdrop colour by accident. This sheet asks for exactly that and gets
it, so the bed cuts to transparency and the racket is an open hoop.

_regen.py keeps three fixes found on the way: margins as a PROPORTION (an
absolute pixel margin means nothing to a model that picks its own resolution —
it clipped 3 rolls in 4), reference sheets that fill keyed-out string beds
instead of showing the model a magenta racket, and --verify, which asks the
question no gate here ever asked: is the art already on disk what it claims to
be. It found 27 of the brute's 31 frames dead — z_brw1fall was one drawing
pasted four times, 0.011 apart where a real sequence is 0.53.

The brute is live: ZFAMS gains 'br', the KEEPER wears it. Verified in #bench,
healthy and arm-off, 54 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 18:37:18 +02:00
1 parent a568d75aed
commit cba2fb8a8b
37 files changed
+219 -9

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
.env
scores.json
scores.json.tmp
assets/_gen/
assets/_refcheck/
assets/__pycache__/
+92 -5
View File
@@ -14,7 +14,7 @@
# Failures leave the sprites alone and print why, so a batch can just be re-run.
import json, os, shutil, subprocess, sys
import numpy as np
from PIL import Image
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen')
@@ -49,9 +49,17 @@ CANVAS = (
"MAGENTA AND PINK ARE THE BACKGROUND COLOUR AND MAY NOT APPEAR ANYWHERE ON A FIGURE: no magenta "
"clothing, no pink skin, and racket strings are drawn as fine pale grey-white crosshatch over "
"the figure's own body or clothes — NEVER left as the background colour showing through. "
"Keep at least 200 "
"pixels of empty magenta between figures and at least 150 pixels of empty magenta at every edge "
"of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text. "
# MARGINS ARE A PROPORTION, NEVER A PIXEL COUNT. This asked for "150 pixels at every edge" for
# months and the brute came back clipped in three rolls out of four: the generator chooses its
# own output resolution, so an absolute pixel margin means nothing to it — it composed figures at
# 92% of a canvas height it had picked itself. The reference sheet shows 29% margins and that is
# ignored too, so this is the one place the rule can be stated. Fractions it can act on.
"THE FIGURES MUST BE SMALL IN THE FRAME. Draw them at about two thirds of the canvas height, "
"centred, with a wide band of empty magenta above the highest point of the tallest figure "
"(including any raised racket), below the feet, and beyond the outermost figure on each side — "
"roughly a sixth of the canvas on every side. No figure, limb, hair or racket may touch or run "
"off an edge, and the figures must be clearly separated from each other by empty magenta, never "
"overlapping or touching. No shadow, no ground line, no text. "
"The canvas is a WIDE LANDSCAPE STRIP and every figure stands side by side in ONE SINGLE HORIZONTAL "
"ROW on the same ground line — never a grid, never two rows, never stacked."
)
@@ -92,12 +100,46 @@ def batches():
return json.load(f)
# ---------------------------------------------------------------- helpers ----
def fill_holes(im):
"""A REFERENCE MAY NOT SHOW A RACKET AS A MAGENTA HOLE. Older sprites were cut before _fine.py
learned to keep enclosed background, so their string beds are TRANSPARENT — o_smash has 3425 such
pixels, o_toss 1786, z_c1 675. Pasted onto the magenta reference canvas those holes come back as
magenta, so every one of those sheets showed the generator a racket whose strings ARE the backdrop
colour, while the prompt spent three sentences forbidding exactly that. It copied the picture,
which is what this project already knows a model does. Fixing it in _fine.py only cleaned the way
IN; this is the way back OUT.
Filled as CROSSHATCH, not as a flat patch, for the same reason: the prompt asks for "fine pale
grey-white crosshatch" and a reference that shows it beats a sentence that describes it.
ponytail: every enclosed hole gets the string bed, including the odd gap between an arm and a
torso — so a body can show a small woven patch on its hip. It is a handful of pixels against a
racket-sized win, and the alternative is classifying holes by shape. Revisit only if a roll comes
back with fabric where skin should be."""
a = np.array(im)
t = a[..., 3] < 128
# PAD BEFORE FLOODING. _fine.py can seed at (0,0) because its sheet has a wide magenta border;
# an installed SPRITE is cropped tight, so the figure touches all four edges and its surround is
# split into pockets that (0,0) cannot reach. Seeding there alone called 10,732 px of ordinary
# background a "hole" and painted the whole bounding box with string bed.
m = Image.fromarray(np.where(np.pad(t, 1, constant_values=True), 255, 0).astype('uint8'), 'L').copy()
ImageDraw.floodfill(m, (0, 0), 128) # .copy() — see _fine.py, fromarray is read-only
holes = t & (np.array(m)[1:-1, 1:-1] == 255)
if not holes.any(): return im
a[holes] = (186, 186, 186, 255)
y, x = np.indices(a.shape[:2])
for step, tone in ((3, 148), (4, 214)): # two offset threads = a woven bed, not a grey disc
w = holes & ((x + y * (step - 2)) % step == 0)
a[w, 0] = a[w, 1] = a[w, 2] = tone
return Image.fromarray(a, 'RGBA')
def ref_sheet(names, out, scale=2.0, gap=220, pad=150, frames=1):
"""THE REFERENCE'S ASPECT IS THE ANSWER'S ASPECT. One tall reference figure makes a portrait
reference canvas, and the generator then answers in portrait — it laid four figures out as a 2x2
GRID, which _fine.py cannot cut (it segments columns). So the canvas is widened to a landscape
strip with room for every frame that was asked for, and the figures are spread across it."""
ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in names]
ims = [fill_holes(Image.open(os.path.join(HERE, n + '.png')).convert('RGBA')) for n in names]
ims = [i.resize((max(1, int(i.width * scale)), max(1, int(i.height * scale))), Image.NEAREST) for i in ims]
w = sum(i.width for i in ims) + gap * (len(ims) - 1) + pad * 2
h = max(i.height for i in ims) + pad * 2
@@ -450,9 +492,54 @@ def audit(B, names):
print('\nFAILS THE GATE:', ', '.join(bad) if bad else 'none')
return not bad
# --verify asks the question no gate here has ever asked: IS THE ART ALREADY ON DISK WHAT IT CLAIMS
# TO BE? Every check in this file fires on a NEW roll and says nothing about installed frames — and a
# chained batch draws its reference from installed frames. So a stage that was wrong the day it
# shipped becomes the reference for the stage below it, and the stage below that, silently and
# forever. The brute's z_brw1fall row is the proof: four copies of ONE standing lean zombie sold as a
# fat man collapsing, differing from each other by 0.011-0.055 where the sequence they were posed
# from differs by 0.53-0.89. Nothing noticed for as long as it has been in the repo.
#
# Two questions, both reusing measures that were already here:
# DUP does this group of frames differ from each other at all? A pose sequence that does not is
# one drawing pasted N times. Real sequences score 0.5+; a stride pair, the tightest legal
# case, still clears 0.12 whole-body.
# PAL is it painted in the anchor's colours? Same dominant-colour distance the palette gate uses.
DUP_MIN = 0.12
def verify(B, names):
print(f"{'batch':16s} {'frames':>6s} {'DUP':>7s} {'PAL':>6s} verdict")
bad = []
for n in names:
spec = B[n]
frames = [s for s in spec['sprites'] if os.path.exists(os.path.join(HERE, s + '.png'))]
if len(frames) < len(spec['sprites']):
print(f'{n:16s} {"":>6s} {"":>7s} {"":>6s} MISSING art'); bad.append(n); continue
paths = [os.path.join(HERE, f + '.png') for f in frames]
dup = max((_d(*_masks(a, b)[2:4]) for i, a in enumerate(paths) for b in paths[i + 1:]),
default=1.0)
anchor = spec.get('palette_anchor')
pal = 0.0
if anchor and os.path.exists(os.path.join(HERE, anchor + '.png')):
ua, _ = dom_colours(os.path.join(HERE, anchor + '.png'))
for p in paths:
uo, wo = dom_colours(p)
if len(ua) and len(uo):
pal = max(pal, float((np.sqrt(((uo[:, None, :] - ua[None, :, :]) ** 2).sum(2))
.min(1) * wo).sum()))
why = ('ONE DRAWING REPEATED' if dup < DUP_MIN else
f'palette off {anchor}' if pal > spec.get('palette_max', 10) else None)
print(f'{n:16s} {len(frames):6d} {dup:7.3f} {pal:6.1f} {why or "ok"}')
if why: bad.append(n)
print('\nDEAD ART (regenerate before anything is drawn from it):',
' '.join(bad) if bad else 'none')
return not bad
if __name__ == '__main__':
args = sys.argv[1:]
B = batches()
if '--verify' in args:
names = [a for a in args if not a.startswith('--')] or list(B)
sys.exit(0 if verify(B, names) else 1)
if '--audit' in args:
names = [a for a in args if not a.startswith('--')] or [k for k, v in B.items() if v.get('stride')]
sys.exit(0 if audit(B, names) else 1)
+120
View File
@@ -0,0 +1,120 @@
# _zsheet.py — ONE generated multi-row sheet -> a whole zombie family of sprites.
#
# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --out assets/_refcheck/sprites
# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --install
#
# WHY THIS EXISTS. _regen.py generates a family as nine separate sheets, and nine separate rolls is
# nine chances for the character to drift — which is exactly what it did, all day. Asking for the
# WHOLE family in one image fixes that by construction: one roll, one character, one light, one
# palette. What was missing was the way back down — a generated sheet is 9 rows of figures and
# _fine.py cuts a single row. This is that step, and nothing else.
#
# TWO THINGS ARE GLOBAL, and both were bugs when they were not:
# SCALE _fine.py anchors one scale per sheet, so nine bands would each be scaled to their own
# tallest figure — the serve strike would come out the same height as the idle stance and
# every pose would pop, because the game sizes a sprite off its own PNG height. The scale
# is taken ONCE, from the standing frame of the play row, and every band uses it.
# PALETTE taken once from the largest figure in the whole sheet, so a wound row cannot quantise
# itself onto a slightly different set of colours than the healthy row it follows.
import os, sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fine import key, block_size, frames, split_piece # the proven keyer and column cutter
HERE = os.path.dirname(os.path.abspath(__file__))
TARGET_H = 224
MIN_BAND = 40
# The family layout, exactly config.famFrames' order. The wound rows carry no separate stance —
# the shuffle doubles as one — which is why they are 3 and not 4.
def layout(k):
P = ['bounce', 'toss', 'serve']
F = ['fall1', 'fall2', 'fall3', 'rise']
rows = [[f'z_{k}1', f'z_{k}2', f'z_{k}3', f'z_{k}4'],
[f'z_{k}{s}' for s in P],
[f'z_{k}{s}' for s in F]]
for st in (1, 2):
rows += [[f'z_{k}w{st}', f'z_{k}w{st}b', f'z_{k}w{st}s'],
[f'z_{k}w{st}{s}' for s in P],
[f'z_{k}w{st}{s}' for s in F]]
return rows
def bands(a):
"""Row bands = runs of rows that contain ink. Rows are what a grid has and _fine.py refuses;
here they are the point, so they are found first and each band is handed over as its own row."""
rows = (a[..., 3] > 0).any(1)
out, s = [], None
for y, v in enumerate(rows):
if v and s is None: s = y
elif not v and s is not None:
if y - s >= MIN_BAND: out.append((s, y))
s = None
if s is not None and len(rows) - s >= MIN_BAND: out.append((s, len(rows)))
return out
def cut(sheet, fam, outdir):
want = layout(fam)
src = Image.open(sheet).convert('RGB')
a = key(src)
# THE ENCLOSED MAGENTA IS THE RACKET, AND HERE IT IS MEANT TO GO. _fine.py repaints magenta that
# is trapped inside a figure as pale grey, because the generator used to fill string beds with
# the backdrop colour BY ACCIDENT and keying them left men holding an empty hoop. This sheet is
# the opposite case: the prompt asks for the string bed to be exactly the background magenta so
# it cuts to transparency, and it obeyed. So every magenta pixel goes, connected to the border
# or not. That is safe only because nothing else on the figure is magenta — check a new sheet.
n = np.array(src).astype(int)
lo = np.minimum(n[..., 0], n[..., 2])
a[(lo > 140) & (n[..., 1] < lo - 55), 3] = 0
b = block_size(a)
bs = bands(a)
if len(bs) != len(want):
sys.exit(f'ERROR: found {len(bs)} row bands, expected {len(want)}')
crops = []
for (y0, y1), row in zip(bs, want):
band = a[y0:y1]
spans = frames(band, [], len(row))
if len(spans) != len(row):
sys.exit(f'ERROR: row {row[0]}: found {len(spans)} figures, expected {len(row)}')
for x0, x1 in spans:
sub = band[:, x0:x1]
ys = np.where((sub[..., 3] > 0).any(1))[0]
xs = np.where((sub[..., 3] > 0).any(0))[0]
c = sub[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
if split_piece(c):
sys.exit(f'ERROR: a frame in row {row[0]} holds two stacked bodies')
crops.append(c)
flat = [n for row in want for n in row]
# ONE scale, off the play row's standing frame; ONE palette, off the biggest figure anywhere.
k = TARGET_H / (crops[0].shape[0] / b)
big = max(crops, key=lambda c: (c[..., 3] > 0).sum())
px = big[big[..., 3] > 200][:, :3]
pal = np.unique((px // 8) * 8, axis=0)
os.makedirs(outdir, exist_ok=True)
for c, name in zip(crops, flat):
h, w = c.shape[:2]
snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX)
im = snap.resize((max(1, round(snap.width * k)), max(1, round(snap.height * k))), Image.BOX)
arr = np.array(im).astype(int)
solid = arr[..., 3] > 110
f = arr[..., :3].reshape(-1, 3)
arr[..., :3] = pal[((f[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1)].reshape(arr.shape[0], arr.shape[1], 3)
arr[..., 3] = np.where(solid, 255, 0)
Image.fromarray(arr.astype('uint8'), 'RGBA').save(os.path.join(outdir, name + '.png'))
print(f'{len(flat)} frames, art block {b}, scale {k:.3f}, palette {len(pal)} colours -> {outdir}')
for (y0, y1), row in zip(bs, want):
hs = [Image.open(os.path.join(outdir, n + '.png')).height for n in row]
print(f' {row[0]:14s} {len(row)} frames heights {hs}')
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if not a.startswith('--')]
out = HERE if '--install' in sys.argv else None
if '--out' in sys.argv: out = sys.argv[sys.argv.index('--out') + 1]
cut(args[0], args[1], out or os.path.join(HERE, '_refcheck', 'sprites'))
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=190"></script>
<script type="module" src="src/main.js?v=191"></script>
</body>
</html>
+2 -2
View File
@@ -684,7 +684,7 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
// short 8 characters, because that is the board's name slot — the match board names
// the thing you are playing instead of calling it THEM
const OPPONENTS = [
{ short: 'KEEPER', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.85, pace: 0.9, wide: 0.85 },
{ short: 'KEEPER', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.85, pace: 0.9, wide: 0.85, fam: 'br' },
{ short: 'JUDGE', line: 'EVERY BALL OF YOURS IS OUT.', wide: 1.25, flub: 0.8 },
{ short: 'UMPIRE', line: 'HE WILL NOT BE OVERRULED.', speed: 1.1, flub: 0.7 },
{ short: 'COACH', line: 'HE HAS SEEN YOUR BACKHAND.', pace: 1.15, wide: 1.1 },
@@ -734,7 +734,7 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
// that (15 to 48 away from z_br1, where a good frame scores under 8.5) and refuses them, which is
// the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at
// the top of _master.py for the fix its next attempt needs.
export const ZFAMS = ['r'];
export const ZFAMS = ['r', 'br'];
const POSES = ['bounce', 'toss', 'serve', 'fall1', 'fall2', 'fall3', 'rise'];
export const famFrames = f => [
`z_${f}1`, `z_${f}2`, `z_${f}3`, `z_${f}4`, // stance, shuffle A, shuffle B, forehand
+1 -1
View File
@@ -195,7 +195,7 @@ export function proj(x, y, z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 101;
export const SPRV = 102;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".