Files
splatter-tennis/assets/_regen.py
T
wuwiandClaude Opus 5 a58e8b18aa Colour is arithmetic: match each frame onto the anchor instead of asking for it
The character-as-image fix landed and did NOT close the gap, which is the answer
I promised to report rather than paper over. The brute's serve sheet came back
with his build, his garments and his DARK TONES PIXEL-IDENTICAL to the anchor —
(56,56,32) both — and his big pale shirt two shades brighter, (200,200,184)
against (176,176,152). The model was looking straight at the character. It held
identity perfectly and re-lit the largest flat area anyway, because every call
re-renders and re-rendering re-lights.

No prompt fixes that. Six gates spent a day trying to make a generator do
arithmetic.

So the arithmetic happens here now. Pair each of a frame's dominant colours with
its nearest anchor colour, fit one gain+offset per channel across those pairs,
apply it to every pixel. Pairs further apart than NEW_COLOUR are dropped from the
FIT — colours the anchor genuinely has not got, i.e. blood on a fresh wound — but
they still RECEIVE the transform, so a wound stays the right red relative to the
body it is on. The gain is clamped so one odd pair cannot posterise a frame.

PROVEN ON THE REJECTED FRAME BEFORE SPENDING A SINGLE CALL, which is the
discipline I skipped all day: the frame that measured 20.0 away passes after
correction, its shirt pulled from (200,200,184) to (176,176,160).

And the order is now CORRECT, THEN CHECK — the palette gate is a safety net for
what arithmetic could not fix, not a tripwire for what it can.

The model this leaves:
  the generator, given references  ->  identity, pose, composition
  the pipeline, deterministically  ->  exact colour
  the gates                        ->  layout accidents only

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:46:56 +02:00

484 lines
30 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, shutil, 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)
# Nano Banana Pro via OpenRouter, pinned here so the skill script's default can't drift under us.
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
# The style block is in three parts because the middle one is a REDRAW instruction and only a redraw
# wants it. "Keep every pose and colour exactly as in the reference" is right when the job is
# remastering a sprite that already exists, and it fights every word of the subject when the job is a
# NEW character drawn in the reference's style — set "newart": true on the batch to drop it.
RES = (
"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. "
)
FIDELITY = (
"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. "
)
CANVAS = (
"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. "
# NOTHING ON THE FIGURE MAY BE MAGENTA. The background colour is the transparency key, so any
# magenta the generator paints INSIDE a body is cut out of the sprite. It filled a racket's
# strings with the backdrop colour and installed four men holding an empty hoop — and the
# not_magenta check cannot see it, because that check only reads the corner pixel.
"MAGENTA AND PINK ARE THE BACKGROUND COLOUR AND MAY NOT APPEAR ANYWHERE ON A FIGURE: no magenta "
"clothing, no pink skin, and racket strings are drawn as fine pale grey-white crosshatch over "
"the figure's own body or clothes — NEVER left as the background colour showing through. "
"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."
)
BG_FIRST = ("THE BACKGROUND OF THE OUTPUT IMAGE MUST BE ONE FLAT SOLID MAGENTA #FF00FF. No court, "
"no grass, no lines, no scenery, no sky, no gradient — nothing behind the figures but flat "
"magenta, edge to edge. ")
STYLE = RES + FIDELITY + CANVAS
NEW_STYLE = RES + (
"Match the reference's DRAWING STYLE, its overall figure height and its facing — but the "
"character, the clothes, the colours and the poses are the ones described above, not the "
"reference's. Do not copy the reference's body or outfit. "
) + CANVAS
# The third case, and the one that is easy to miss: the SAME character in a different state (wounded,
# mid-throw). "newart" is wrong for it — it licenses the generator to repaint the character, and it
# did: wound frames came back with living tan skin and a redesigned shirt while the healthy frame
# stayed a grey-green corpse. Set "state": true for those.
SAME_CHAR = (
"This is the SAME character as the reference, not a new one. Their colours, their hair colour, "
"their clothing, their build, their proportions and their height must be IDENTICAL to the "
"reference — the only things that change are the pose and any injuries described above. Do not "
"redesign or recolour any garment. Draw them in FULL COLOUR in exactly the reference's drawing "
"style — never greyscale, never line art, never a different rendering style. "
)
CORPSE = (
"Their skin stays exactly the same dead grey-green as the reference: this is a walking corpse, "
"never pink, never tan, never living flesh. "
)
STATE_STYLE = RES + SAME_CHAR + CORPSE + CANVAS
# ...and the fourth case, which the three above between them could not express: the same LIVING
# character in a NEW POSE. "newart" repaints them (a women's-tour batch anchored on the men's frames
# came back blonde in one batch, auburn in the next, and greyscale line art in a third — a different
# woman every sheet), and "state" is welded to the corpse palette. Set "pose": true, and anchor the
# batch on a frame OF THAT CHARACTER rather than on whoever the pose originally belonged to.
POSE_STYLE = RES + SAME_CHAR + CANVAS
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):
"""Four 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.
HUE the shorts' blue-minus-red. THE ONE THE FIRST THREE ARE BLIND TO: Oliver spotted the
headless men's trousers had changed colour and every number above said they had not
— brightness sat at 85 against the healthy frame's 89 while the hue walked from a
warm charcoal (-8.7) to a cold blue-grey (+0.2). A greyscale measure cannot see a
grey turning blue, which is most of what "the colour changed" ever means here.
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, 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),
round(float((shorts[:, 2] - shorts[:, 0]).mean()) if len(shorts) else 0, 1))
# NOTE the fourth tolerance: kit() returns four numbers and kit_off zips them against this, so a
# three-long tuple would silently skip the hue check rather than fail — the same shape of no-op the
# read-only floodfill in _fine.py was. Every caller passing its own `palette_tol` needs four too.
# A measure only means something if the garment it measures EXISTS. The brute wears a pale grey-green
# work shirt with no red in it at all, so the reference's redness is 0 and every roll with a spot of
# blood on it scored 70-100 — twenty-six points of tolerance against a number that was never
# measuring a shirt. Below this floor `redness` is reading BLOOD, which is supposed to change between
# wound stages, so the axis is skipped rather than fought. The tolerances in a batch were calibrated
# on a red-shirted zombie; a variant inherits them and must not inherit their assumptions.
RED_FLOOR = 40
def kit_off(outs, ref_name, tol=(12, 10, 1.5, 4)):
"""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'))
skip = [ref[0] < RED_FLOOR, False, False, False] # no red garment -> redness means nothing
for o in outs:
got = kit(o)
if any(abs(g - r) > t for i, (g, r, t) in enumerate(zip(got, ref, tol)) if not skip[i]):
return (f'kit drifted vs {ref_name}: redness/shorts/bright {got} vs {ref} '
f'(tolerance {tol})')
return None
# ---- THE STRIDE GATE. A walk cycle is ONE DRAWING WITH THE LEGS MOVED. The generator does not know
# that: asked for "frame 2, then the same with the other leg forward", it draws two independent
# illustrations of the character — different torso twist, different arm positions, different camera
# distance — and every other check in this file passes on that happily (the frame count is right, the
# background is magenta, nothing touches an edge, the detail is fine). So the failure has been
# INVISIBLE and it has shipped repeatedly: the turned CPU's z_c1/z_c2 differ by 0.85 above the hip.
#
# Three numbers, silhouette only, on a feet-aligned centred overlay — which is how drawStatic puts
# them on screen, so this measures what the eye actually sees:
# HGT height ratio. drawStatic sizes every frame by its own PNG height, so anything off 1.00 is a
# body that GROWS AND SHRINKS once per step (the shambler's walk does, by 8%).
# TOP how much differs ABOVE the hip. Near zero for a real stride pair — same man, same camera.
# LEG how much differs BELOW it. This is the step. Near zero means the legs never moved.
# Calibrated on the pairs that read: the brute's wound lurch is (1.00, 0.000, 0.638) and the
# shambler's is (1.00, 0.070, 0.520); the failures sit at TOP 0.47-0.85 or LEG 0.09. The player's
# four-frame RUN sits at TOP 0.23 and is fine — a run swings the arms too — which is why this gate is
# opt-in per batch (`stride: [[i, j]]`) rather than applied to everything with two frames in it.
STRIDE_H, STRIDE_TOP, STRIDE_LEG, STRIDE_HIP = 0.04, 0.20, 0.30, 0.62
def _masks(pa, pb):
a, b = Image.open(pa).convert('RGBA'), Image.open(pb).convert('RGBA')
w, h = max(a.width, b.width), max(a.height, b.height)
def m(im):
c = Image.new('RGBA', (w, h)); c.paste(im, ((w - im.width) // 2, h - im.height))
return np.array(c)[..., 3] > 110
return a, b, m(a), m(b), h
def _d(x, y):
return round(float((x ^ y).sum() / max(1, (x | y).sum())), 3)
def stride_stats(pa, pb):
a, b, ma, mb, h = _masks(pa, pb)
hip = int(h * STRIDE_HIP)
return round(a.height / max(1, b.height), 3), _d(ma[:hip], mb[:hip]), _d(ma[hip:], mb[hip:])
# ---- DID THE CHANGE ACTUALLY HAPPEN? A chained restyle asks for one sheet redrawn with ONE THING
# different, and "copy this exactly" reliably beats "…but change that": removing a HEAD works every
# time (it is dramatic), removing an ARM mostly did not — five of seven wound frames came back as
# the reference redrawn, differing by 0.02-0.05 of their own silhouette, and the ladder silently
# healed the body it had just maimed. So a batch can declare how much it must differ from the sheet
# it was drawn FROM. Measured against the known numbers: a copy scores 0.02-0.05, a real amputation
# 0.12-0.38, a decapitation 0.27-0.75. Nothing else in the pipeline can see this — the art is
# beautiful, correctly coloured, correctly posed and completely wrong.
def changed_off(outs, refs, want):
for o, r in zip(outs, refs):
rp = os.path.join(HERE, r + '.png')
if not os.path.exists(rp): continue
_, _, ma, mb, h = _masks(o, rp)
# WHOLE BODY *OR* UPPER BODY, whichever moved more. An arm is a body-relative amount of
# silhouette: on the lean club pro it is 12-38% of him, on a brute twice his width it is 9%,
# so a threshold calibrated on one rejects a perfectly good amputation on the other. Above
# the hip is where an arm lives, and there the same removal reads roughly twice as loud —
# the stride gate splits at the same line for the same reason.
hip = int(h * STRIDE_HIP)
d, up = _d(ma, mb), _d(ma[:hip], mb[:hip])
if max(d, up) < want:
return (f'{os.path.basename(o)} is {r} redrawn — only {d:.3f} of the silhouette differs '
f'({up:.3f} above the hip, want >= {want}). '
f'The thing that was supposed to change did not change.')
return None
def stride_off(outs, pairs):
for i, j in pairs:
hgt, top, leg = stride_stats(outs[i], outs[j])
why = ('body pulsates' if abs(hgt - 1) > STRIDE_H else
'whole body redrawn, not a leg swap' if top > STRIDE_TOP else
'the legs do not move' if leg < STRIDE_LEG else None)
if why:
return (f'frames {i}/{j} are not one body with the legs moved — {why} '
f'(height x{hgt:.2f}, upper {top:.3f}, legs {leg:.3f}; '
f'want ~1.00 / <={STRIDE_TOP} / >={STRIDE_LEG})')
return None
# ---- THE PALETTE GATE, which replaces four hand-picked ones. `kit` names features — shirt redness,
# shorts brightness, shorts hue, near-white fraction — and every character breaks a different one:
# the brute's shirt has no red in it, a body face down shows a fraction of the shirt it shows
# standing, and NOTHING in that list ever measured SKIN, which is how a brute's flesh got twenty
# points lighter across its fall sheet without a single gate noticing.
#
# This asks the only question that generalises: are the big colour areas of this frame the same
# colours as the anchor's? Top SIX by area — the garments and the skin. Proportions are ignored, so a
# pose change is invisible; blood is never in the top six, so a fresh wound cannot look like drift.
# Calibrated on frames known to be right (0.0-8.5) against the drifts we could see (12.0-15.9), so
# the default sits in the gap. One number, no per-character tuning.
def dom_colours(path, n=6, q=8, floor=0.005):
a = np.array(Image.open(path).convert('RGBA')).astype(int)
px = a[..., :3][a[..., 3] > 128]
if not len(px): return np.zeros((0, 3)), np.zeros(0)
u, c = np.unique((px // q) * q, axis=0, return_counts=True)
k = c / c.sum() >= floor
u, c = u[k], c[k] / c[k].sum()
o = np.argsort(-c)[:n]
return u[o], c[o] / c[o].sum()
def palette_off(outs, anchor, want):
ap = os.path.join(HERE, anchor + '.png')
if not os.path.exists(ap): return None
ua, _ = dom_colours(ap)
if not len(ua): return None
for o in outs:
uo, wo = dom_colours(o)
if not len(uo): continue
d = float((np.sqrt(((uo[:, None, :] - ua[None, :, :]) ** 2).sum(2)).min(1) * wo).sum())
if d > want:
return (f'{os.path.basename(o)} is not painted in {anchor}\'s colours — its main areas sit '
f'{d:.1f} away (want <= {want}). Something the size of a garment or the skin changed.')
return None
# ---- COLOUR IS ARITHMETIC, NOT LUCK. Every call re-renders, and re-rendering re-lights: the brute's
# serve sheet came back with his build, his garments and his dark tones PIXEL-IDENTICAL to the anchor
# and his big pale shirt two shades brighter. That is not a specification failure — the model was
# looking straight at the character — and no prompt fixes it, which is why six gates spent a day
# trying to make a generator do arithmetic.
#
# So do the arithmetic here. Pair each of the frame's dominant colours with its nearest anchor
# colour, fit one gain+offset per channel across those pairs, and apply it to every pixel. Pairs
# further apart than NEW_COLOUR are dropped from the FIT — those are colours the anchor genuinely
# does not have (blood on a fresh wound) — but they still receive the transform, so a wound stays
# the right red relative to the body it is on.
NEW_COLOUR = 60
def match_to_anchor(src, anchor_name):
ap = os.path.join(HERE, anchor_name + '.png')
if not os.path.exists(ap): return
ua, _ = dom_colours(ap)
uo, wo = dom_colours(src)
if not len(ua) or not len(uo): return
d = np.sqrt(((uo[:, None, :] - ua[None, :, :]) ** 2).sum(2))
near = d.argmin(1)
keep = d.min(1) < NEW_COLOUR
if keep.sum() < 2: return # nothing recognisable to fit against
src_c, dst_c, w = uo[keep].astype(float), ua[near[keep]].astype(float), wo[keep]
a = np.array(Image.open(src).convert('RGBA')).astype(float)
m = a[..., 3] > 0
for ch in range(3):
x, y = src_c[:, ch], dst_c[:, ch]
# weighted least squares y = gain*x + offset, with the gain kept sane so one odd pair
# cannot posterise the frame
W = w.sum(); mx, my = (w * x).sum() / W, (w * y).sum() / W
var = (w * (x - mx) ** 2).sum()
gain = float(np.clip(((w * (x - mx) * (y - my)).sum() / var) if var > 1e-6 else 1, 0.7, 1.4))
a[m, ch] = np.clip(gain * (a[m, ch] - mx) + my, 0, 255)
Image.fromarray(a.astype('uint8'), 'RGBA').save(src)
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'
# THE BACKGROUND RULE GOES FIRST, not only at the end of the style block. The chained restyle
# subjects are long, and with the magenta instruction sitting behind three hundred words of
# "keep everything exactly as the reference" the generator started painting the figures on a
# COURT — grey-blue, grey-green, dark green backgrounds, six rejections in eleven rolls. Same
# sentence, said before anything else has had a chance to bury it.
prompt = (BG_FIRST + spec['subject'] + ' '
+ (STATE_STYLE if spec.get('state') else POSE_STYLE if spec.get('pose')
else NEW_STYLE if spec.get('newart') else 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, '--model', MODEL, '--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
# A CHAINED BATCH'S BASELINE IS ITS REFERENCE, MEASURED NOW. Seeding every placeholder up front
# meant a wound stage was scored against a copy of the MASTER — art two steps better than the
# stage it actually follows — and three brute batches were rejected for not out-detailing a
# sheet they were never drawn from.
refs0 = spec.get('ref_only') or []
if len(refs0) == len(sprites):
for r, t in zip(refs0, sprites):
tp, rp = (os.path.join(HERE, n + '.png') for n in (t, r))
if not os.path.exists(tp) and os.path.exists(rp): shutil.copy(rp, tp)
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.
# 'any' turns the detail gate OFF, and there is exactly one job it is for: RE-ROLLING art that is
# already fine to fix something the gate cannot see, such as a colour. The bar is measured against
# the file being replaced, so once a good sheet installs it RATCHETS — a second roll of equal
# quality now has to beat the first, and three colour-fix re-rolls were rejected for being 0.39
# against a 0.46 that was itself only there because the first roll happened to be dense. Magenta
# and edge checks still apply; verify the thing you were actually fixing by measuring it.
bar = {'finer': 1.25, 'same': 0.85, 'loose': 0.7, 'any': 0.0}[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
# CORRECT, THEN CHECK. The frames are pulled onto the anchor's colours first, so the gate below is
# a safety net for what arithmetic could not fix rather than a tripwire for what it can.
if spec.get('palette_anchor'):
for o in outs: match_to_anchor(o, spec['palette_anchor'])
if spec.get('palette_anchor'):
bad = palette_off(outs, spec['palette_anchor'], spec.get('palette_max', 10))
if bad: print(f'[{name}] REJECTED: {bad}'); return False
elif spec.get('palette_ref'):
bad = kit_off(outs, spec['palette_ref'], tuple(spec.get('palette_tol', (12, 10, 1.5))))
if bad: print(f'[{name}] REJECTED: {bad}'); return False
if spec.get('stride'):
bad = stride_off(outs, spec['stride'])
if bad: print(f'[{name}] REJECTED: {bad}'); return False
# a chained restyle declares how far it must move from the sheet it was drawn from
if spec.get('changed') and len(spec.get('ref_only', [])) == len(sprites):
bad = changed_off(outs, spec['ref_only'], spec['changed'])
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
# --audit runs the stride gate over the sprites ALREADY INSTALLED, which is the other half of it
# being useful: a gate that only fires on new rolls says nothing about the art already in the game,
# and the art already in the game is where this was first noticed.
def audit(B, names):
print(f"{'':34s} {'HGT':>6s} {'TOP':>6s} {'LEG':>6s} verdict")
bad = []
for n in names:
for i, j in B[n].get('stride', []):
a, b = (os.path.join(HERE, B[n]['sprites'][k] + '.png') for k in (i, j))
if not (os.path.exists(a) and os.path.exists(b)): continue
hgt, top, leg = stride_stats(a, b)
why = stride_off([a, b], [[0, 1]])
label = f"{n} {B[n]['sprites'][i]}/{B[n]['sprites'][j]}"
print(f'{label:34s} {hgt:6.2f} {top:6.3f} {leg:6.3f} {"FAIL" if why else "ok"}')
if why: bad.append(label)
print('\nFAILS THE GATE:', ', '.join(bad) if bad else 'none')
return not bad
if __name__ == '__main__':
args = sys.argv[1:]
B = batches()
if '--audit' in args:
names = [a for a in args if not a.startswith('--')] or [k for k, v in B.items() if v.get('stride')]
sys.exit(0 if audit(B, names) else 1)
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, stale = [], set()
for n in names:
# A CHAINED BATCH IS DRAWN FROM THE STAGE ABOVE IT, so if that stage failed its reference is
# still a placeholder and whatever comes back is drawn from the wrong body. The brute's
# headless sheet installed cleanly off an arm-off stage that never landed — three perfect
# frames of a two-armed corpse. Skipping is the only honest answer; running is worse than
# failing, because it SUCCEEDS.
blocked = [r for r in (B[n].get('ref_only') or []) if r in stale]
if blocked:
print(f'[{n}] SKIPPED: drawn from {", ".join(blocked)}, which did not land')
bad.append(n); stale.update(B[n]['sprites']); continue
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); stale.update(B[n]['sprites'])
print('\nFAILED BATCHES (re-run):', ' '.join(bad) if bad else 'none')