Files
wuwiandClaude Opus 5 1b7fc2cdcf Both players run from a video, and the middle chip finally plays you
Six sessions of asking an image model for a row of running figures, and it
never once drew a gait: three phases repeated with the same leg leading,
"ropeskipping, not running". A frame-by-frame LEFT/RIGHT skeleton spec, a
closing checklist, a loop clause, six figures instead of eight — all lost.
One 5-second image-to-video clip has no such problem, because it moves a
body instead of drawing poses. ~$0.42 a character through the key already in
.env, pose pinned at BOTH ends (pinned only at the front, she rotated into
profile halfway), stride period MEASURED off the frames rather than sampled
by eye — hers repeats in 6 clip frames, his in 23 — and the phases cut and
palette-snapped through _stride.cut. assets/_vidroll.py films and cuts it;
her first frame came from one still roll that took his approved pose as a
picture instead of a paragraph.

Wired: runN probes the sheet through SK() so the women's tour counts HER
frames, the player is no longer clamped to a two-frame pair, runHz 2.4 ->
1.9 ("this looks like roadrunner"), and the bench grew two PLAYER stops —
a cycle only ever seen mid-rally is a cycle nobody has watched.

Also in: the difficulty retune (medium is a real step above the shipped CPU
and is where `smart` first appears; hard stepped back off superhuman; easy
closed its gap) with MEDIUM's dial-for-dial pin to TUNE removed from the
test, and Oliver's own transparency fixes on the male frames.

SPRV 138 — the frames were right on disk and wrong on screen for an hour
because assets are cached for a year unless the ?v= moves. main.js v269.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:53:58 +02:00

310 lines
16 KiB
Python

# _stride.py — draw the missing HALF of a stride pair. One frame in, its leg-swapped twin out.
#
# uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw1 z_rw2
# uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw1 --check
#
# WHY. A walk cycle in this game is ONE DRAWING WITH THE LEGS MOVED, and every `…b` frame is that
# second half. The club pro's wound rows never got one: `z_rw1b` has the same legs as `z_rw1` (15.1%
# silhouette difference in the bottom third, where the healthy pair scores 51.1%), so a wounded
# opponent has slid through both damage stages since the day it shipped — and because the family
# pipeline draws every new body off the club pro, all seven families inherited it. Oliver, from the
# sprite bench: "arm off — feet are not moving. headless — feet are not moving."
#
# THE PROMPT SHOWS THE CHANGE RATHER THAN DESCRIBING IT. Every previous attempt at this asked in
# words ("identical above the hips, legs clearly swapped") and got two near-identical drawings back.
# IMAGE 2 here is the HEALTHY pair side by side — the same character performing exactly the edit being
# asked for. That is this project's own rule: a picture beats a paragraph.
#
# THE GATE IS THE POINT. A roll is installed only if the legs moved, the body above the hips did not,
# and the racket kept its bed. All three are measured and printed; a failure leaves the sprite alone.
import base64, os, sys
import numpy as np
import requests
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fine import key, block_size, frames, split_piece
from _famref import legs, stride_diff
HERE = os.path.dirname(os.path.abspath(__file__))
SCRATCH = os.path.join(HERE, '_refcheck')
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
# THE THRESHOLDS ARE CALIBRATED ON ART THAT ALREADY SHIPS, not chosen. Every accepted stride pair in
# the repo, measured by the two functions below:
#
# pair legs upper pair legs upper
# z_r2 / z_r3 51.1% 4.4% z_e1 / z_e2 72.1% 24.0%
# z_a1 / z_a2 39.2% 13.2% z_g1 / z_g2 33.6% 8.9%
# z_b1 / z_b2 58.3% 8.0% z_br2 / z_br3 40.9% 2.2%
# z_d1 / z_d2 53.1% 9.9% z_d3 / z_d4 34.6% 5.7%
#
# ...against the frozen pairs this file exists to replace: z_rw1/z_rw1b 15.1%, z_rw2/z_rw2b 14.7%.
# So a real stride starts at 33.6 and a frozen one tops out at 15.1 — 30 sits in the gap with room on
# both sides. Upper allows 25 because the ball boy's accepted pair spends 24 of it: a step moves the
# torso and the racket arm a little, and on a one-armed or headless body there is less silhouette for
# the same absolute shift to hide in. My first guesses (35 / 14) rejected both of those shipping pairs.
# The two-frame BOSS walk (16.3%) is deliberately outside this: it is a slow pace, not a stride pair.
LEGS_MIN = 30.0 # % the legs must differ
UPPER_MAX = 25.0 # % the body above the hips may differ — it is meant to be the same drawing
BED_MIN = 0.60 # the racket bed must keep at least this fraction of the base frame's bed area
def upper(im):
"""Silhouette of the TOP 60%, normalised like legs() — the half that must NOT change."""
a = np.array(im.convert('RGBA'))
op = a[..., 3] > 40
if not op.any(): return np.zeros((32, 64), bool)
h, w = op.shape
y1 = int(h * 0.60)
ys, xs = np.nonzero(op[:y1])
cx = xs.mean() if len(xs) else w / 2
W, H, mpp = 64, 32, w / 140
gx = np.clip((cx + (np.arange(W) - W / 2) * mpp).round().astype(int), 0, w - 1)
gy = np.clip((np.arange(H) * (y1 / H)).round().astype(int), 0, h - 1)
return op[np.ix_(gy, gx)]
def pair_ref(a, b, out, scale=4):
"""IMAGE 2: two frames side by side on magenta — the edit, demonstrated."""
ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in (a, b)]
ims = [i.resize((i.width * scale, i.height * scale), Image.NEAREST) for i in ims]
gap = round(ims[0].height * 0.10)
pad = gap
W = sum(i.width for i in ims) + gap + 2 * pad
H = max(i.height for i in ims) + 2 * pad
s = Image.new('RGB', (W, H), (255, 0, 255))
x = pad
for i in ims:
s.paste(i, (x, H - pad - i.height), i); x += i.width + gap
s.save(out)
return out
def one_ref(n, out, scale=4):
im = Image.open(os.path.join(HERE, n + '.png')).convert('RGBA')
big = im.resize((im.width * scale, im.height * scale), Image.NEAREST)
pad = round(big.height * 0.10)
s = Image.new('RGB', (big.width + 2 * pad, big.height + 2 * pad), (255, 0, 255))
s.paste(big, (pad, pad), big)
s.save(out)
return out
PROMPT = (
'IMAGE 1 is one figure from a zombie tennis game — a player or a walking corpse, holding a '
'racket, drawn as hard-edged pixel art on a flat magenta background.\n\n'
'Redraw IMAGE 1 with ONE change: THE LEGS ARE IN THE OPPOSITE PHASE OF THE SAME STEP. '
'The leg that is forward becomes the back leg and the leg that is back swings forward, so the two '
'drawings together read as one body taking a step. The feet must clearly be in different places '
'than in IMAGE 1.\n\n'
'IMAGE 2 shows exactly the change being asked for: it is the same character, undamaged, drawn '
'twice — one drawing, the legs in the two phases of one step. Copy THAT relationship. The '
'difference between your output and IMAGE 1 must be the difference between the two figures in '
'IMAGE 2, and nothing else.\n\n'
'EVERYTHING FROM THE HIPS UP IS UNCHANGED AND IDENTICAL TO IMAGE 1, pixel for pixel where it can '
'be: the same head or the same headless neck stump, the same shoulders, the same arms, the same '
'racket in the same hand at the same angle, the same clothing with the same tears and the same '
'stains, the same wounds with the same blood, the same colours, the same facing, the same height, '
'the same build. Do NOT redraw the character. Do NOT heal any injury. Do NOT add a head or an arm '
'that IMAGE 1 does not have. Only the legs and the feet move.\n\n'
'Match IMAGE 1\'s drawing technique exactly: the same square pixel grid at the same pixel size, the '
'same hard black outline, the same flat blocks of colour, the same stepped aliased edges, the same '
'dithering. No soft brushwork, no airbrushing, no blur, no gradients, no anti-aliasing.\n\n'
'Output ONE figure only, standing on the same ground line, at the same size as IMAGE 1, on a '
'background of ONE flat solid magenta #FF00FF edge to edge. The inside of the racket head is that '
'same EXACT #FF00FF. No text, no shadows, no ground lines, no second figure.')
def api_key():
if os.environ.get('OPENROUTER_API_KEY'): return os.environ['OPENROUTER_API_KEY']
for line in open(os.path.join(HERE, '..', '.env')):
if line.startswith('OPENROUTER_API_KEY='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
sys.exit('no OPENROUTER_API_KEY')
def u(p):
return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode()
def pale_blob(im):
"""Largest flat pale blob as a % of the body — the racket bed. A bed drawn as STRINGS breaks into
fragments and scores a third of what a flat one does, which is the whole use of the number."""
a = np.array(im.convert('RGBA')).astype(int) if isinstance(im, Image.Image) else im.astype(int)
R, G, B, A = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
lo, hi = np.minimum(np.minimum(R, G), B), np.maximum(np.maximum(R, G), B)
pale = (A > 40) & (lo > 140) & (hi - lo < 50)
lab = np.zeros(pale.shape, int); n = 0
for y, x in zip(*np.nonzero(pale)):
if lab[y, x]: continue
n += 1; st = [(y, x)]; lab[y, x] = n
while st:
cy, cx = st.pop()
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
ny, nx = cy + dy, cx + dx
if 0 <= ny < pale.shape[0] and 0 <= nx < pale.shape[1] and pale[ny, nx] and not lab[ny, nx]:
lab[ny, nx] = n; st.append((ny, nx))
if not n: return 0.0, None, None
sizes = [(int((lab == i).sum()), i) for i in range(1, n + 1)]
big, idx = max(sizes)
m = lab == idx
colour = a[m][:, :3].mean(0) if m.any() else None
return round(big / max(1, int((A > 40).sum())) * 100, 2), m, colour
def fill_bed(crop, ref):
"""Repaint the racket's enclosed background in the BASE frame's bed colour.
`_fine.key` keeps background trapped inside a figure as ink rather than punching it out, so the
hole is here to be filled; the colour comes off the base's own bed so the pair cannot disagree.
"""
_, _, colour = pale_blob(ref)
if colour is None: return crop
a = crop.copy()
op = a[..., 3] > 40
h, w = op.shape
seen = np.zeros_like(op)
stack = [(y, x) for x in range(w) for y in (0, h - 1) if not op[y, x]]
stack += [(y, x) for y in range(h) for x in (0, w - 1) if not op[y, x]]
for y, x in stack: seen[y, x] = True
while stack:
cy, cx = stack.pop()
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and not op[ny, nx] and not seen[ny, nx]:
seen[ny, nx] = True; stack.append((ny, nx))
inside = ~op & ~seen
if inside.any():
a[inside, :3] = colour.astype(a.dtype)
a[inside, 3] = 255
return a
def cut(sheet, like):
"""Slice the roll down to ONE sprite at the SAME height as the frame it twins, on its palette.
Same height is not a nicety: the game draws every frame at its own PNG height, so a twin that
came back 20 px taller would make the body rise once per step — the bounce this project has
already fixed twice.
"""
src = Image.open(sheet).convert('RGB')
a = key(src)
# THE RACKET BED IS FILLED HERE, NOT KEYED OUT — the opposite of what _zsheet.py does, and for the
# opposite reason. _zsheet cuts a whole family, where every frame's bed keys out together and the
# racket is an open hoop throughout. A stride twin is half of a PAIR whose other half is already
# on disk with a painted bed, so keying this one out makes the racket face flicker empty/filled
# once per step — measured: z_rw1 has 0 px of enclosed transparency and the first z_rw1b roll had
# 959. `_fine.key` already keeps enclosed background as ink; it is repainted in the base frame's
# own bed colour so the pair matches.
b = block_size(a)
spans = frames(a, [], 1)
if len(spans) != 1:
return None, f'{len(spans)} figures in the roll, expected 1'
x0, x1 = spans[0]
sub = a[:, x0:x1]
ys = np.nonzero((sub[..., 3] > 0).any(1))[0]
xs = np.nonzero((sub[..., 3] > 0).any(0))[0]
crop = sub[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
if split_piece(crop):
return None, 'the roll holds two stacked bodies'
ref = Image.open(os.path.join(HERE, like + '.png')).convert('RGBA')
crop = fill_bed(crop, ref)
snap = Image.fromarray(crop, 'RGBA').resize(
(max(1, round(crop.shape[1] / b)), max(1, round(crop.shape[0] / b))), Image.BOX)
k = ref.height / snap.height
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
px = np.array(ref)[np.array(ref)[..., 3] > 200][:, :3].astype(int)
pal = np.unique((px // 8) * 8, axis=0)
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)
return Image.fromarray(arr.astype('uint8'), 'RGBA'), None
def gate(new, base):
"""Legs moved, body did not, racket still has a bed. All three numbers, always printed."""
ref = Image.open(os.path.join(HERE, base + '.png'))
dl = stride_diff(legs(new), legs(ref))
du = stride_diff(upper(new), upper(ref))
bn, br = pale_blob(new)[0], pale_blob(ref)[0]
# the bed is drawn, not keyed out, so it must survive at close to the base's area. A string mesh
# fragments it — the first z_rw2b roll scored 2.25 against its base's 7.22.
bed = bn / br if br else 1.0
ok = dl >= LEGS_MIN and du <= UPPER_MAX and bed >= BED_MIN
print(f' legs {dl:5.1f}% (>={LEGS_MIN}) upper {du:5.1f}% (<={UPPER_MAX}) '
f'bed {bn:5.2f}% vs {br:5.2f}% = {bed:.2f}x (>={BED_MIN}) {"PASS" if ok else "REJECT"}')
return ok
def attempt(base, example, i):
"""One roll. EVERY candidate is kept under its own name — a re-roll that comes back worse must
never be able to overwrite one that passed, which is exactly how the first good z_rw1b was lost."""
twin = base + 'b'
i1 = one_ref(base, os.path.join(SCRATCH, f'stride_in_{base}.png'))
i2 = pair_ref(example[0], example[1], os.path.join(SCRATCH, f'stride_ex_{example[0]}.png'))
r = requests.post('https://openrouter.ai/api/v1/images',
headers={'Authorization': f'Bearer {api_key()}', 'Content-Type': 'application/json'},
json={'model': MODEL, 'prompt': PROMPT, 'resolution': '2K', 'aspect_ratio': '1:1',
'input_references': [{'type': 'image_url', 'image_url': {'url': u(i1)}},
{'type': 'image_url', 'image_url': {'url': u(i2)}}]},
timeout=1800)
if r.status_code != 200:
print(f' [{i}] HTTP {r.status_code} {r.text[:160]}'); return None
d = r.json().get('data') or []
if not d:
print(f' [{i}] no image returned'); return None
b64 = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
raw = os.path.join(SCRATCH, f'stride_raw_{twin}_{i}.png')
open(raw, 'wb').write(base64.b64decode(b64))
im, err = cut(raw, base)
if err:
print(f' [{i}] {err} — discarded'); return None
cand = os.path.join(SCRATCH, f'stride_cand_{twin}_{i}.png')
im.save(cand)
print(f' [{i}] cut {im.width}x{im.height} -> {cand}')
return im if gate(im, base) else None
def run(base, example=None, tries=3, install=True):
fam = base[2:base.index('w')] if 'w' in base[2:] else base[2:]
example = example or (f'z_{fam}2', f'z_{fam}3')
twin = base + 'b'
print(f'{base} -> {twin} stride example: {example[0]} / {example[1]}')
for i in range(1, tries + 1):
im = attempt(base, example, i)
if im:
if install:
im.save(os.path.join(HERE, twin + '.png'))
print(f' installed {twin}.png')
return True
print(f' {tries} rolls, none passed — {twin}.png left alone. Candidates are in _refcheck/.')
return False
if __name__ == '__main__':
# a flag's VALUE is not a frame name — skip the token after every option that takes one
argv, args, skip = sys.argv[1:], [], False
for i, a in enumerate(argv):
if skip: skip = False; continue
if a in ('--example', '--tries'): skip = True; continue
if not a.startswith('-'): args.append(a)
if not args: sys.exit('usage: _stride.py <frame>... [--example z_rw1] [--tries N] [--check]')
# --example NAME uses NAME / NAMEb as the demonstration pair instead of the healthy shuffle. A
# wound stage is a closer picture of the edit than an undamaged body is — same chaining logic the
# wound ladder itself uses (see _master.py).
ex = None
if '--example' in sys.argv:
e = sys.argv[sys.argv.index('--example') + 1]
ex = (e, e + 'b')
tries = int(sys.argv[sys.argv.index('--tries') + 1]) if '--tries' in sys.argv else 3
if '--check' in sys.argv:
for base in args:
print(base)
gate(Image.open(os.path.join(HERE, base + 'b.png')), base)
else:
for base in args:
run(base, ex, tries, install='--no-install' not in sys.argv)