Draw the whole serve right-handed, and let the pull wind it up

The racket kept changing hands because the art disagreed with itself: p_idle,
p_bounce and p_toss held it in his LEFT hand while p_smash and p_swing used his
right, so every new frame copied whichever reference it was handed. The idle, the
bounce pair, the toss and the four wind-up frames are all regenerated with the
side named — drawn from behind, his right hand is the right-hand side of the
image, and only the racket HEAD crosses behind his back.

What separates the four wind-up stages is how far the racket has fallen, and that
needed the racket head pinned to a body landmark per frame — above his head,
beside his ear, between his shoulder blades, at the back of his thighs. Described
as "further into it each time" it drew four near-identical poses twice running.
They are wired late in the pull, Oliver's call: frame 1 owns the first half and
the three coiled frames are packed into the top half.

assets/_derive.py is deleted. Fixing generated art by mangling it afterwards is
always worse than regenerating; three guards in _regen.py turn the bad rolls that
used to install silently into automatic re-rolls — a portrait reference canvas
(the generator answered with a 2x2 grid), a non-magenta background (a green sheet
keyed away four racket heads), and a mirrored frame.

The bounce pair is one drawing from one sheet now, differing only in the free
hand, so BOUNCE_IDLE moved to his left side and was re-measured off the sprite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-01 00:49:28 +02:00
1 parent 231cd8650a
commit bcff17812f
17 files changed
+181 -170

No files matched your search

+41 -4
View File
@@ -32,7 +32,9 @@ STYLE = (
"colours. Hard black outline around each figure. The background must be ONE FLAT solid magenta "
"#FF00FF across the entire canvas — no scenery, no gradient, no vignette. 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."
"of the canvas: no figure or limb may touch or run off an edge. 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."
)
def batches():
@@ -40,15 +42,21 @@ def batches():
return json.load(f)
# ---------------------------------------------------------------- helpers ----
def ref_sheet(names, out, scale=2.0, gap=220, pad=150):
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 = [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
w = max(w, int(h * 0.62 * max(frames, len(ims)))) # one landscape slot per frame asked for
s = Image.new('RGBA', (w, h), (255, 0, 255, 255))
step = (w - pad * 2 - sum(i.width for i in ims)) // max(1, len(ims) - 1) if len(ims) > 1 else 0
x = pad
for i in ims:
s.paste(i, (x, h - pad - i.height), i); x += i.width + gap
s.paste(i, (x, h - pad - i.height), i); x += i.width + max(gap, step)
s.save(out)
return out
@@ -60,6 +68,15 @@ def density(path):
d = np.abs(np.diff(a[..., :3].astype(int), axis=1)).sum(2)
return round(float(((d > 40) & solid[:, :-1]).sum() / max(1, solid.sum())), 3)
def not_magenta(sheet):
"""The generator sometimes ignores the magenta and paints its own background. That was written
off as harmless — the key uses the corner colour — right up until it returned a GREEN sheet and
keyed away four grey-green racket heads with it, installing four men holding a grip and nothing
else. A wrong background is now a rejected roll, because the art that dies to it is exactly the
thin art the sprites are made of."""
a = np.array(Image.open(sheet).convert('RGB'))[0, 0].astype(int)
return not (a[0] > 140 and a[2] > 140 and a[1] < a[0] - 60 and a[1] < a[2] - 60)
def touches_edge(sheet):
"""A CLIPPED figure, not a speck of noise: a run of 8+ ink pixels along a border. The strict
version rejected sheets over single stray pixels and cost several regenerations."""
@@ -74,6 +91,19 @@ def touches_edge(sheet):
return best
return max(run(fg[0]), run(fg[-1]), run(fg[:, 0]), run(fg[:, -1])) >= 8
def arm_not_left(outs):
"""The generator MIRRORS the later frames of a serve about half the time — the coil is a strong
silhouette and it draws it whichever way it likes, which puts the racket in the other hand
halfway through the animation. Oliver spotted it twice. Every frame after the first must reach
its raised arm up the LEFT-hand side, so a frame whose left half has nothing near the top (the
arm went right instead) is a rejected roll, not something to notice later in the file browser."""
for o in outs[1:]:
ink = np.array(Image.open(o).convert('RGBA'))[..., 3] > 128
h, w = ink.shape
left = np.nonzero(ink[:, :w // 2].any(1))[0]
if not len(left) or left.min() > 0.18 * h: return o
return None
def colour_match(src, dst_name):
"""Fit the new frame onto the palette statistics of the sprite it replaces, so a redrawn body
does not change colour halfway through a walk cycle."""
@@ -93,7 +123,8 @@ def run(name, spec, dry=False):
lead = spec.get('ref') # generated for scale, thrown away after
shown = spec.get('ref_only') or (([lead] if lead else []) + sprites)
if spec.get('ref_only'): lead = None # ref_only sheets return exactly the frames asked for
ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0))
ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0),
frames=len(sprites) + (1 if lead else 0))
sheet = f'{SCRATCH}/{name}_hi.png'
prompt = f"{spec['subject']} {STYLE}"
if dry:
@@ -103,6 +134,8 @@ def run(name, spec, dry=False):
capture_output=True, text=True, cwd=os.path.dirname(HERE))
if not os.path.exists(sheet):
print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-400:]}'); return False
if not_magenta(sheet):
print(f'[{name}] REJECTED: background is not magenta — thin art keys away with it'); return False
if touches_edge(sheet):
print(f'[{name}] REJECTED: a figure runs off the canvas edge — re-run'); return False
outs = [f'{SCRATCH}/{name}_{i}.png' for i in range(len(sprites) + (1 if lead else 0))]
@@ -113,6 +146,10 @@ def run(name, spec, dry=False):
print(r.stdout.strip() or r.stderr.strip())
if r.returncode or not all(os.path.exists(o) for o in outs):
print(f'[{name}] REJECTED: slicing failed (frames merged?) — re-run or add "cut"'); return False
if spec.get('arm_left'):
bad = arm_not_left(outs)
if bad:
print(f'[{name}] REJECTED: {bad} was drawn MIRRORED — racket in the wrong hand'); return False
if lead: outs = outs[1:] # the scale reference is not installed
before = [density(os.path.join(HERE, s + '.png')) for s in sprites]
after = [density(o) for o in outs]