Make the regen pipeline check the kit, and fix the swing and toss

Every roll drifts the palette and nothing measured it: a white headband on six
frames, pale grey shorts on the toss twice, brick-red shirts and brown shoes on
the swing all sailed past --tries. check: "same"/"loose" skips colour_match, and
colour_match is a global mean/sd fit far too weak to pull brick-red back to red
— a nearest-colour palette lock is worse still, it drags the duller reds into
the racket's browns.

So _regen.py measures three numbers and a batch opts in with palette_ref:
redness (r minus mean(g,b) over saturated warm pixels — brightness alone cannot
tell brick-red from red), shorts brightness (charcoal 61 vs the drift's 83), and
the near-white fraction that is the shoes and socks (brown drops it to 0.9%,
pale shorts push it to 7.9%, correct frames sit at 2.8%). Frames known to be
right cluster tightly on all three; every drifted frame fell outside on at least
one. It rejected five bad rolls in a row, then passed a good one.

The swing also kept re-inheriting its own drift: ref_sheet is built from
[lead] + the sprites being replaced, so it was showing the generator the very
brick-red frame it was about to overwrite. Copying a canonical frame over the
targets first breaks the loop — it passed on the next roll.

Swing is now a real backswing and follow-through (silhouettes differ by 1.0,
racket low behind vs up over the shoulder), both at full standing height, and
the toss and smash wear charcoal shorts again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-01 11:51:27 +02:00
1 parent 494bab1b69
commit af30db2ff3
10 files changed
+72 -10

No files matched your search

+25 -4
View File
@@ -479,10 +479,31 @@ the player's own batches already used. Correctly anchored, a toss frame is TALLE
here) and the body renders at full size. Quick audit: a standing frame should be ~224, a raised-arm
frame >224, and nothing should be well under it.
**The opponent wears a RED headband.** A prompt that said "white headband" silently repainted it on
six frames, and it only showed up against the untouched `o_toss`/`o_idle1`. When regenerating part
of a character, name his colours in the prompt AND check the regenerated frames against an untouched
one — `check: "same"`/`"loose"` skips `colour_match`, so nothing else will catch a palette drift.
**EVERY ROLL DRIFTS THE PALETTE, SO THE PIPELINE CHECKS IT NOW.** A white headband on six frames,
pale grey shorts on the toss twice, brick-red shirts and brown shoes on the swing — all of it got
past `--tries` because nothing measured colour. `check: "same"`/`"loose"` skips `colour_match`
entirely, and `colour_match` is a global mean/sd fit that is far too weak to pull brick-red back to
red anyway (tried it; and a nearest-colour palette lock is worse — it drags the duller reds into the
racket's browns). So `_regen.py` has `kit()` and `kit_off()`, and a batch opts in with
`"palette_ref": "o_idle1"`:
- **redness** `r - mean(g,b)` over saturated warm pixels. Brightness alone does NOT work — a
brick-red shirt is exactly as bright as a red one, which is why the first check missed it.
- **shorts** mean brightness of mid-tone neutrals. Charcoal ~61, the pale-grey drift ~83.
- **bright%** share of the body that is near-white — the shoes and socks. Brown shoes drop it to
0.9%, pale shorts push it to 7.9%; correct frames sit at 2.7-2.9%.
Frames known to be right cluster at redness 99-110 / shorts 60-63 / bright 2.7-2.9, and every
visibly-drifted frame fell outside on at least one axis. Tolerance `(12, 10, 1.5)`. It immediately
rejected five bad rolls in a row and then passed a good one. **The `palette_ref` must be a frame the
batch is NOT regenerating**, or it validates against itself.
**`ref` feeds the frames you are REPLACING back into the reference sheet.** `ref_sheet` is built
from `[lead] + sprites`, so regenerating an already-drifted frame shows the generator that drift and
it faithfully copies it — the swing failed six straight rolls this way, each one re-inheriting the
brick-red shirt from the frame it was about to overwrite. Breaking the loop is one line: copy a
canonical frame over the targets first (`cp o_idle1.png o_swing1.png`), so the sheet is
colour-correct and the prompt supplies the pose. It passed on the very next roll.
**The opponent was running with empty hands.** `o_run1`/`o_run2` had no racket in either fist, and
the cause was the batch prompt: `opponent_run` never said the word "racket" and its reference sheet
Binary file not shown.
+37
View File
@@ -68,6 +68,40 @@ def density(path):
d = np.abs(np.diff(a[..., :3].astype(int), axis=1)).sum(2)
return round(float(((d > 40) & solid[:, :-1]).sum() / max(1, solid.sum())), 3)
def kit(path):
"""Three numbers that say whether a roll is wearing the right clothes:
REDNESS how red the shirt is (r minus the mean of g,b, over the saturated warm pixels).
Brightness alone will NOT do — a brick-red shirt is as bright as a red one.
SHORTS mean brightness of the mid-tone neutral pixels. Charcoal ~61, pale grey ~83.
BRIGHT fraction of the body that is near-white — the shoes and socks. Brown shoes drop it,
pale shorts push it up.
Calibrated on frames known to be right (idle/bounce/run): redness 99-110, shorts 60-63,
bright 2.7-2.9%. Every frame that had visibly drifted fell outside on at least one axis."""
a = np.array(Image.open(path).convert('RGBA')).astype(int)
px = a[..., :3][a[..., 3] > 128]
if not len(px): return (0.0, 0.0, 0.0)
red = px[(px[:, 0] > px[:, 1] + 45) & (px[:, 0] > px[:, 2] + 45)]
neutral = px[(px.max(1) - px.min(1)) < 55]
shorts = neutral[(neutral.mean(1) > 25) & (neutral.mean(1) < 145)]
bright = neutral[neutral.mean(1) >= 150]
return (round(float((red[:, 0] - (red[:, 1] + red[:, 2]) / 2).mean()) if len(red) else 0, 1),
round(float(shorts.mean()) if len(shorts) else 0, 1),
round(len(bright) / len(px) * 100, 1))
def kit_off(outs, ref_name, tol=(12, 10, 1.5)):
"""Is this roll wearing the right kit? Generative rolls drift the palette EVERY TIME and nothing
else here catches it: colour_match only runs for `check: finer`, and it is a global mean/sd fit
far too weak to pull brick-red back to red. Before this existed, six frames shipped with a white
headband, the toss shipped twice in pale grey shorts, and a swing pair shipped in brown shoes.
Compared against a canonical frame that is NOT part of the batch (o_idle1 for the opponent)."""
ref = kit(os.path.join(HERE, ref_name + '.png'))
for o in outs:
got = kit(o)
if any(abs(g - r) > t for g, r, t in zip(got, ref, tol)):
return (f'kit drifted vs {ref_name}: redness/shorts/bright {got} vs {ref} '
f'(tolerance {tol})')
return None
def not_magenta(sheet):
"""The generator sometimes ignores the magenta and paints its own background. That was written
off as harmless — the key uses the corner colour — right up until it returned a GREEN sheet and
@@ -143,6 +177,9 @@ def run(name, spec, dry=False):
bar = {'finer': 1.25, 'same': 0.85, 'loose': 0.7}[spec.get('check', 'finer')]
if sum(after) / len(after) < sum(before) / len(before) * bar:
print(f'[{name}] REJECTED: not finer than what it replaces {before} -> {after}'); return False
if spec.get('palette_ref'):
bad = kit_off(outs, spec['palette_ref'])
if bad: print(f'[{name}] REJECTED: {bad}'); return False
for o, s in zip(outs, sprites):
if spec.get('check', 'finer') == 'finer': colour_match(o, s)
Image.open(o).save(os.path.join(HERE, s + '.png'))
+8 -4
View File
@@ -152,7 +152,8 @@
],
"anchor": 0,
"check": "same",
"subject": "The reference is ONE tennis opponent in a RED polo shirt, dark grey shorts and a RED HEADBAND, seen from the front, gripping a tennis racket in his right hand. Draw FOUR frames of that same man RUNNING towards the camera. THE FOUR FRAMES MUST BE FOUR DIFFERENT BODY POSITIONS \u2014 no two frames alike, do not repeat a pose: (1) his RIGHT knee lifted high in front of him, left leg stretched out straight behind, left arm forward; (2) his right foot planted flat on the ground under him taking his weight, left knee just beginning to swing forward, both arms low; (3) his LEFT knee lifted high in front of him, right leg stretched out straight behind, right arm forward; (4) his left foot planted flat on the ground under him taking his weight, right knee just beginning to swing forward, both arms low. Every figure is the SAME man as the reference and the SAME size: red polo shirt, dark grey shorts, RED HEADBAND, blond hair, seen from the FRONT. Same face, same colours, same proportions, standing the same height. IN EVERY ONE OF THE FOUR FRAMES he is HOLDING THE TENNIS RACKET in his right hand \u2014 the full racket, with its oval head, its strings, its throat and its grip all drawn, exactly like the reference's racket, carried close in front of his chest. NO FRAME MAY HAVE AN EMPTY RIGHT HAND and no frame may show a racket with the head missing. The whole figure and the whole racket sit fully inside the canvas with clear space around them. The canvas must contain EXACTLY FOUR figures and no others. Do NOT redraw the standing reference pose as an extra figure."
"subject": "The reference is ONE tennis opponent in a RED polo shirt, dark grey shorts and a RED HEADBAND, seen from the front, gripping a tennis racket in his right hand. Draw FOUR frames of that same man RUNNING towards the camera. THE FOUR FRAMES MUST BE FOUR DIFFERENT BODY POSITIONS \u2014 no two frames alike, do not repeat a pose: (1) his RIGHT knee lifted high in front of him, left leg stretched out straight behind, left arm forward; (2) his right foot planted flat on the ground under him taking his weight, left knee just beginning to swing forward, both arms low; (3) his LEFT knee lifted high in front of him, right leg stretched out straight behind, right arm forward; (4) his left foot planted flat on the ground under him taking his weight, right knee just beginning to swing forward, both arms low. Every figure is the SAME man as the reference and the SAME size: red polo shirt, dark grey shorts, RED HEADBAND, blond hair, seen from the FRONT. Same face, same colours, same proportions, standing the same height. IN EVERY ONE OF THE FOUR FRAMES he is HOLDING THE TENNIS RACKET in his right hand \u2014 the full racket, with its oval head, its strings, its throat and its grip all drawn, exactly like the reference's racket, carried close in front of his chest. NO FRAME MAY HAVE AN EMPTY RIGHT HAND and no frame may show a racket with the head missing. The whole figure and the whole racket sit fully inside the canvas with clear space around them. The canvas must contain EXACTLY FOUR figures and no others. Do NOT redraw the standing reference pose as an extra figure.",
"palette_ref": "o_idle1"
},
"ump_boy": {
"sprites": [
@@ -402,7 +403,8 @@
"ref": "o_idle1",
"anchor": 0,
"check": "loose",
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN two frames of a FOREHAND: the racket drawn back across his body, then the follow-through carried through to the other side. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both swing frames he stays UPRIGHT at his full standing height \u2014 head at the same level as the standing frame, back straight, knees only slightly bent. He does not crouch, squat or lunge low. The whole racket is drawn and fully inside the canvas."
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art, side by side. FRAME 1: a plain standing frame for scale, exactly like the reference. FRAME 2 \u2014 THE BACKSWING: he has turned his shoulders and taken the racket BACK BEHIND HIS RIGHT HIP, low and well behind his body, arm coiled, racket head pointing backwards away from the net, weight on his back foot. He has NOT hit the ball yet. FRAME 3 \u2014 THE FOLLOW-THROUGH: the swing is FINISHED. His arm has swept all the way ACROSS HIS CHEST to the OTHER SIDE of his body and the racket head is now UP AND OVER HIS LEFT SHOULDER, near his left ear, his chest turned to face the net, weight on his front foot. FRAMES 2 AND 3 MUST LOOK COMPLETELY DIFFERENT FROM EACH OTHER \u2014 in frame 2 the racket is BEHIND him on his RIGHT, in frame 3 it is HIGH on his LEFT. This contrast is the whole point of the pair. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, DARK CHARCOAL GREY shorts, RED cloth headband, blond hair, white shoes, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, never white or cream. In both swing frames he stays UPRIGHT at his full standing height \u2014 head at the same level as the standing frame, back straight, knees only slightly bent, never crouching or lunging low. The whole racket is drawn and fully inside the canvas. His shoes are WHITE with RED socks and his shirt is a BRIGHT PURE RED, exactly like the reference \u2014 not brick, not brown, not orange.",
"palette_ref": "o_idle1"
},
"opponent_idle": {
"sprites": [
@@ -424,7 +426,8 @@
"ref": "o_idle1",
"anchor": 0,
"check": "loose",
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN the ball TOSS \u2014 standing tall, his free hand stretched straight up above his head having just released the ball, racket back and low. THEN the SMASH \u2014 at full stretch, the racket swung up overhead to hit. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In the toss and smash frames his FEET STAY ON THE GROUND LINE and his body stays the same size as the standing frame; only his arms reach above his head. The whole racket is drawn and fully inside the canvas. His shorts are DARK CHARCOAL GREY, almost black \u2014 the same dark grey as the reference figure standing beside them, definitely NOT pale grey, light grey or blue. His shoes are white. Match the reference palette exactly in every frame."
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN the ball TOSS \u2014 standing tall, his free hand stretched straight up above his head having just released the ball, racket back and low. THEN the SMASH \u2014 at full stretch, the racket swung up overhead to hit. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In the toss and smash frames his FEET STAY ON THE GROUND LINE and his body stays the same size as the standing frame; only his arms reach above his head. The whole racket is drawn and fully inside the canvas. His shorts are DARK CHARCOAL GREY, almost black \u2014 the same dark grey as the reference figure standing beside them, definitely NOT pale grey, light grey or blue. His shoes are white. Match the reference palette exactly in every frame.",
"palette_ref": "o_idle1"
},
"opponent_bounce": {
"sprites": [
@@ -434,6 +437,7 @@
"ref": "o_idle1",
"anchor": 0,
"check": "loose",
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN TWO frames of him standing at the baseline BOUNCING A BALL on the ground before serving, with NO ball drawn. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both bouncing frames his racket is held out to the side in his RIGHT hand \u2014 which is on the LEFT-HAND SIDE OF THE IMAGE \u2014 and his other arm is stretched DOWN AND OUT to the RIGHT-HAND SIDE OF THE IMAGE, well clear of his body, with the palm open and facing the ground as if patting a ball. CRITICAL: the two bouncing frames are otherwise the IDENTICAL drawing \u2014 same stance, same legs, same head, same racket, same size \u2014 and the ONLY difference between them is the height of that patting hand: in the first it is low, near knee height; in the second it is higher, near his waist. Draw NO ball in any frame."
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN TWO frames of him standing at the baseline BOUNCING A BALL on the ground before serving, with NO ball drawn. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both bouncing frames his racket is held out to the side in his RIGHT hand \u2014 which is on the LEFT-HAND SIDE OF THE IMAGE \u2014 and his other arm is stretched DOWN AND OUT to the RIGHT-HAND SIDE OF THE IMAGE, well clear of his body, with the palm open and facing the ground as if patting a ball. CRITICAL: the two bouncing frames are otherwise the IDENTICAL drawing \u2014 same stance, same legs, same head, same racket, same size \u2014 and the ONLY difference between them is the height of that patting hand: in the first it is low, near knee height; in the second it is higher, near his waist. Draw NO ball in any frame.",
"palette_ref": "o_idle1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -117,6 +117,6 @@
<div id="courts"><h2>CHOOSE YOUR COURT</h2></div>
</div>
</div>
<script type="module" src="main.js?v=88"></script>
<script type="module" src="main.js?v=89"></script>
</body>
</html>
+1 -1
View File
@@ -151,7 +151,7 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 76; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 77; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn