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>
577 lines
36 KiB
Python
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')
|