# _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 kit(path): """Three numbers that say whether a roll is wearing the right clothes: REDNESS how red the shirt is (r minus the mean of g,b, over the saturated warm pixels). Brightness alone will NOT do — a brick-red shirt is as bright as a red one. SHORTS mean brightness of the mid-tone neutral pixels. Charcoal ~61, pale grey ~83. BRIGHT fraction of the body that is near-white — the shoes and socks. Brown shoes drop it, pale shorts push it up. Calibrated on frames known to be right (idle/bounce/run): redness 99-110, shorts 60-63, bright 2.7-2.9%. Every frame that had visibly drifted fell outside on at least one axis.""" a = np.array(Image.open(path).convert('RGBA')).astype(int) px = a[..., :3][a[..., 3] > 128] if not len(px): return (0.0, 0.0, 0.0) red = px[(px[:, 0] > px[:, 1] + 45) & (px[:, 0] > px[:, 2] + 45)] neutral = px[(px.max(1) - px.min(1)) < 55] shorts = neutral[(neutral.mean(1) > 25) & (neutral.mean(1) < 145)] bright = neutral[neutral.mean(1) >= 150] return (round(float((red[:, 0] - (red[:, 1] + red[:, 2]) / 2).mean()) if len(red) else 0, 1), round(float(shorts.mean()) if len(shorts) else 0, 1), round(len(bright) / len(px) * 100, 1)) def kit_off(outs, ref_name, tol=(12, 10, 1.5)): """Is this roll wearing the right kit? Generative rolls drift the palette EVERY TIME and nothing else here catches it: colour_match only runs for `check: finer`, and it is a global mean/sd fit far too weak to pull brick-red back to red. Before this existed, six frames shipped with a white headband, the toss shipped twice in pale grey shorts, and a swing pair shipped in brown shoes. Compared against a canonical frame that is NOT part of the batch (o_idle1 for the opponent).""" ref = kit(os.path.join(HERE, ref_name + '.png')) for o in outs: got = kit(o) if any(abs(g - r) > t for g, r, t in zip(got, ref, tol)): return (f'kit drifted vs {ref_name}: redness/shorts/bright {got} vs {ref} ' f'(tolerance {tol})') return None 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 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 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 if spec.get('palette_ref'): bad = kit_off(outs, spec['palette_ref']) if bad: print(f'[{name}] REJECTED: {bad}'); 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')