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>
188 lines
11 KiB
Python
188 lines
11 KiB
Python
# _regen.py — redraw a batch of sprites at real pixel-art resolution, end to end.
|
|
#
|
|
# python3 assets/_regen.py shambler_a # one batch
|
|
# python3 assets/_regen.py --list # what batches exist
|
|
# python3 assets/_regen.py --all # everything not already done
|
|
#
|
|
# Per batch it: builds a reference sheet from the sprites in the repo, asks the generator to redraw
|
|
# them at HIGH art resolution (the style block below is the whole point — the previous prompts asked
|
|
# for "big chunky pixel blocks", which is a specification for coarse art), slices with _fine.py,
|
|
# colour-matches each frame onto what it replaces, and installs — but only if the sheet passes its
|
|
# checks: right number of frames, nothing touching a canvas edge (that is what clipped z_f1), and a
|
|
# real gain in inner-edge density (the number that tracks "is the drawing actually finer").
|
|
#
|
|
# Failures leave the sprites alone and print why, so a batch can just be re-run.
|
|
import json, os, subprocess, sys
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen')
|
|
GEN = os.path.expanduser('~/.claude/skills/generate-image/scripts/generate_image.py')
|
|
PY = os.environ.get('SPLAT_PY', sys.executable)
|
|
|
|
STYLE = (
|
|
"Redraw them as HIGH-RESOLUTION pixel art. The art pixels must be SMALL: build each figure out "
|
|
"of roughly 90 to 110 pixels of height instead of the reference's 40, so there is room for real "
|
|
"detail, and spend it — a face with teeth, sunken eyes and a jawline; individual fingers; "
|
|
"clothing with visible seams, hems, folds and frayed torn edges; laced shoes; four or five "
|
|
"distinct shades per material with fine hatched shading where the light falls away. Keep every "
|
|
"pose, every body proportion, every height, every facing and every wound EXACTLY as in the "
|
|
"reference — only the drawing resolution and the amount of fine detail change. Keep the same "
|
|
"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. "
|
|
"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():
|
|
with open(os.path.join(HERE, '_regen_batches.json')) as f:
|
|
return json.load(f)
|
|
|
|
# ---------------------------------------------------------------- helpers ----
|
|
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 + max(gap, step)
|
|
s.save(out)
|
|
return out
|
|
|
|
def density(path):
|
|
"""Inner-edge density: how much detail lives INSIDE the silhouette. Coarse art ~0.15, the
|
|
redrawn runner ~0.45. This is the number that matched Oliver's eye."""
|
|
a = np.array(Image.open(path).convert('RGBA'))
|
|
solid = a[..., 3] > 128
|
|
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."""
|
|
a = np.array(Image.open(sheet).convert('RGB')).astype(int)
|
|
bg = a[0, 0]
|
|
fg = (((a - bg) ** 2).sum(2) > 3600) & ~(np.minimum(a[..., 0], a[..., 2]) > a[..., 1] + 40)
|
|
def run(line):
|
|
n = best = 0
|
|
for v in line:
|
|
n = n + 1 if v else 0
|
|
best = max(best, n)
|
|
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."""
|
|
op = lambda x: x[..., 3] > 200
|
|
old = np.array(Image.open(os.path.join(HERE, dst_name + '.png')).convert('RGBA'), dtype=float)
|
|
new = np.array(Image.open(src).convert('RGBA'), dtype=float)
|
|
po, pn = old[op(old)][:, :3], new[op(new)][:, :3]
|
|
m = op(new)
|
|
px = (new[m][:, :3] - pn.mean(0)) / np.maximum(pn.std(0), 1e-6) * po.std(0) + po.mean(0)
|
|
new[m] = np.concatenate([np.clip(px, 0, 255), new[m][:, 3:]], axis=1)
|
|
Image.fromarray(new.astype('uint8'), 'RGBA').save(src)
|
|
|
|
# ------------------------------------------------------------------- run ----
|
|
def run(name, spec, dry=False):
|
|
os.makedirs(SCRATCH, exist_ok=True)
|
|
sprites = spec['sprites']
|
|
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),
|
|
frames=len(sprites) + (1 if lead else 0))
|
|
sheet = f'{SCRATCH}/{name}_hi.png'
|
|
prompt = f"{spec['subject']} {STYLE}"
|
|
if dry:
|
|
print(f'[{name}] would generate {len(sprites)} frames: {", ".join(sprites)}'); return True
|
|
print(f'[{name}] generating {len(sprites)} frames...')
|
|
r = subprocess.run([PY, GEN, prompt, '--input', ref, '--output', sheet],
|
|
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))]
|
|
cut = ['--cut', spec['cut']] if spec.get('cut') else []
|
|
r = subprocess.run([PY, os.path.join(HERE, '_fine.py'), sheet, *outs,
|
|
'--anchor', str(spec.get('anchor', 0)), '--h', str(spec.get('h', 224)), *cut],
|
|
capture_output=True, text=True)
|
|
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]
|
|
# Two jobs, two bars. A coarse->fine REDRAW has to be measurably finer (1.25x, not 1.5x: a sheet
|
|
# that already has blood on it starts high, so a genuinely finer wounded body gains less). A
|
|
# REPOSE — turning the player around, pointing the runner at the camera, matching a wound between
|
|
# two frames — is already fine art and only has to come back no worse.
|
|
bar = {'finer': 1.25, 'same': 0.85, 'loose': 0.7}[spec.get('check', 'finer')]
|
|
if sum(after) / len(after) < sum(before) / len(before) * bar:
|
|
print(f'[{name}] REJECTED: not finer than what it replaces {before} -> {after}'); return False
|
|
for o, s in zip(outs, sprites):
|
|
if spec.get('check', 'finer') == 'finer': colour_match(o, s)
|
|
Image.open(o).save(os.path.join(HERE, s + '.png'))
|
|
print(f'[{name}] installed. detail {before} -> {after}')
|
|
return True
|
|
|
|
if __name__ == '__main__':
|
|
args = sys.argv[1:]
|
|
B = batches()
|
|
if not args or '--list' in args:
|
|
for k, v in B.items(): print(f'{k:16s} {len(v["sprites"]):2d} frames {", ".join(v["sprites"])}')
|
|
sys.exit()
|
|
dry = '--dry' in args
|
|
tries = 3 # a rejected sheet is usually just a bad roll:
|
|
for a in args: # frames merged, or a limb off the canvas
|
|
if a.startswith('--tries='): tries = int(a.split('=')[1])
|
|
names = list(B) if '--all' in args else [a for a in args if not a.startswith('--')]
|
|
bad = []
|
|
for n in names:
|
|
for attempt in range(1, tries + 1):
|
|
if run(n, B[n], dry): break
|
|
print(f'[{n}] attempt {attempt}/{tries} rejected')
|
|
else:
|
|
bad.append(n)
|
|
print('\nFAILED BATCHES (re-run):', ' '.join(bad) if bad else 'none')
|