Files
splatter-tennis/assets/_regen.py
T
wuwiandClaude Opus 5 67a410e707 Show the generator what to copy, not what to replace
The reference sheet is what it imitates, so building that sheet out of the very
sprites I was trying to change is why the front-facing player came back
front-facing and the side-on runner came back side-on. A batch can now name
`ref_only` — the frames it looks at — separately from the frames it replaces:
the back view is drawn from the one genuine back-view frame, the charging runner
from a front-facing zombie, and each brute wound pair from its own A frame so the
injury cannot wander between the two.

Also stopped colour-matching a REPOSE. Fitting a differently-posed frame onto the
old one's colour statistics is what greyed out the serve shorts; when the pose
changes the generator's own palette is the better answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:36:13 +02:00

151 lines
8.4 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."
)
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):
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
s = Image.new('RGBA', (w, h), (255, 0, 255, 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 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 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 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))
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 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 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 = 1.25 if spec.get('check', 'finer') == 'finer' else 0.85
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')