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>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 14:46:56 +02:00
1 parent 525f017636
commit a58e8b18aa
11 files changed
+81 -9

No files matched your search

Binary file not shown.
+38 -6
View File
@@ -65,6 +65,7 @@ def build(name, frames, scale=2, gap=90, pad=70):
# master: the reference specifies eleven poses that already work, and the prompt only says who.
import json, shutil, collections
# THE FIRST SHEET describes the character in WORDS, because at that point no picture of it exists.
RESTYLE = (
"The reference image is a {n}-FRAME SPRITE SHEET of one zombie tennis player. Redraw THE WHOLE "
"SHEET as a COMPLETELY DIFFERENT ZOMBIE: {who} "
@@ -75,6 +76,28 @@ RESTYLE = (
"There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and "
"the air are empty and the poses are the same as if a ball were there. "
"Output exactly {n} figures side by side in ONE horizontal row, all on the same ground line. ")
# ...AND EVERY SHEET AFTER IT DESCRIBES THE CHARACTER WITH A PICTURE OF IT. This is the whole fix.
# The brute's three sheets each restyled from the master independently and each re-derived "pale
# grey-green work shirt" from the same paragraph, so the game got three different brutes and the
# palette gate — correctly — refused two of them. A paragraph is a lossy specification of a
# character; the character is not.
#
# It is also the pattern that already worked: the racket zombie's own play sheet was generated from
# [z_c1, o_idle1, o_run1, o_swing1] — the character as an IMAGE, the poses as IMAGES — in one call,
# first time. I had it, and then did not use it for variants.
RESTYLE_KNOWN = (
"The FIRST reference image is THE CHARACTER — the exact zombie to draw, and the ONLY source for "
"who it is: its build, its bulk, its height, its clothing and every colour of it. Copy that "
"character exactly. "
"The REMAINING reference images are {n} POSES, and are there ONLY to show what the body is doing "
"— never who it is. "
"Draw THE CHARACTER FROM THE FIRST IMAGE in each of those {n} poses, in the same order, at the "
"same size and the same distance from the camera, all facing the same way. The character never "
"changes; only the pose does. "
"There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and "
"the air are empty and the poses are the same as if a ball were there. "
"Output exactly {n} figures side by side in ONE horizontal row, all on the same ground line. ")
STRIDE_NOTE = ("In particular frames 2 and 3 must stay what they are in the reference: ONE DRAWING "
"SHOWN TWICE with only the legs different — everything above the hips identical "
"between them, the legs clearly swapped, and the two frames NOT identical copies. ")
@@ -117,12 +140,16 @@ def variant(key, who):
seed = lambda src, dst: shutil.copy(os.path.join(HERE, src + '.png'), os.path.join(HERE, dst + '.png'))
made = []
def batch(name, sprites, refs, subject, stride=None, changed=None, newart=False):
def batch(name, sprites, refs, subject, stride=None, changed=None, newart=False, lead=False):
# `lead` means the first reference is THE CHARACTER and not a pose — it is shown to the
# generator and is not one of the frames asked for, so it is skipped when pairing references
# to outputs (both for seeding and for the run-time baseline in _regen).
pose_refs = refs[1:] if lead else refs
# Only the sheets drawn off the MASTER are seeded here. A chained wound stage is seeded by
# _regen at run time, from whatever its reference has become by then — seeding it now would
# score it against a copy of the master, two steps better than the stage it follows.
if newart:
for src, dst in zip(refs, sprites):
for src, dst in zip(pose_refs, sprites):
seed(src, dst)
spec = dict(sprites=sprites, ref_only=list(refs), check='loose', anchor=0, subject=subject)
spec['newart' if newart else 'state'] = True
@@ -139,11 +166,16 @@ def variant(key, who):
print(f'{name:16s} <- {refs[0]}… {", ".join(sprites)}')
K = 'z_' + key
# --- the whole body, restyled off the master
# --- the whole body. The PLAY sheet invents the character from the words; serve and fall are then
# drawn from the character it produced (K+'1' leads their reference list) plus the master's poses.
# That ordering is why the three must be run in the order printed at the end.
for part, frames in SHEETS.items():
batch(f'{key}_{part}', [K + f[3:] for f in frames], frames,
RESTYLE.format(n=len(frames), who=who) + (STRIDE_NOTE if part == 'play' else ''),
stride=[[1, 2]] if part == 'play' else None, newart=True)
first = part == 'play'
refs = frames if first else [K + '1'] + frames
batch(f'{key}_{part}', [K + f[3:] for f in frames], refs,
(RESTYLE.format(n=len(frames), who=who) + STRIDE_NOTE) if first
else RESTYLE_KNOWN.format(n=len(frames)),
stride=[[1, 2]] if first else None, newart=True, lead=not first)
# --- the two wound stages, each chained off the one above it
WALK = ['2', '3', '4'] # shuffle A, shuffle B, forehand — the wound rows' three frames
+39 -1
View File
@@ -277,6 +277,41 @@ def palette_off(outs, anchor, want):
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
@@ -374,7 +409,10 @@ def run(name, spec, dry=False):
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
# the palette gate supersedes the feature-picked kit gate wherever a batch declares an anchor
# 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
+4 -2
View File
@@ -1465,13 +1465,14 @@
"z_brserve"
],
"ref_only": [
"z_br1",
"z_rbounce",
"z_rtoss",
"z_rserve"
],
"check": "loose",
"anchor": 0,
"subject": "The reference image is a 3-FRAME SPRITE SHEET of one zombie tennis player. Redraw THE WHOLE SHEET as a COMPLETELY DIFFERENT ZOMBIE: an ENORMOUS FAT ZOMBIE BRUTE — the same creature as this game's brute, now playing tennis: a HUGE SAGGING BELLY hanging out over the belt, a filthy PALE GREY-GREEN button-up work shirt straining over it, dark stains and a torn ragged hem, heavy OLIVE-BROWN work trousers, thick tree-trunk legs, scuffed brown boots, a SMALL BALD HEAD sunk low into massive rounded shoulders, heavy jowls, sunken eyes, green-grey dead skin, and a wooden tennis racket looking tiny in one huge fist. He is FAR bulkier, heavier and wider than the lean man in the reference — twice his width — and much shorter in the neck. KEEP ALL 3 POSES EXACTLY AS THEY ARE IN THE REFERENCE — the same stance, the same steps with the same leg forward in each, the same swing, the same facing, the same arm and racket positions, the same order left to right, the same total height and the same distance from the camera. Only the CHARACTER changes; the POSES do not. There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and the air are empty and the poses are the same as if a ball were there. Output exactly 3 figures side by side in ONE horizontal row, all on the same ground line. ",
"subject": "The FIRST reference image is THE CHARACTER — the exact zombie to draw, and the ONLY source for who it is: its build, its bulk, its height, its clothing and every colour of it. Copy that character exactly. The REMAINING reference images are 3 POSES, and are there ONLY to show what the body is doing — never who it is. Draw THE CHARACTER FROM THE FIRST IMAGE in each of those 3 poses, in the same order, at the same size and the same distance from the camera, all facing the same way. The character never changes; only the pose does. There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and the air are empty and the poses are the same as if a ball were there. Output exactly 3 figures side by side in ONE horizontal row, all on the same ground line. ",
"newart": true,
"palette_anchor": "z_br1"
},
@@ -1483,6 +1484,7 @@
"z_brrise"
],
"ref_only": [
"z_br1",
"z_rfall1",
"z_rfall2",
"z_rfall3",
@@ -1490,7 +1492,7 @@
],
"check": "loose",
"anchor": 0,
"subject": "The reference image is a 4-FRAME SPRITE SHEET of one zombie tennis player. Redraw THE WHOLE SHEET as a COMPLETELY DIFFERENT ZOMBIE: an ENORMOUS FAT ZOMBIE BRUTE — the same creature as this game's brute, now playing tennis: a HUGE SAGGING BELLY hanging out over the belt, a filthy PALE GREY-GREEN button-up work shirt straining over it, dark stains and a torn ragged hem, heavy OLIVE-BROWN work trousers, thick tree-trunk legs, scuffed brown boots, a SMALL BALD HEAD sunk low into massive rounded shoulders, heavy jowls, sunken eyes, green-grey dead skin, and a wooden tennis racket looking tiny in one huge fist. He is FAR bulkier, heavier and wider than the lean man in the reference — twice his width — and much shorter in the neck. KEEP ALL 4 POSES EXACTLY AS THEY ARE IN THE REFERENCE — the same stance, the same steps with the same leg forward in each, the same swing, the same facing, the same arm and racket positions, the same order left to right, the same total height and the same distance from the camera. Only the CHARACTER changes; the POSES do not. There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and the air are empty and the poses are the same as if a ball were there. Output exactly 4 figures side by side in ONE horizontal row, all on the same ground line. ",
"subject": "The FIRST reference image is THE CHARACTER — the exact zombie to draw, and the ONLY source for who it is: its build, its bulk, its height, its clothing and every colour of it. Copy that character exactly. The REMAINING reference images are 4 POSES, and are there ONLY to show what the body is doing — never who it is. Draw THE CHARACTER FROM THE FIRST IMAGE in each of those 4 poses, in the same order, at the same size and the same distance from the camera, all facing the same way. The character never changes; only the pose does. There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and the air are empty and the poses are the same as if a ball were there. Output exactly 4 figures side by side in ONE horizontal row, all on the same ground line. ",
"newart": true,
"palette_anchor": "z_br1"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB