Files
wuwiandClaude Fable 5 6cc9799536 Route by job, not by file size: the tool table fresh sessions needed
Oliver's concern, after the un-chunking: a fresh session walked straight into
_regen.py and diagnosed from there, twice — first the legacy prompts, then the
model's rolls — while the real bug sat in the cutter, provable by putting any
fam_ sheet next to the sprite cut from it. The family-pipeline section already
demoted _regen.py, but 270 lines below the "Asset pipeline" heading, and the
session memory that routed the last investigation pointed at _regen by name.

So the routing is now the FIRST thing under "## Asset pipeline": a
which-tool-for-which-job table that names _famroll -> _zsheet for families,
marks _regen.py as the single-sheet remaster tool it still is (bosses, players,
crowd, weapons — those jobs keep it alive, so it stays), and states the
diagnostic rule that would have saved two days: when installed art looks worse
than the _refcheck sheet it came from, the suspect is the CUT, and a re-cut is
free. _regen.py's own header says the same, for whoever opens it first anyway.

The "Only ink votes" lesson joins the family-pipeline section with the numbers:
0.0% of ink blocks uniform at b=11 against 99.2% of empty ones is how a
detector proven on single-row sheets quietly broke on 9-row geometry — the
threshold never changed, the data under it did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:57:25 +02:00

577 lines
36 KiB
Python

# _regen.py — redraw a batch of sprites at real pixel-art resolution, end to end.
#
# ROUTING — CHECK BEFORE USING. This is the SINGLE-SHEET remaster tool (boss roster, players, crowd,
# weapons — anything without a family). A whole opponent family is NEVER built here: that is
# _famroll.py -> _zsheet.py (PROJECT.md, "THE ZOMBIE FAMILY PIPELINE"), because nine separate rolls
# is nine chances for the character to drift, and it took every one of them. See PROJECT.md
# "WHICH TOOL FOR WHICH JOB" for the full routing table.
#
# 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, ImageDraw
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. "
# MARGINS ARE A PROPORTION, NEVER A PIXEL COUNT. This asked for "150 pixels at every edge" for
# months and the brute came back clipped in three rolls out of four: the generator chooses its
# own output resolution, so an absolute pixel margin means nothing to it — it composed figures at
# 92% of a canvas height it had picked itself. The reference sheet shows 29% margins and that is
# ignored too, so this is the one place the rule can be stated. Fractions it can act on.
"THE FIGURES MUST BE SMALL IN THE FRAME. Draw them at about two thirds of the canvas height, "
"centred, with a wide band of empty magenta above the highest point of the tallest figure "
"(including any raised racket), below the feet, and beyond the outermost figure on each side — "
"roughly a sixth of the canvas on every side. No figure, limb, hair or racket may touch or run "
"off an edge, and the figures must be clearly separated from each other by empty magenta, never "
"overlapping or touching. 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 fill_holes(im):
"""A REFERENCE MAY NOT SHOW A RACKET AS A MAGENTA HOLE. Older sprites were cut before _fine.py
learned to keep enclosed background, so their string beds are TRANSPARENT — o_smash has 3425 such
pixels, o_toss 1786, z_c1 675. Pasted onto the magenta reference canvas those holes come back as
magenta, so every one of those sheets showed the generator a racket whose strings ARE the backdrop
colour, while the prompt spent three sentences forbidding exactly that. It copied the picture,
which is what this project already knows a model does. Fixing it in _fine.py only cleaned the way
IN; this is the way back OUT.
Filled as CROSSHATCH, not as a flat patch, for the same reason: the prompt asks for "fine pale
grey-white crosshatch" and a reference that shows it beats a sentence that describes it.
ponytail: every enclosed hole gets the string bed, including the odd gap between an arm and a
torso — so a body can show a small woven patch on its hip. It is a handful of pixels against a
racket-sized win, and the alternative is classifying holes by shape. Revisit only if a roll comes
back with fabric where skin should be."""
a = np.array(im)
t = a[..., 3] < 128
# PAD BEFORE FLOODING. _fine.py can seed at (0,0) because its sheet has a wide magenta border;
# an installed SPRITE is cropped tight, so the figure touches all four edges and its surround is
# split into pockets that (0,0) cannot reach. Seeding there alone called 10,732 px of ordinary
# background a "hole" and painted the whole bounding box with string bed.
m = Image.fromarray(np.where(np.pad(t, 1, constant_values=True), 255, 0).astype('uint8'), 'L').copy()
ImageDraw.floodfill(m, (0, 0), 128) # .copy() — see _fine.py, fromarray is read-only
holes = t & (np.array(m)[1:-1, 1:-1] == 255)
if not holes.any(): return im
a[holes] = (186, 186, 186, 255)
y, x = np.indices(a.shape[:2])
for step, tone in ((3, 148), (4, 214)): # two offset threads = a woven bed, not a grey disc
w = holes & ((x + y * (step - 2)) % step == 0)
a[w, 0] = a[w, 1] = a[w, 2] = tone
return Image.fromarray(a, 'RGBA')
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 = [fill_holes(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
# --verify asks the question no gate here has ever asked: IS THE ART ALREADY ON DISK WHAT IT CLAIMS
# TO BE? Every check in this file fires on a NEW roll and says nothing about installed frames — and a
# chained batch draws its reference from installed frames. So a stage that was wrong the day it
# shipped becomes the reference for the stage below it, and the stage below that, silently and
# forever. The brute's z_brw1fall row is the proof: four copies of ONE standing lean zombie sold as a
# fat man collapsing, differing from each other by 0.011-0.055 where the sequence they were posed
# from differs by 0.53-0.89. Nothing noticed for as long as it has been in the repo.
#
# Two questions, both reusing measures that were already here:
# DUP does this group of frames differ from each other at all? A pose sequence that does not is
# one drawing pasted N times. Real sequences score 0.5+; a stride pair, the tightest legal
# case, still clears 0.12 whole-body.
# PAL is it painted in the anchor's colours? Same dominant-colour distance the palette gate uses.
DUP_MIN = 0.12
def verify(B, names):
print(f"{'batch':16s} {'frames':>6s} {'DUP':>7s} {'PAL':>6s} verdict")
bad = []
for n in names:
spec = B[n]
frames = [s for s in spec['sprites'] if os.path.exists(os.path.join(HERE, s + '.png'))]
if len(frames) < len(spec['sprites']):
print(f'{n:16s} {"":>6s} {"":>7s} {"":>6s} MISSING art'); bad.append(n); continue
paths = [os.path.join(HERE, f + '.png') for f in frames]
dup = max((_d(*_masks(a, b)[2:4]) for i, a in enumerate(paths) for b in paths[i + 1:]),
default=1.0)
anchor = spec.get('palette_anchor')
pal = 0.0
if anchor and os.path.exists(os.path.join(HERE, anchor + '.png')):
ua, _ = dom_colours(os.path.join(HERE, anchor + '.png'))
for p in paths:
uo, wo = dom_colours(p)
if len(ua) and len(uo):
pal = max(pal, float((np.sqrt(((uo[:, None, :] - ua[None, :, :]) ** 2).sum(2))
.min(1) * wo).sum()))
why = ('ONE DRAWING REPEATED' if dup < DUP_MIN else
f'palette off {anchor}' if pal > spec.get('palette_max', 10) else None)
print(f'{n:16s} {len(frames):6d} {dup:7.3f} {pal:6.1f} {why or "ok"}')
if why: bad.append(n)
print('\nDEAD ART (regenerate before anything is drawn from it):',
' '.join(bad) if bad else 'none')
return not bad
if __name__ == '__main__':
args = sys.argv[1:]
B = batches()
if '--verify' in args:
names = [a for a in args if not a.startswith('--')] or list(B)
sys.exit(0 if verify(B, names) else 1)
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')