The reference sheet was the bug: a clipped head, five scales, frozen legs
Oliver walked the sprite bench and reported the same two faults on every family: the healthy walk loses its head, and the wounded walk's feet do not move. Both were in the ONE FILE every zombie tennis character is drawn from — and the family pipeline had copied it faithfully, which is the pipeline working. WHERE IT CAME FROM. That sheet was not composed from the sprites. It was a GENERATED RE-DRAW of the club pro, made in nine separate 4K calls, one per row band, then stacked (gen4k.py, in a session scratchpad, untracked and now gone). Nine calls returned nine scales, and call 1 came back with the shuffle frame decapitated. Measured against the sprite it was drawn from, that one figure scaled 2.341 wide and 1.996 tall where all thirty others were proportional to three decimals: 33 px missing off the top, width untouched. The local input to that call was exact at 2.000x. The sheet lost the head, not the sprite. THE FIX IS TO STOP PUTTING THE REFERENCE THROUGH THE MODEL. _famref.py composes it from the 31 z_r* frames on disk — one scale, applied once, NEAREST — and costs no tokens. That kills the head and the five scales together, and it explains two things in _zsheet.py that read like fudges: the fall1 re-anchor and the walk-frame levelling exist only to undo per-band scale drift. The levelling is also what turned a headless z_sh2 into a headless AND 13% oversized one, by reading the missing head as a height error and scaling up to compensate. THE GATE WAS MISSING ON THE INPUT. _zsheet.py checks what comes BACK from the model — band count, figure count, stacked bodies, edges, palette, heights — and nothing had ever checked the sheet handed TO it. _famref.check() re-reads the file it just wrote with _zsheet's own detection and refuses one where any figure's width and height scales disagree by 2%, or where a stride pair is frozen. THE THIRD DEFECT NEEDED NEW ART. z_rw1b had the same legs as z_rw1 (15.1% against the healthy pair's 51.1%), so the club pro has slid through both wound stages since the day he shipped, and every family inherited it. _stride.py draws the missing twin, and what made it work after words had failed for as long as they were tried is SHOWING the edit: image 2 is the healthy pair side by side, the same character performing exactly the change being asked for. The headless twin then took the ARM-OFF pair as its example — a wounded body is a closer picture of the edit, the same chaining the wound ladder already uses. Its gate measures three things and prints all three: legs moved, body above the hips did not, racket kept its bed. The thresholds are CALIBRATED on pairs that already ship, not chosen — my first guesses rejected z_e1/z_e2 and z_g1/z_g2, both accepted art. And a twin FILLS the racket bed instead of keying it out: the first rolls came back with 959 px of enclosed transparency against their base's 0, which would have flickered the racket face empty/filled once per step. AFTER, on the installed sprites — leg difference within each stride pair, where the wounded pairs used to sit between 4.8% and 19.7%: r 51.1 / 68.1 / 36.7 ru 55.9 / 59.1 / 38.7 br 40.7 / 42.0 / 26.8 gk 41.1 / 44.6 / 30.6 sh 59.4 / 71.4 / 43.9 lj 38.4 / 45.5 / 27.4 bb 56.5 / 67.1 / 43.2 st 51.6 / 66.1 / 35.7 All seven families re-rolled off the rebuilt reference, every play row with its head, and all 24 family/wound-stage states alternating a real pair — checked by instrumenting drawImage on the bench, not by eye. br and lj sit lowest on the headless pair and are worth a re-roll if they ever read as stiff; lj needed one, its first roll came back frozen on all three pairs with the headless row 31 px short. AND THE REFERENCE IS TRACKED NOW. It lived in gitignored _refcheck/, so the thing the entire pipeline rests on was unreproducible for as long as it existed. It is assets/_pose_ref_family.png, next to _pose_ref_serve.png, and the scripts that build and consume it are in the repo instead of a scratchpad. Also in here: the SHAMBLE BOB, which was a 1 px lift on a wall clock applied to every body every frame whether it was moving or not — a held pose bobbed forever and a corpse bobbed while it lay on the ground. It only happens while walking now, and it steps off ground covered, which is the same rule the strides follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -1768,16 +1768,50 @@ stops being something you police with gates and becomes a property of the reques
|
||||
the model's own guidance says: frames generated in bulk as a sheet hold their character; frames
|
||||
generated one at a time do not.
|
||||
|
||||
### THE POSE REFERENCE IS `assets/_pose_ref_family.png`, AND IT IS TRACKED
|
||||
|
||||
**One file is the reference for every zombie tennis character there will ever be.** 31 frames of the
|
||||
club pro in 9 row bands, exactly `_zsheet.layout()`'s order, on magenta. Every family in `ZFAMS`
|
||||
except `r` itself was drawn off it. It lived in `assets/_refcheck/` — which is **gitignored** — so the
|
||||
thing the entire pipeline rests on was untracked and unreproducible for as long as it existed. It is
|
||||
`assets/_pose_ref_family.png` now, next to `_pose_ref_serve.png`, and it is committed. It ships in the
|
||||
image via `COPY assets`, like every other source sheet; that is not worth a Dockerfile exception.
|
||||
|
||||
**Nothing in this repo composed it.** `_regen.ref_sheet()` builds ONE row at ONE scale — it is not
|
||||
what made a 9-row sheet. Whatever did was ad-hoc and is gone. Rebuilding the sheet therefore means
|
||||
writing the composer INTO the repo, once, with the gate below.
|
||||
|
||||
#### NEVER PUT THE REFERENCE THROUGH THE MODEL
|
||||
|
||||
The version of this sheet that produced the six broken families was a GENERATED RE-DRAW of `z_r*` —
|
||||
nine separate 4K calls, one per row band, stacked locally. That round trip is where both of Oliver's
|
||||
sprite-bench faults came from, and `_famref.py` exists to make it impossible: the sheet is composed
|
||||
from the sprites on disk, one scale, NEAREST, no API. See "The three defects" below for the numbers.
|
||||
|
||||
### The four steps
|
||||
|
||||
```bash
|
||||
# 1. the pose sheet — 31 existing frames, one PNG (any family that is already good; today z_r*)
|
||||
# Composed locally, no API. See assets/_refcheck/brute_poses_in.png for the shape.
|
||||
# 2. the character — ONE frame of the new body, upscaled NEAREST, on magenta
|
||||
# 3. ONE call: poses from image 1, character from image 2
|
||||
# 4. cut it back down
|
||||
python3 assets/_zsheet.py <sheet.png> <famkey> --out assets/_refcheck/sprites # review first
|
||||
python3 assets/_zsheet.py <sheet.png> <famkey> --install # then install
|
||||
# 1. THE POSE SHEET — composed from the 31 z_r* frames, one scale, NEAREST, gated. No API call.
|
||||
uv run --with pillow --with numpy python assets/_famref.py
|
||||
# 2+3. THE CHARACTER (one frame at 4x NEAREST on magenta) and ONE call: poses from image 1,
|
||||
# character from image 2. Both live in _famroll.py; add a family with one row in its FAMS table.
|
||||
uv run --with pillow --with requests python assets/_famroll.py <famkey>
|
||||
# 4. CUT IT BACK DOWN — review first, install second.
|
||||
uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_<k>.png <k> --out assets/_refcheck/rev_<k>
|
||||
uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_<k>.png <k> --install
|
||||
```
|
||||
|
||||
`uv run --with …` is how these run: this machine's python has no PIL or numpy of its own, and the
|
||||
previous session's interpreter was a uv ephemeral env. `SPLAT_PY` still overrides for `_regen.py`.
|
||||
|
||||
**A frozen stride pair in the master blocks the whole pipeline, by design.** `_famref.py` refuses to
|
||||
write the sheet if any of the three stride pairs has legs that barely move — because the model copies
|
||||
what it is shown, so a frozen pair in the reference is a frozen pair in every family drawn off it.
|
||||
Draw the missing twin first:
|
||||
|
||||
```bash
|
||||
uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw1 # then z_rw2
|
||||
uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw2 --example z_rw1
|
||||
```
|
||||
|
||||
Then: add the key to `config.ZFAMS`, put `fam: '<key>'` on an `OPPONENTS` row, bump `SPRV` and
|
||||
@@ -1860,6 +1894,103 @@ magenta — check a new sheet.
|
||||
`z_brw1fall` was ONE DRAWING PASTED FOUR TIMES (0.011–0.055 apart, where a real fall sequence is
|
||||
0.53–0.89). Run it before trusting any chained batch.
|
||||
|
||||
### The three defects the sprite bench found, and the fix (2026-08-12, ALL FIXED)
|
||||
|
||||
Oliver walked the sprite bench and reported the same two faults on every family: the healthy walk
|
||||
loses its head, and the wounded walk's feet do not move. All of it traced to `_pose_ref_family.png`,
|
||||
and the pipeline had copied it faithfully — **which is the pipeline working.** Measured per figure as
|
||||
the implied scale of the sheet against the `z_r*` PNG it was pasted from, width and height separately:
|
||||
|
||||
```
|
||||
band 0 play 1 w2.339 h2.362 | 2 w2.341 h1.996 <-- the ONLY non-proportional figure in all 31
|
||||
3 w2.345 h2.360 | 4 w2.333 h2.361
|
||||
band 1 serve 2.335 | band 2 fall 1.902 | band 3 w1 walk 2.34 | band 4 w1 serve 2.30
|
||||
band 5 w1 fall 1.922 | band 6 w2 walk 2.38 | band 7 w2 serve 2.047 | band 8 w2 fall 1.945
|
||||
```
|
||||
|
||||
**1. `z_r2` IS PASTED WITH ITS HEAD CLIPPED OFF.** Width scales at 2.341, height at 1.996 — 33 sprite
|
||||
px missing off the top, width untouched. Every other figure is proportional to three decimals, so
|
||||
this is a clip and it is the only one in the sheet. `z_r2.png` on disk is fine (123x225, head
|
||||
present): the damage is in the composition, not the sprite. One bad paste, six families — and this is
|
||||
the whole of "healthy walk loses its head", because drawZombie picks frames 2/3 as the walk pair.
|
||||
|
||||
The last commit's walk-frame levelling makes it WORSE, and that is not the levelling being wrong: it
|
||||
reads the missing head as a height error and scales the body up to compensate, so `z_sh2` is 147 px
|
||||
wide against `z_sh3`'s 116. The walk flickers between a normal body and an oversized headless one.
|
||||
|
||||
**2. THE SHEET IS NOT ONE SCALE, IT IS FIVE.** The fall bands sit at 81% of the standing bands and the
|
||||
w2 serve band at 87%. That is the exact bug `_zsheet.py` was written to prevent on the way DOWN,
|
||||
present on the way UP — the sheet tells the model a falling body is a fifth smaller than a standing
|
||||
one. **`_zsheet.py`'s fall1 re-anchor and the walk levelling exist only to undo this.** Rebuild the
|
||||
sheet at one global scale and both should become no-ops; that is the check that they were
|
||||
compensations and not fudges.
|
||||
|
||||
**3. THE WOUNDED STRIDE PAIR WAS NEVER DRAWN, and a rebuild will not fix it.** Leg-region silhouette
|
||||
difference within the stride pair (feet-anchored, mass-centred, bottom third):
|
||||
|
||||
| | healthy 2 <-> 3 | w1 <-> w1b | w2 <-> w2b |
|
||||
|---|---|---|---|
|
||||
| the reference sheet | **51.1%** | 14.3% | 14.5% |
|
||||
| installed `r` | 51.0% | 15.2% | 14.8% |
|
||||
| the six new families | 41–70% | 4.8–19.7% | 6.2–19.9% |
|
||||
|
||||
`z_rw1b` genuinely has the same legs as `z_rw1` on disk, so the club pro has been sliding through both
|
||||
wound stages since the day he shipped — `r` IS the reference, which is why its numbers match it
|
||||
exactly. drawZombie is animating correctly (verified by instrumenting `drawImage` on the bench: it
|
||||
alternates `z_rw1` <-> `z_rw1b` every 0.42 m of ground covered, the right frames at the right
|
||||
cadence). This one needs the master's `w1b`/`w2b` REDRAWN with the legs swapped before a rebuild is
|
||||
worth spending a roll on.
|
||||
|
||||
**THE GATE THAT WAS MISSING IS ON THE INPUT.** `_zsheet.py` checks the roll that comes back — band
|
||||
count, figure count, stacked bodies, edges, palette, heights — and nothing had ever checked the sheet
|
||||
handed TO the model. `_famref.check()` is that gate: it re-reads the sheet it just wrote with
|
||||
`_zsheet`'s own band and figure detection, and refuses one where any figure's width and height scales
|
||||
disagree by more than 2% (the head clip was a 0.35 discrepancy) or where a stride pair is frozen.
|
||||
|
||||
#### How each one was fixed
|
||||
|
||||
| defect | fix | cost |
|
||||
|---|---|---|
|
||||
| 1 · head | `_famref.py` composes the sheet from the sprites on disk, one scale, NEAREST | no API call |
|
||||
| 2 · five scales | same — one `k` for all 31 figures, derived once | no API call |
|
||||
| 3 · frozen wounded stride | `_stride.py` draws the missing twin, shown the healthy pair as the example | 2 rolls |
|
||||
|
||||
Defect 3 is the one that needed new art, and the thing that made it work after every previous attempt
|
||||
failed was **showing the edit instead of describing it**: IMAGE 2 is the healthy stride pair side by
|
||||
side, the same character performing exactly the change being asked for. Words alone ("identical above
|
||||
the hips, legs clearly swapped") had returned near-identical drawings for as long as they were tried.
|
||||
The headless twin then took the ARM-OFF pair as its example rather than the healthy one — a wounded
|
||||
body is a closer picture of the edit, the same chaining the wound ladder itself uses.
|
||||
|
||||
`_stride.py`'s gate measures three things and prints all three: legs moved (>=30%), body above the
|
||||
hips did NOT (<=25%), and the racket bed survived (>=0.6x the base's area). **Its thresholds are
|
||||
calibrated on stride pairs that already ship, not chosen** — the table is in the file. My first
|
||||
guesses, 35 and 14, rejected `z_e1/z_e2` and `z_g1/z_g2`, both of which are accepted art.
|
||||
|
||||
**And a stride twin FILLS the racket bed instead of keying it out** — the opposite of `_zsheet.py`,
|
||||
for the opposite reason. `_zsheet` cuts a whole family, so every bed keys out together and the racket
|
||||
is an open hoop throughout. A twin is half of a pair whose other half is already on disk with a
|
||||
painted bed: the first rolls came back with 959 px of enclosed transparency against their base's 0,
|
||||
which would have flickered the racket face empty/filled once per step.
|
||||
|
||||
#### After the fix, measured on the installed sprites
|
||||
|
||||
Every family re-rolled off the rebuilt reference. Leg difference within each stride pair:
|
||||
|
||||
| fam | walk 2/3 | w1/w1b | w2/w2b | | fam | walk 2/3 | w1/w1b | w2/w2b |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| r | 51.1% | 68.1% | 36.7% | | ru | 55.9% | 59.1% | 38.7% |
|
||||
| br | 40.7% | 42.0% | 26.8% | | gk | 41.1% | 44.6% | 30.6% |
|
||||
| sh | 59.4% | 71.4% | 43.9% | | lj | 38.4% | 45.5% | 27.4% |
|
||||
| bb | 56.5% | 67.1% | 43.2% | | st | 51.6% | 66.1% | 35.7% |
|
||||
| | | | | | | *was 4.8–19.7%* | | |
|
||||
|
||||
Every play row has its head, verified frame by frame in `#bench`, and all 24 family/wound-stage states
|
||||
alternate a real pair (checked by instrumenting `drawImage` on the bench, not by eye). `br` and `lj`
|
||||
sit at 26.8 / 27.4 on the headless pair — the hardest one, subtler than the rest but visibly moving;
|
||||
worth a re-roll if they ever read as stiff. `lj` needed one re-roll: its first came back frozen on all
|
||||
three pairs with the headless row 31 px short.
|
||||
|
||||
### Still open
|
||||
|
||||
- The style is close but not identical to the hand-made sprites: softer outline, blended shading
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# _famref.py — compose THE POSE REFERENCE, `assets/_pose_ref_family.png`, from the installed z_r*
|
||||
# frames. Locally. No API call.
|
||||
#
|
||||
# uv run --with pillow --with numpy python assets/_famref.py # write it, then gate it
|
||||
# uv run --with pillow --with numpy python assets/_famref.py --check # gate the file on disk only
|
||||
#
|
||||
# WHY THIS EXISTS. The reference every zombie tennis family is drawn from used to be a GENERATED
|
||||
# RE-DRAW of the racket family: nine separate 4K calls, one per row band, stacked locally
|
||||
# (`gen4k.py`, session "Zombie sprite regeneration"). Both of the faults Oliver found on the sprite
|
||||
# bench came out of that round trip, and both were then copied faithfully into all six families:
|
||||
#
|
||||
# THE HEAD. The play row's call came back with the shuffle-A figure DECAPITATED. Measured against
|
||||
# the sprite it was drawn from, that one figure scaled 2.341 wide and 1.996 tall where all thirty
|
||||
# others were proportional to three decimals — 33 sprite px missing off the top, width untouched.
|
||||
# The local input to that call (`in_1_play.png`) was exact at 2.000x. The sheet lost the head, not
|
||||
# the sprite: `z_r2.png` on disk has always been fine.
|
||||
#
|
||||
# THE SCALE. Nine independent calls returned nine independent sizes — the fall bands at 81% of the
|
||||
# standing bands, the w2 serve band at 87%. `_zsheet.py`'s fall1 re-anchor and its walk-frame
|
||||
# levelling exist ONLY to undo that, and the levelling is what turned a headless z_sh2 into a
|
||||
# headless AND 13% oversized one: it read the missing head as a height error and scaled up.
|
||||
#
|
||||
# A reference composed from the sprites cannot have either fault. One scale, applied once, NEAREST;
|
||||
# every figure is the art that is already on disk. That is the whole fix, and it costs no tokens.
|
||||
#
|
||||
# THE GATE IS ON THE INPUT, WHICH IS WHERE IT WAS MISSING. `_zsheet.py` checks what comes BACK from
|
||||
# the model — band count, figure count, stacked bodies, edges, palette, heights — and nothing ever
|
||||
# checked the sheet handed TO it. check() below re-reads the file it just wrote with _zsheet's own
|
||||
# detection and refuses a sheet the cutter would misread or that has lost a figure's head.
|
||||
import os, sys
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _zsheet import layout, bands, key # the same layout and the same row splitter
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, '_pose_ref_family.png')
|
||||
FAM = 'r' # the club pro is the master. See PROJECT.md.
|
||||
MAGENTA = (255, 0, 255)
|
||||
SIZE = (3609, 6416) # 9:16, the aspect the images endpoint snaps to
|
||||
MARGIN = 0.025 # a PROPORTION, never a pixel count — the model
|
||||
GAP_X, GAP_Y = 60, 60 # picks its own resolution (see PROJECT.md)
|
||||
STRIDE_MIN = 30.0 # % leg difference a real stride pair clears — calibrated in _stride.py
|
||||
|
||||
|
||||
def bed_magenta(im):
|
||||
"""Repaint the racket's string bed magenta, so it cuts to an open hoop downstream.
|
||||
|
||||
`_zsheet.py` deliberately INVERTS _fine.py's enclosed-magenta rule: the bed is meant to key out.
|
||||
That only works if the reference SHOWS a magenta bed, because a picture beats a paragraph — the
|
||||
prompt asking for one is not enough on its own. The installed z_r* frames have the bed painted
|
||||
pale, so it is repainted here. The bed is the largest pale blob in the frame; the runners-up are
|
||||
the shoes, at a third of its area or less on every one of the 31 frames.
|
||||
"""
|
||||
a = np.array(im.convert('RGBA')).astype(int)
|
||||
R, G, B, A = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
|
||||
lo, hi = np.minimum(np.minimum(R, G), B), np.maximum(np.maximum(R, G), B)
|
||||
pale = (A > 40) & (lo > 140) & (hi - lo < 50)
|
||||
lab = np.zeros(pale.shape, int)
|
||||
n = 0
|
||||
for y, x in zip(*np.nonzero(pale)):
|
||||
if lab[y, x]: continue
|
||||
n += 1
|
||||
stack = [(y, x)]; lab[y, x] = n
|
||||
while stack:
|
||||
cy, cx = stack.pop()
|
||||
for dy in (-1, 0, 1):
|
||||
for dx in (-1, 0, 1):
|
||||
ny, nx = cy + dy, cx + dx
|
||||
if 0 <= ny < pale.shape[0] and 0 <= nx < pale.shape[1] and pale[ny, nx] and not lab[ny, nx]:
|
||||
lab[ny, nx] = n; stack.append((ny, nx))
|
||||
if not n: return im
|
||||
areas = [(int((lab == i).sum()), i) for i in range(1, n + 1)]
|
||||
best = max(areas)[1]
|
||||
a[lab == best, :3] = MAGENTA
|
||||
return Image.fromarray(a.astype('uint8'), 'RGBA')
|
||||
|
||||
|
||||
def legs(im):
|
||||
"""Feet-anchored, mass-centred silhouette of the bottom third — the stride comparison surface.
|
||||
|
||||
Normalised so a wider canvas cannot fake a difference: same metres-per-pixel for every frame,
|
||||
centred on the silhouette's own horizontal centre of mass, anchored on the ground line.
|
||||
"""
|
||||
a = np.array(im.convert('RGBA'))
|
||||
op = a[..., 3] > 40
|
||||
if not op.any(): return np.zeros((32, 64), bool)
|
||||
h, w = op.shape
|
||||
y0 = int(h * 0.66)
|
||||
ys, xs = np.nonzero(op[y0:])
|
||||
cx = xs.mean() if len(xs) else w / 2
|
||||
W, H, mpp = 64, 32, w / 140
|
||||
gx = np.clip((cx + (np.arange(W) - W / 2) * mpp).round().astype(int), 0, w - 1)
|
||||
gy = np.clip((h - 1 - (H - 1 - np.arange(H)) * ((h - y0) / H)).round().astype(int), 0, h - 1)
|
||||
return op[np.ix_(gy, gx)]
|
||||
|
||||
|
||||
def stride_diff(a, b):
|
||||
"""% of the union silhouette that differs. A real stride pair clears 33; a frozen one sits at 15."""
|
||||
u = (a | b).sum()
|
||||
return round(float((a ^ b).sum()) / u * 100, 1) if u else 0.0
|
||||
|
||||
|
||||
def compose():
|
||||
rows = layout(FAM)
|
||||
frames = {n: bed_magenta(Image.open(os.path.join(HERE, n + '.png'))) for row in rows for n in row}
|
||||
# ONE scale for all 31 figures, derived from whichever axis binds. This is the entire point of the
|
||||
# file: the old sheet had five scales because it was five separate rolls.
|
||||
row_w = [sum(frames[n].width for n in row) + GAP_X * (len(row) - 1) for row in rows]
|
||||
row_h = [max(frames[n].height for n in row) for row in rows]
|
||||
cw, ch = max(row_w), sum(row_h) + GAP_Y * (len(rows) - 1)
|
||||
k = min(SIZE[0] * (1 - 2 * MARGIN) / cw, SIZE[1] * (1 - 2 * MARGIN) / ch)
|
||||
|
||||
sheet = Image.new('RGB', SIZE, MAGENTA)
|
||||
y = round((SIZE[1] - ch * k) / 2)
|
||||
for row, rh in zip(rows, row_h):
|
||||
scaled = [(n, round(frames[n].width * k), round(frames[n].height * k)) for n in row]
|
||||
x = round((SIZE[0] - (sum(w for _, w, _ in scaled) + round(GAP_X * k) * (len(row) - 1))) / 2)
|
||||
ground = y + round(rh * k) # ONE ground line per band, bottom-aligned
|
||||
for n, w, h in scaled:
|
||||
sheet.paste(frames[n].resize((w, h), Image.NEAREST), (x, ground - h), frames[n].resize((w, h), Image.NEAREST))
|
||||
x += w + round(GAP_X * k)
|
||||
y = ground + round(GAP_Y * k)
|
||||
sheet.save(OUT)
|
||||
print(f'{OUT} {sheet.size} scale {k:.3f} 31 figures, {len(rows)} bands')
|
||||
return k
|
||||
|
||||
|
||||
def check():
|
||||
"""Re-read the written sheet the way _zsheet.py will, and refuse anything it would misread."""
|
||||
rows = layout(FAM)
|
||||
a = key(Image.open(OUT).convert('RGB'))
|
||||
bs = bands(a)
|
||||
bad = []
|
||||
if len(bs) != len(rows):
|
||||
sys.exit(f'GATE FAILED: {len(bs)} row bands, expected {len(rows)}')
|
||||
# scale is read off the stance, exactly as _zsheet.py reads it
|
||||
K = None
|
||||
for (y0, y1), row in zip(bs, rows):
|
||||
band = a[y0:y1]
|
||||
cols = (band[..., 3] > 0).any(0)
|
||||
spans, s = [], None
|
||||
for x, v in enumerate(cols):
|
||||
if v and s is None: s = x
|
||||
elif not v and s is not None:
|
||||
if x - s > 20: spans.append((s, x))
|
||||
s = None
|
||||
if s is not None: spans.append((s, len(cols)))
|
||||
if len(spans) != len(row):
|
||||
sys.exit(f'GATE FAILED: row {row[0]}: {len(spans)} figures, expected {len(row)}')
|
||||
for (x0, x1), n in zip(spans, row):
|
||||
sub = band[:, x0:x1]
|
||||
ys = np.nonzero((sub[..., 3] > 0).any(1))[0]
|
||||
w, h = x1 - x0, int(ys.max() - ys.min() + 1)
|
||||
src = Image.open(os.path.join(HERE, n + '.png'))
|
||||
if K is None: K = h / src.height
|
||||
sw, sh = w / src.width, h / src.height
|
||||
# THE HEAD CLIP IS EXACTLY THIS TEST. A figure that lost its head keeps its width and
|
||||
# loses height, so width and height stop agreeing — 2.341 against 1.996 is what the old
|
||||
# sheet's z_r2 measured. Everything proportional to 2% is a figure that is all there.
|
||||
if abs(sw - sh) > 0.02 * K:
|
||||
bad.append(f'{n}: w{sw:.3f} h{sh:.3f} NOT PROPORTIONAL — a clipped figure')
|
||||
elif abs(sh - K) > 0.02 * K:
|
||||
bad.append(f'{n}: scale {sh:.3f} against the sheet\'s {K:.3f} — not one scale')
|
||||
if bad:
|
||||
sys.exit('GATE FAILED:\n ' + '\n '.join(bad))
|
||||
print(f'gate: 31 figures, {len(bs)} bands, every one proportional at one scale {K:.3f}')
|
||||
|
||||
# ...and every stride pair, which the old sheet also got wrong. A frozen pair in the REFERENCE is
|
||||
# a frozen pair in every family drawn off it — the model copies what it is shown, so this is the
|
||||
# one place the check is worth anything. It was a warning while z_rw1b and z_rw2b still had the
|
||||
# same legs as the frames they twin; `_stride.py` drew them, so it is a hard gate now.
|
||||
L = {n: legs(Image.open(os.path.join(HERE, n + '.png'))) for n in
|
||||
('z_r2', 'z_r3', 'z_rw1', 'z_rw1b', 'z_rw2', 'z_rw2b')}
|
||||
frozen = []
|
||||
for lbl, p, q in (('healthy 2<->3', 'z_r2', 'z_r3'), ('w1<->w1b', 'z_rw1', 'z_rw1b'),
|
||||
('w2<->w2b', 'z_rw2', 'z_rw2b')):
|
||||
d = stride_diff(L[p], L[q])
|
||||
print(f' stride {lbl:14s} {d:5.1f}% {"ok" if d >= STRIDE_MIN else "FROZEN — legs barely move"}')
|
||||
if d < STRIDE_MIN: frozen.append(f'{p}/{q} at {d:.1f}%')
|
||||
if frozen:
|
||||
sys.exit('GATE FAILED: frozen stride pair(s): ' + ', '.join(frozen) +
|
||||
f' — need >={STRIDE_MIN}%. Draw the twin with _stride.py before rolling a family.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if '--check' not in sys.argv: compose()
|
||||
check()
|
||||
@@ -0,0 +1,125 @@
|
||||
# _famroll.py — roll ONE opponent family: 31 sprites, one call, one character.
|
||||
#
|
||||
# uv run --with pillow --with requests python assets/_famroll.py br # one family
|
||||
# uv run --with pillow --with requests python assets/_famroll.py sh bb # several, in sequence
|
||||
#
|
||||
# Then cut it back down, review first and install second:
|
||||
# uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_br.png br --out assets/_refcheck/sprites_br
|
||||
# uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_br.png br --install
|
||||
#
|
||||
# WHY THIS IS IN THE REPO. Every family in ZFAMS except the club pro was rolled by a script that lived
|
||||
# in a session scratchpad and is gone, off a reference in a gitignored directory. The pipeline PROJECT.md
|
||||
# calls the one reliable way to make this game's art was, in practice, unreproducible. This is that
|
||||
# script, tracked, reading the tracked reference that `_famref.py` composes.
|
||||
#
|
||||
# IMAGE 1 IS THE POSES, IMAGE 2 IS THE CHARACTER, and naming each image's job in the prompt is
|
||||
# load-bearing: without it the majority wins and the roll comes back as whatever most of the pixels
|
||||
# look like. See PROJECT.md, "What the model will and will not do".
|
||||
import base64, os, sys
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REF = os.path.join(HERE, '_pose_ref_family.png')
|
||||
SCRATCH = os.path.join(HERE, '_refcheck')
|
||||
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
|
||||
|
||||
# key -> (the frame the character is read off, the words that describe it). The frame is the source of
|
||||
# truth for build and colour; the words only have to stop the model averaging it into a generic zombie.
|
||||
FAMS = {
|
||||
'br': ('z_g1', 'an ENORMOUS FAT ZOMBIE BRUTE: a huge sagging belly over the belt, a filthy pale '
|
||||
'grey-green work shirt straining over it, heavy olive-brown work trousers, thick '
|
||||
'tree-trunk legs, scuffed brown boots, and a small bald head sunk low into massive '
|
||||
'rounded shoulders. Far bulkier, heavier and wider than the figure in image 1'),
|
||||
'sh': ('z_a1', 'a lean SHAMBLING ZOMBIE in a filthy dark red tennis shirt and grey shorts, gaunt and stooped'),
|
||||
'bb': ('z_e1', 'a small young ZOMBIE BALL BOY in a pale tournament polo and shorts, slight and short'),
|
||||
'st': ('z_d1', 'a ZOMBIE STEWARD in a dark uniform jacket with a high collar, stiff and official'),
|
||||
'ru': ('z_f1', 'a wiry FAST ZOMBIE RUNNER in a sleeveless tennis top, lean and forward-leaning'),
|
||||
'gk': ('z_boss1', 'a heavy ZOMBIE GROUNDSKEEPER in stained work overalls and a shapeless hat'),
|
||||
'lj': ('z_judge1', 'a ZOMBIE LINE JUDGE in a dark blazer and pale trousers, upright and severe'),
|
||||
}
|
||||
|
||||
|
||||
def key():
|
||||
if os.environ.get('OPENROUTER_API_KEY'): return os.environ['OPENROUTER_API_KEY']
|
||||
for line in open(os.path.join(HERE, '..', '.env')):
|
||||
if line.startswith('OPENROUTER_API_KEY='):
|
||||
return line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
sys.exit('no OPENROUTER_API_KEY')
|
||||
|
||||
|
||||
def char_ref(k, spr):
|
||||
"""IMAGE 2: the character, at 4x NEAREST on magenta. Raw sprite pixels, no round trip — the same
|
||||
rule the pose sheet now follows, and for the same reason."""
|
||||
im = Image.open(os.path.join(HERE, spr + '.png')).convert('RGBA')
|
||||
big = im.resize((im.width * 4, im.height * 4), Image.NEAREST)
|
||||
pad = round(big.height * 0.08)
|
||||
out = Image.new('RGB', (big.width + 2 * pad, big.height + 2 * pad), (255, 0, 255))
|
||||
out.paste(big, (pad, pad), big)
|
||||
p = os.path.join(SCRATCH, f'char_{k}.png')
|
||||
os.makedirs(SCRATCH, exist_ok=True)
|
||||
out.save(p)
|
||||
return p
|
||||
|
||||
|
||||
def prompt(who):
|
||||
return (
|
||||
'Take IMAGE 1 and redraw it with ONE change: the character.\n\n'
|
||||
'IMAGE 1 is a sprite sheet of a zombie tennis player: 31 figures in 9 rows on a flat magenta '
|
||||
'background, first undamaged, then with one arm torn off, then with the head gone.\n\n'
|
||||
f'IMAGE 2 is THE CHARACTER to draw instead: {who}. It is a walking corpse with dead '
|
||||
'grey-green skin. Copy its build, its garments and every one of its colours exactly.\n\n'
|
||||
'Keep IMAGE 1 EXACTLY as it is in every other respect: the same 9 rows, the same number of '
|
||||
'figures per row, the same order, the same poses, the same facing, the same sizes, the same '
|
||||
'damage states in the same places. A figure with no head keeps no head; a figure missing an arm '
|
||||
'keeps that arm missing with the same stump and blood. Only the body wearing the poses changes. '
|
||||
'Do not carry over the previous character\'s shirt, shorts, shoes, hair or headband.\n\n'
|
||||
# EVERY UNDAMAGED FIGURE HAS A HEAD, said out loud. The clause above ("a figure with no head
|
||||
# keeps no head") is there for the wound rows and the old normalisation pass took it as licence
|
||||
# to remove one from the play row — which is how six families came back with a decapitated
|
||||
# shuffle frame. The reference no longer shows that; the prompt no longer implies it either.
|
||||
'EVERY FIGURE IN THE FIRST THREE ROWS HAS A HEAD ON ITS SHOULDERS, and so does every figure in '
|
||||
'rows 4, 5 and 6. Only the last three rows are headless. Never remove a head that image 1 shows.\n\n'
|
||||
'MATCH THE DRAWING STYLE OF IMAGE 1 EXACTLY. Image 1 is hard-edged pixel art and your output '
|
||||
'must be indistinguishable from it in technique: the same square pixel grid at the same pixel '
|
||||
'size, the same hard black outline around every figure, the same flat blocks of colour, the '
|
||||
'same stepped aliased edges, the same hand-placed dithering. No soft brushwork, no airbrushing, '
|
||||
'no blur, no gradients, no anti-aliasing.\n\n'
|
||||
'The background is a solid, flat, uniform chroma key. Use EXACTLY hex color #FF00FF, one single '
|
||||
'flat tone across the whole canvas behind every figure, with no panels or boxes of any other '
|
||||
'colour. The inside of every racket head is that same EXACT #FF00FF. No text, no shadows, '
|
||||
'no ground lines.')
|
||||
|
||||
|
||||
def u(p):
|
||||
return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode()
|
||||
|
||||
|
||||
def roll(k):
|
||||
spr, who = FAMS[k]
|
||||
ch = char_ref(k, spr)
|
||||
r = requests.post('https://openrouter.ai/api/v1/images',
|
||||
headers={'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'},
|
||||
# 4K and 9:16: the endpoint DEFAULTS to 1K and snaps the aspect to an enum, so both are
|
||||
# spelled out. The reference is composed at 9:16 for exactly this reason.
|
||||
json={'model': MODEL, 'prompt': prompt(who), 'resolution': '4K', 'aspect_ratio': '9:16',
|
||||
'input_references': [{'type': 'image_url', 'image_url': {'url': u(REF)}},
|
||||
{'type': 'image_url', 'image_url': {'url': u(ch)}}]},
|
||||
timeout=1800)
|
||||
if r.status_code != 200:
|
||||
print(f'{k}: HTTP {r.status_code} {r.text[:200]}'); return
|
||||
d = r.json().get('data') or []
|
||||
if not d:
|
||||
print(f'{k}: no image returned'); return
|
||||
b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
|
||||
out = os.path.join(SCRATCH, f'fam_{k}.png')
|
||||
open(out, 'wb').write(base64.b64decode(b))
|
||||
print(f'{k}: {out} {Image.open(out).size} (character from {spr})', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
keys = [a for a in sys.argv[1:] if not a.startswith('-')]
|
||||
if not keys: sys.exit(f'usage: _famroll.py <key>... keys: {" ".join(FAMS)}')
|
||||
for k in keys:
|
||||
if k not in FAMS: sys.exit(f'unknown family {k}')
|
||||
roll(k)
|
||||
|
After Width: | Height: | Size: 846 KiB |
@@ -0,0 +1,309 @@
|
||||
# _stride.py — draw the missing HALF of a stride pair. One frame in, its leg-swapped twin out.
|
||||
#
|
||||
# uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw1 z_rw2
|
||||
# uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw1 --check
|
||||
#
|
||||
# WHY. A walk cycle in this game is ONE DRAWING WITH THE LEGS MOVED, and every `…b` frame is that
|
||||
# second half. The club pro's wound rows never got one: `z_rw1b` has the same legs as `z_rw1` (15.1%
|
||||
# silhouette difference in the bottom third, where the healthy pair scores 51.1%), so a wounded
|
||||
# opponent has slid through both damage stages since the day it shipped — and because the family
|
||||
# pipeline draws every new body off the club pro, all seven families inherited it. Oliver, from the
|
||||
# sprite bench: "arm off — feet are not moving. headless — feet are not moving."
|
||||
#
|
||||
# THE PROMPT SHOWS THE CHANGE RATHER THAN DESCRIBING IT. Every previous attempt at this asked in
|
||||
# words ("identical above the hips, legs clearly swapped") and got two near-identical drawings back.
|
||||
# IMAGE 2 here is the HEALTHY pair side by side — the same character performing exactly the edit being
|
||||
# asked for. That is this project's own rule: a picture beats a paragraph.
|
||||
#
|
||||
# THE GATE IS THE POINT. A roll is installed only if the legs moved, the body above the hips did not,
|
||||
# and the racket kept its bed. All three are measured and printed; a failure leaves the sprite alone.
|
||||
import base64, os, sys
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _fine import key, block_size, frames, split_piece
|
||||
from _famref import legs, stride_diff
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRATCH = os.path.join(HERE, '_refcheck')
|
||||
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
|
||||
# THE THRESHOLDS ARE CALIBRATED ON ART THAT ALREADY SHIPS, not chosen. Every accepted stride pair in
|
||||
# the repo, measured by the two functions below:
|
||||
#
|
||||
# pair legs upper pair legs upper
|
||||
# z_r2 / z_r3 51.1% 4.4% z_e1 / z_e2 72.1% 24.0%
|
||||
# z_a1 / z_a2 39.2% 13.2% z_g1 / z_g2 33.6% 8.9%
|
||||
# z_b1 / z_b2 58.3% 8.0% z_br2 / z_br3 40.9% 2.2%
|
||||
# z_d1 / z_d2 53.1% 9.9% z_d3 / z_d4 34.6% 5.7%
|
||||
#
|
||||
# ...against the frozen pairs this file exists to replace: z_rw1/z_rw1b 15.1%, z_rw2/z_rw2b 14.7%.
|
||||
# So a real stride starts at 33.6 and a frozen one tops out at 15.1 — 30 sits in the gap with room on
|
||||
# both sides. Upper allows 25 because the ball boy's accepted pair spends 24 of it: a step moves the
|
||||
# torso and the racket arm a little, and on a one-armed or headless body there is less silhouette for
|
||||
# the same absolute shift to hide in. My first guesses (35 / 14) rejected both of those shipping pairs.
|
||||
# The two-frame BOSS walk (16.3%) is deliberately outside this: it is a slow pace, not a stride pair.
|
||||
LEGS_MIN = 30.0 # % the legs must differ
|
||||
UPPER_MAX = 25.0 # % the body above the hips may differ — it is meant to be the same drawing
|
||||
BED_MIN = 0.60 # the racket bed must keep at least this fraction of the base frame's bed area
|
||||
|
||||
|
||||
def upper(im):
|
||||
"""Silhouette of the TOP 60%, normalised like legs() — the half that must NOT change."""
|
||||
a = np.array(im.convert('RGBA'))
|
||||
op = a[..., 3] > 40
|
||||
if not op.any(): return np.zeros((32, 64), bool)
|
||||
h, w = op.shape
|
||||
y1 = int(h * 0.60)
|
||||
ys, xs = np.nonzero(op[:y1])
|
||||
cx = xs.mean() if len(xs) else w / 2
|
||||
W, H, mpp = 64, 32, w / 140
|
||||
gx = np.clip((cx + (np.arange(W) - W / 2) * mpp).round().astype(int), 0, w - 1)
|
||||
gy = np.clip((np.arange(H) * (y1 / H)).round().astype(int), 0, h - 1)
|
||||
return op[np.ix_(gy, gx)]
|
||||
|
||||
|
||||
def pair_ref(a, b, out, scale=4):
|
||||
"""IMAGE 2: two frames side by side on magenta — the edit, demonstrated."""
|
||||
ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in (a, b)]
|
||||
ims = [i.resize((i.width * scale, i.height * scale), Image.NEAREST) for i in ims]
|
||||
gap = round(ims[0].height * 0.10)
|
||||
pad = gap
|
||||
W = sum(i.width for i in ims) + gap + 2 * pad
|
||||
H = max(i.height for i in ims) + 2 * pad
|
||||
s = Image.new('RGB', (W, H), (255, 0, 255))
|
||||
x = pad
|
||||
for i in ims:
|
||||
s.paste(i, (x, H - pad - i.height), i); x += i.width + gap
|
||||
s.save(out)
|
||||
return out
|
||||
|
||||
|
||||
def one_ref(n, out, scale=4):
|
||||
im = Image.open(os.path.join(HERE, n + '.png')).convert('RGBA')
|
||||
big = im.resize((im.width * scale, im.height * scale), Image.NEAREST)
|
||||
pad = round(big.height * 0.10)
|
||||
s = Image.new('RGB', (big.width + 2 * pad, big.height + 2 * pad), (255, 0, 255))
|
||||
s.paste(big, (pad, pad), big)
|
||||
s.save(out)
|
||||
return out
|
||||
|
||||
|
||||
PROMPT = (
|
||||
'IMAGE 1 is one figure from a zombie tennis game: a walking corpse holding a racket, drawn as '
|
||||
'hard-edged pixel art on a flat magenta background.\n\n'
|
||||
'Redraw IMAGE 1 with ONE change: THE LEGS ARE IN THE OPPOSITE PHASE OF THE SAME WALKING STEP. '
|
||||
'The leg that is forward becomes the back leg and the leg that is back swings forward, so the two '
|
||||
'drawings together read as one body taking a step. The feet must clearly be in different places '
|
||||
'than in IMAGE 1.\n\n'
|
||||
'IMAGE 2 shows exactly the change being asked for: it is the same character, undamaged, drawn '
|
||||
'twice — one drawing, the legs in the two phases of one step. Copy THAT relationship. The '
|
||||
'difference between your output and IMAGE 1 must be the difference between the two figures in '
|
||||
'IMAGE 2, and nothing else.\n\n'
|
||||
'EVERYTHING FROM THE HIPS UP IS UNCHANGED AND IDENTICAL TO IMAGE 1, pixel for pixel where it can '
|
||||
'be: the same head or the same headless neck stump, the same shoulders, the same arms, the same '
|
||||
'racket in the same hand at the same angle, the same clothing with the same tears and the same '
|
||||
'stains, the same wounds with the same blood, the same colours, the same facing, the same height, '
|
||||
'the same build. Do NOT redraw the character. Do NOT heal any injury. Do NOT add a head or an arm '
|
||||
'that IMAGE 1 does not have. Only the legs and the feet move.\n\n'
|
||||
'Match IMAGE 1\'s drawing technique exactly: the same square pixel grid at the same pixel size, the '
|
||||
'same hard black outline, the same flat blocks of colour, the same stepped aliased edges, the same '
|
||||
'dithering. No soft brushwork, no airbrushing, no blur, no gradients, no anti-aliasing.\n\n'
|
||||
'Output ONE figure only, standing on the same ground line, at the same size as IMAGE 1, on a '
|
||||
'background of ONE flat solid magenta #FF00FF edge to edge. The inside of the racket head is that '
|
||||
'same EXACT #FF00FF. No text, no shadows, no ground lines, no second figure.')
|
||||
|
||||
|
||||
def api_key():
|
||||
if os.environ.get('OPENROUTER_API_KEY'): return os.environ['OPENROUTER_API_KEY']
|
||||
for line in open(os.path.join(HERE, '..', '.env')):
|
||||
if line.startswith('OPENROUTER_API_KEY='):
|
||||
return line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
sys.exit('no OPENROUTER_API_KEY')
|
||||
|
||||
|
||||
def u(p):
|
||||
return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode()
|
||||
|
||||
|
||||
def pale_blob(im):
|
||||
"""Largest flat pale blob as a % of the body — the racket bed. A bed drawn as STRINGS breaks into
|
||||
fragments and scores a third of what a flat one does, which is the whole use of the number."""
|
||||
a = np.array(im.convert('RGBA')).astype(int) if isinstance(im, Image.Image) else im.astype(int)
|
||||
R, G, B, A = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
|
||||
lo, hi = np.minimum(np.minimum(R, G), B), np.maximum(np.maximum(R, G), B)
|
||||
pale = (A > 40) & (lo > 140) & (hi - lo < 50)
|
||||
lab = np.zeros(pale.shape, int); n = 0
|
||||
for y, x in zip(*np.nonzero(pale)):
|
||||
if lab[y, x]: continue
|
||||
n += 1; st = [(y, x)]; lab[y, x] = n
|
||||
while st:
|
||||
cy, cx = st.pop()
|
||||
for dy in (-1, 0, 1):
|
||||
for dx in (-1, 0, 1):
|
||||
ny, nx = cy + dy, cx + dx
|
||||
if 0 <= ny < pale.shape[0] and 0 <= nx < pale.shape[1] and pale[ny, nx] and not lab[ny, nx]:
|
||||
lab[ny, nx] = n; st.append((ny, nx))
|
||||
if not n: return 0.0, None, None
|
||||
sizes = [(int((lab == i).sum()), i) for i in range(1, n + 1)]
|
||||
big, idx = max(sizes)
|
||||
m = lab == idx
|
||||
colour = a[m][:, :3].mean(0) if m.any() else None
|
||||
return round(big / max(1, int((A > 40).sum())) * 100, 2), m, colour
|
||||
|
||||
|
||||
def fill_bed(crop, ref):
|
||||
"""Repaint the racket's enclosed background in the BASE frame's bed colour.
|
||||
|
||||
`_fine.key` keeps background trapped inside a figure as ink rather than punching it out, so the
|
||||
hole is here to be filled; the colour comes off the base's own bed so the pair cannot disagree.
|
||||
"""
|
||||
_, _, colour = pale_blob(ref)
|
||||
if colour is None: return crop
|
||||
a = crop.copy()
|
||||
op = a[..., 3] > 40
|
||||
h, w = op.shape
|
||||
seen = np.zeros_like(op)
|
||||
stack = [(y, x) for x in range(w) for y in (0, h - 1) if not op[y, x]]
|
||||
stack += [(y, x) for y in range(h) for x in (0, w - 1) if not op[y, x]]
|
||||
for y, x in stack: seen[y, x] = True
|
||||
while stack:
|
||||
cy, cx = stack.pop()
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
ny, nx = cy + dy, cx + dx
|
||||
if 0 <= ny < h and 0 <= nx < w and not op[ny, nx] and not seen[ny, nx]:
|
||||
seen[ny, nx] = True; stack.append((ny, nx))
|
||||
inside = ~op & ~seen
|
||||
if inside.any():
|
||||
a[inside, :3] = colour.astype(a.dtype)
|
||||
a[inside, 3] = 255
|
||||
return a
|
||||
|
||||
|
||||
def cut(sheet, like):
|
||||
"""Slice the roll down to ONE sprite at the SAME height as the frame it twins, on its palette.
|
||||
|
||||
Same height is not a nicety: the game draws every frame at its own PNG height, so a twin that
|
||||
came back 20 px taller would make the body rise once per step — the bounce this project has
|
||||
already fixed twice.
|
||||
"""
|
||||
src = Image.open(sheet).convert('RGB')
|
||||
a = key(src)
|
||||
# THE RACKET BED IS FILLED HERE, NOT KEYED OUT — the opposite of what _zsheet.py does, and for the
|
||||
# opposite reason. _zsheet cuts a whole family, where every frame's bed keys out together and the
|
||||
# racket is an open hoop throughout. A stride twin is half of a PAIR whose other half is already
|
||||
# on disk with a painted bed, so keying this one out makes the racket face flicker empty/filled
|
||||
# once per step — measured: z_rw1 has 0 px of enclosed transparency and the first z_rw1b roll had
|
||||
# 959. `_fine.key` already keeps enclosed background as ink; it is repainted in the base frame's
|
||||
# own bed colour so the pair matches.
|
||||
b = block_size(a)
|
||||
spans = frames(a, [], 1)
|
||||
if len(spans) != 1:
|
||||
return None, f'{len(spans)} figures in the roll, expected 1'
|
||||
x0, x1 = spans[0]
|
||||
sub = a[:, x0:x1]
|
||||
ys = np.nonzero((sub[..., 3] > 0).any(1))[0]
|
||||
xs = np.nonzero((sub[..., 3] > 0).any(0))[0]
|
||||
crop = sub[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
|
||||
if split_piece(crop):
|
||||
return None, 'the roll holds two stacked bodies'
|
||||
ref = Image.open(os.path.join(HERE, like + '.png')).convert('RGBA')
|
||||
crop = fill_bed(crop, ref)
|
||||
snap = Image.fromarray(crop, 'RGBA').resize(
|
||||
(max(1, round(crop.shape[1] / b)), max(1, round(crop.shape[0] / b))), Image.BOX)
|
||||
k = ref.height / snap.height
|
||||
im = snap.resize((max(1, round(snap.width * k)), max(1, round(snap.height * k))), Image.BOX)
|
||||
arr = np.array(im).astype(int)
|
||||
solid = arr[..., 3] > 110
|
||||
px = np.array(ref)[np.array(ref)[..., 3] > 200][:, :3].astype(int)
|
||||
pal = np.unique((px // 8) * 8, axis=0)
|
||||
f = arr[..., :3].reshape(-1, 3)
|
||||
arr[..., :3] = pal[((f[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1)].reshape(arr.shape[0], arr.shape[1], 3)
|
||||
arr[..., 3] = np.where(solid, 255, 0)
|
||||
return Image.fromarray(arr.astype('uint8'), 'RGBA'), None
|
||||
|
||||
|
||||
def gate(new, base):
|
||||
"""Legs moved, body did not, racket still has a bed. All three numbers, always printed."""
|
||||
ref = Image.open(os.path.join(HERE, base + '.png'))
|
||||
dl = stride_diff(legs(new), legs(ref))
|
||||
du = stride_diff(upper(new), upper(ref))
|
||||
bn, br = pale_blob(new)[0], pale_blob(ref)[0]
|
||||
# the bed is drawn, not keyed out, so it must survive at close to the base's area. A string mesh
|
||||
# fragments it — the first z_rw2b roll scored 2.25 against its base's 7.22.
|
||||
bed = bn / br if br else 1.0
|
||||
ok = dl >= LEGS_MIN and du <= UPPER_MAX and bed >= BED_MIN
|
||||
print(f' legs {dl:5.1f}% (>={LEGS_MIN}) upper {du:5.1f}% (<={UPPER_MAX}) '
|
||||
f'bed {bn:5.2f}% vs {br:5.2f}% = {bed:.2f}x (>={BED_MIN}) {"PASS" if ok else "REJECT"}')
|
||||
return ok
|
||||
|
||||
|
||||
def attempt(base, example, i):
|
||||
"""One roll. EVERY candidate is kept under its own name — a re-roll that comes back worse must
|
||||
never be able to overwrite one that passed, which is exactly how the first good z_rw1b was lost."""
|
||||
twin = base + 'b'
|
||||
i1 = one_ref(base, os.path.join(SCRATCH, f'stride_in_{base}.png'))
|
||||
i2 = pair_ref(example[0], example[1], os.path.join(SCRATCH, f'stride_ex_{example[0]}.png'))
|
||||
r = requests.post('https://openrouter.ai/api/v1/images',
|
||||
headers={'Authorization': f'Bearer {api_key()}', 'Content-Type': 'application/json'},
|
||||
json={'model': MODEL, 'prompt': PROMPT, 'resolution': '2K', 'aspect_ratio': '1:1',
|
||||
'input_references': [{'type': 'image_url', 'image_url': {'url': u(i1)}},
|
||||
{'type': 'image_url', 'image_url': {'url': u(i2)}}]},
|
||||
timeout=1800)
|
||||
if r.status_code != 200:
|
||||
print(f' [{i}] HTTP {r.status_code} {r.text[:160]}'); return None
|
||||
d = r.json().get('data') or []
|
||||
if not d:
|
||||
print(f' [{i}] no image returned'); return None
|
||||
b64 = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
|
||||
raw = os.path.join(SCRATCH, f'stride_raw_{twin}_{i}.png')
|
||||
open(raw, 'wb').write(base64.b64decode(b64))
|
||||
im, err = cut(raw, base)
|
||||
if err:
|
||||
print(f' [{i}] {err} — discarded'); return None
|
||||
cand = os.path.join(SCRATCH, f'stride_cand_{twin}_{i}.png')
|
||||
im.save(cand)
|
||||
print(f' [{i}] cut {im.width}x{im.height} -> {cand}')
|
||||
return im if gate(im, base) else None
|
||||
|
||||
|
||||
def run(base, example=None, tries=3, install=True):
|
||||
fam = base[2:base.index('w')] if 'w' in base[2:] else base[2:]
|
||||
example = example or (f'z_{fam}2', f'z_{fam}3')
|
||||
twin = base + 'b'
|
||||
print(f'{base} -> {twin} stride example: {example[0]} / {example[1]}')
|
||||
for i in range(1, tries + 1):
|
||||
im = attempt(base, example, i)
|
||||
if im:
|
||||
if install:
|
||||
im.save(os.path.join(HERE, twin + '.png'))
|
||||
print(f' installed {twin}.png')
|
||||
return True
|
||||
print(f' {tries} rolls, none passed — {twin}.png left alone. Candidates are in _refcheck/.')
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# a flag's VALUE is not a frame name — skip the token after every option that takes one
|
||||
argv, args, skip = sys.argv[1:], [], False
|
||||
for i, a in enumerate(argv):
|
||||
if skip: skip = False; continue
|
||||
if a in ('--example', '--tries'): skip = True; continue
|
||||
if not a.startswith('-'): args.append(a)
|
||||
if not args: sys.exit('usage: _stride.py <frame>... [--example z_rw1] [--tries N] [--check]')
|
||||
# --example NAME uses NAME / NAMEb as the demonstration pair instead of the healthy shuffle. A
|
||||
# wound stage is a closer picture of the edit than an undamaged body is — same chaining logic the
|
||||
# wound ladder itself uses (see _master.py).
|
||||
ex = None
|
||||
if '--example' in sys.argv:
|
||||
e = sys.argv[sys.argv.index('--example') + 1]
|
||||
ex = (e, e + 'b')
|
||||
tries = int(sys.argv[sys.argv.index('--tries') + 1]) if '--tries' in sys.argv else 3
|
||||
if '--check' in sys.argv:
|
||||
for base in args:
|
||||
print(base)
|
||||
gate(Image.open(os.path.join(HERE, base + 'b.png')), base)
|
||||
else:
|
||||
for base in args:
|
||||
run(base, ex, tries, install='--no-install' not in sys.argv)
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |