# _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')