diff --git a/.gitignore b/.gitignore
index 6b6b1fb..4c38cdd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
.env
scores.json
scores.json.tmp
+assets/_gen/
+assets/_refcheck/
+assets/__pycache__/
diff --git a/assets/_regen.py b/assets/_regen.py
index 1ce8b81..25a8447 100644
--- a/assets/_regen.py
+++ b/assets/_regen.py
@@ -14,7 +14,7 @@
# Failures leave the sprites alone and print why, so a batch can just be re-run.
import json, os, shutil, subprocess, sys
import numpy as np
-from PIL import Image
+from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen')
@@ -49,9 +49,17 @@ CANVAS = (
"MAGENTA AND PINK ARE THE BACKGROUND COLOUR AND MAY NOT APPEAR ANYWHERE ON A FIGURE: no magenta "
"clothing, no pink skin, and racket strings are drawn as fine pale grey-white crosshatch over "
"the figure's own body or clothes — NEVER left as the background colour showing through. "
- "Keep at least 200 "
- "pixels of empty magenta between figures and at least 150 pixels of empty magenta at every edge "
- "of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text. "
+ # MARGINS ARE A PROPORTION, NEVER A PIXEL COUNT. This asked for "150 pixels at every edge" for
+ # months and the brute came back clipped in three rolls out of four: the generator chooses its
+ # own output resolution, so an absolute pixel margin means nothing to it — it composed figures at
+ # 92% of a canvas height it had picked itself. The reference sheet shows 29% margins and that is
+ # ignored too, so this is the one place the rule can be stated. Fractions it can act on.
+ "THE FIGURES MUST BE SMALL IN THE FRAME. Draw them at about two thirds of the canvas height, "
+ "centred, with a wide band of empty magenta above the highest point of the tallest figure "
+ "(including any raised racket), below the feet, and beyond the outermost figure on each side — "
+ "roughly a sixth of the canvas on every side. No figure, limb, hair or racket may touch or run "
+ "off an edge, and the figures must be clearly separated from each other by empty magenta, never "
+ "overlapping or touching. No shadow, no ground line, no text. "
"The canvas is a WIDE LANDSCAPE STRIP and every figure stands side by side in ONE SINGLE HORIZONTAL "
"ROW on the same ground line — never a grid, never two rows, never stacked."
)
@@ -92,12 +100,46 @@ def batches():
return json.load(f)
# ---------------------------------------------------------------- helpers ----
+def fill_holes(im):
+ """A REFERENCE MAY NOT SHOW A RACKET AS A MAGENTA HOLE. Older sprites were cut before _fine.py
+ learned to keep enclosed background, so their string beds are TRANSPARENT — o_smash has 3425 such
+ pixels, o_toss 1786, z_c1 675. Pasted onto the magenta reference canvas those holes come back as
+ magenta, so every one of those sheets showed the generator a racket whose strings ARE the backdrop
+ colour, while the prompt spent three sentences forbidding exactly that. It copied the picture,
+ which is what this project already knows a model does. Fixing it in _fine.py only cleaned the way
+ IN; this is the way back OUT.
+
+ Filled as CROSSHATCH, not as a flat patch, for the same reason: the prompt asks for "fine pale
+ grey-white crosshatch" and a reference that shows it beats a sentence that describes it.
+
+ ponytail: every enclosed hole gets the string bed, including the odd gap between an arm and a
+ torso — so a body can show a small woven patch on its hip. It is a handful of pixels against a
+ racket-sized win, and the alternative is classifying holes by shape. Revisit only if a roll comes
+ back with fabric where skin should be."""
+ a = np.array(im)
+ t = a[..., 3] < 128
+ # PAD BEFORE FLOODING. _fine.py can seed at (0,0) because its sheet has a wide magenta border;
+ # an installed SPRITE is cropped tight, so the figure touches all four edges and its surround is
+ # split into pockets that (0,0) cannot reach. Seeding there alone called 10,732 px of ordinary
+ # background a "hole" and painted the whole bounding box with string bed.
+ m = Image.fromarray(np.where(np.pad(t, 1, constant_values=True), 255, 0).astype('uint8'), 'L').copy()
+ ImageDraw.floodfill(m, (0, 0), 128) # .copy() — see _fine.py, fromarray is read-only
+ holes = t & (np.array(m)[1:-1, 1:-1] == 255)
+ if not holes.any(): return im
+ a[holes] = (186, 186, 186, 255)
+ y, x = np.indices(a.shape[:2])
+ for step, tone in ((3, 148), (4, 214)): # two offset threads = a woven bed, not a grey disc
+ w = holes & ((x + y * (step - 2)) % step == 0)
+ a[w, 0] = a[w, 1] = a[w, 2] = tone
+ return Image.fromarray(a, 'RGBA')
+
+
def ref_sheet(names, out, scale=2.0, gap=220, pad=150, frames=1):
"""THE REFERENCE'S ASPECT IS THE ANSWER'S ASPECT. One tall reference figure makes a portrait
reference canvas, and the generator then answers in portrait — it laid four figures out as a 2x2
GRID, which _fine.py cannot cut (it segments columns). So the canvas is widened to a landscape
strip with room for every frame that was asked for, and the figures are spread across it."""
- ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in names]
+ ims = [fill_holes(Image.open(os.path.join(HERE, n + '.png')).convert('RGBA')) for n in names]
ims = [i.resize((max(1, int(i.width * scale)), max(1, int(i.height * scale))), Image.NEAREST) for i in ims]
w = sum(i.width for i in ims) + gap * (len(ims) - 1) + pad * 2
h = max(i.height for i in ims) + pad * 2
@@ -450,9 +492,54 @@ def audit(B, names):
print('\nFAILS THE GATE:', ', '.join(bad) if bad else 'none')
return not bad
+# --verify asks the question no gate here has ever asked: IS THE ART ALREADY ON DISK WHAT IT CLAIMS
+# TO BE? Every check in this file fires on a NEW roll and says nothing about installed frames — and a
+# chained batch draws its reference from installed frames. So a stage that was wrong the day it
+# shipped becomes the reference for the stage below it, and the stage below that, silently and
+# forever. The brute's z_brw1fall row is the proof: four copies of ONE standing lean zombie sold as a
+# fat man collapsing, differing from each other by 0.011-0.055 where the sequence they were posed
+# from differs by 0.53-0.89. Nothing noticed for as long as it has been in the repo.
+#
+# Two questions, both reusing measures that were already here:
+# DUP does this group of frames differ from each other at all? A pose sequence that does not is
+# one drawing pasted N times. Real sequences score 0.5+; a stride pair, the tightest legal
+# case, still clears 0.12 whole-body.
+# PAL is it painted in the anchor's colours? Same dominant-colour distance the palette gate uses.
+DUP_MIN = 0.12
+def verify(B, names):
+ print(f"{'batch':16s} {'frames':>6s} {'DUP':>7s} {'PAL':>6s} verdict")
+ bad = []
+ for n in names:
+ spec = B[n]
+ frames = [s for s in spec['sprites'] if os.path.exists(os.path.join(HERE, s + '.png'))]
+ if len(frames) < len(spec['sprites']):
+ print(f'{n:16s} {"":>6s} {"":>7s} {"":>6s} MISSING art'); bad.append(n); continue
+ paths = [os.path.join(HERE, f + '.png') for f in frames]
+ dup = max((_d(*_masks(a, b)[2:4]) for i, a in enumerate(paths) for b in paths[i + 1:]),
+ default=1.0)
+ anchor = spec.get('palette_anchor')
+ pal = 0.0
+ if anchor and os.path.exists(os.path.join(HERE, anchor + '.png')):
+ ua, _ = dom_colours(os.path.join(HERE, anchor + '.png'))
+ for p in paths:
+ uo, wo = dom_colours(p)
+ if len(ua) and len(uo):
+ pal = max(pal, float((np.sqrt(((uo[:, None, :] - ua[None, :, :]) ** 2).sum(2))
+ .min(1) * wo).sum()))
+ why = ('ONE DRAWING REPEATED' if dup < DUP_MIN else
+ f'palette off {anchor}' if pal > spec.get('palette_max', 10) else None)
+ print(f'{n:16s} {len(frames):6d} {dup:7.3f} {pal:6.1f} {why or "ok"}')
+ if why: bad.append(n)
+ print('\nDEAD ART (regenerate before anything is drawn from it):',
+ ' '.join(bad) if bad else 'none')
+ return not bad
+
if __name__ == '__main__':
args = sys.argv[1:]
B = batches()
+ if '--verify' in args:
+ names = [a for a in args if not a.startswith('--')] or list(B)
+ sys.exit(0 if verify(B, names) else 1)
if '--audit' in args:
names = [a for a in args if not a.startswith('--')] or [k for k, v in B.items() if v.get('stride')]
sys.exit(0 if audit(B, names) else 1)
diff --git a/assets/_zsheet.py b/assets/_zsheet.py
new file mode 100644
index 0000000..0a4cff7
--- /dev/null
+++ b/assets/_zsheet.py
@@ -0,0 +1,120 @@
+# _zsheet.py — ONE generated multi-row sheet -> a whole zombie family of sprites.
+#
+# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --out assets/_refcheck/sprites
+# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --install
+#
+# WHY THIS EXISTS. _regen.py generates a family as nine separate sheets, and nine separate rolls is
+# nine chances for the character to drift — which is exactly what it did, all day. Asking for the
+# WHOLE family in one image fixes that by construction: one roll, one character, one light, one
+# palette. What was missing was the way back down — a generated sheet is 9 rows of figures and
+# _fine.py cuts a single row. This is that step, and nothing else.
+#
+# TWO THINGS ARE GLOBAL, and both were bugs when they were not:
+# SCALE _fine.py anchors one scale per sheet, so nine bands would each be scaled to their own
+# tallest figure — the serve strike would come out the same height as the idle stance and
+# every pose would pop, because the game sizes a sprite off its own PNG height. The scale
+# is taken ONCE, from the standing frame of the play row, and every band uses it.
+# PALETTE taken once from the largest figure in the whole sheet, so a wound row cannot quantise
+# itself onto a slightly different set of colours than the healthy row it follows.
+import os, sys
+import numpy as np
+from PIL import Image
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _fine import key, block_size, frames, split_piece # the proven keyer and column cutter
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TARGET_H = 224
+MIN_BAND = 40
+
+# The family layout, exactly config.famFrames' order. The wound rows carry no separate stance —
+# the shuffle doubles as one — which is why they are 3 and not 4.
+def layout(k):
+ P = ['bounce', 'toss', 'serve']
+ F = ['fall1', 'fall2', 'fall3', 'rise']
+ rows = [[f'z_{k}1', f'z_{k}2', f'z_{k}3', f'z_{k}4'],
+ [f'z_{k}{s}' for s in P],
+ [f'z_{k}{s}' for s in F]]
+ for st in (1, 2):
+ rows += [[f'z_{k}w{st}', f'z_{k}w{st}b', f'z_{k}w{st}s'],
+ [f'z_{k}w{st}{s}' for s in P],
+ [f'z_{k}w{st}{s}' for s in F]]
+ return rows
+
+
+def bands(a):
+ """Row bands = runs of rows that contain ink. Rows are what a grid has and _fine.py refuses;
+ here they are the point, so they are found first and each band is handed over as its own row."""
+ rows = (a[..., 3] > 0).any(1)
+ out, s = [], None
+ for y, v in enumerate(rows):
+ if v and s is None: s = y
+ elif not v and s is not None:
+ if y - s >= MIN_BAND: out.append((s, y))
+ s = None
+ if s is not None and len(rows) - s >= MIN_BAND: out.append((s, len(rows)))
+ return out
+
+
+def cut(sheet, fam, outdir):
+ want = layout(fam)
+ src = Image.open(sheet).convert('RGB')
+ a = key(src)
+ # THE ENCLOSED MAGENTA IS THE RACKET, AND HERE IT IS MEANT TO GO. _fine.py repaints magenta that
+ # is trapped inside a figure as pale grey, because the generator used to fill string beds with
+ # the backdrop colour BY ACCIDENT and keying them left men holding an empty hoop. This sheet is
+ # the opposite case: the prompt asks for the string bed to be exactly the background magenta so
+ # it cuts to transparency, and it obeyed. So every magenta pixel goes, connected to the border
+ # or not. That is safe only because nothing else on the figure is magenta — check a new sheet.
+ n = np.array(src).astype(int)
+ lo = np.minimum(n[..., 0], n[..., 2])
+ a[(lo > 140) & (n[..., 1] < lo - 55), 3] = 0
+ b = block_size(a)
+ bs = bands(a)
+ if len(bs) != len(want):
+ sys.exit(f'ERROR: found {len(bs)} row bands, expected {len(want)}')
+
+ crops = []
+ for (y0, y1), row in zip(bs, want):
+ band = a[y0:y1]
+ spans = frames(band, [], len(row))
+ if len(spans) != len(row):
+ sys.exit(f'ERROR: row {row[0]}: found {len(spans)} figures, expected {len(row)}')
+ for x0, x1 in spans:
+ sub = band[:, x0:x1]
+ ys = np.where((sub[..., 3] > 0).any(1))[0]
+ xs = np.where((sub[..., 3] > 0).any(0))[0]
+ c = sub[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
+ if split_piece(c):
+ sys.exit(f'ERROR: a frame in row {row[0]} holds two stacked bodies')
+ crops.append(c)
+
+ flat = [n for row in want for n in row]
+ # ONE scale, off the play row's standing frame; ONE palette, off the biggest figure anywhere.
+ k = TARGET_H / (crops[0].shape[0] / b)
+ big = max(crops, key=lambda c: (c[..., 3] > 0).sum())
+ px = big[big[..., 3] > 200][:, :3]
+ pal = np.unique((px // 8) * 8, axis=0)
+
+ os.makedirs(outdir, exist_ok=True)
+ for c, name in zip(crops, flat):
+ h, w = c.shape[:2]
+ snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX)
+ 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
+ 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)
+ Image.fromarray(arr.astype('uint8'), 'RGBA').save(os.path.join(outdir, name + '.png'))
+ print(f'{len(flat)} frames, art block {b}, scale {k:.3f}, palette {len(pal)} colours -> {outdir}')
+ for (y0, y1), row in zip(bs, want):
+ hs = [Image.open(os.path.join(outdir, n + '.png')).height for n in row]
+ print(f' {row[0]:14s} {len(row)} frames heights {hs}')
+
+
+if __name__ == '__main__':
+ args = [a for a in sys.argv[1:] if not a.startswith('--')]
+ out = HERE if '--install' in sys.argv else None
+ if '--out' in sys.argv: out = sys.argv[sys.argv.index('--out') + 1]
+ cut(args[0], args[1], out or os.path.join(HERE, '_refcheck', 'sprites'))
diff --git a/assets/z_br1.png b/assets/z_br1.png
index 0a2d978..06f3b13 100644
Binary files a/assets/z_br1.png and b/assets/z_br1.png differ
diff --git a/assets/z_br2.png b/assets/z_br2.png
index 0477af5..b814c25 100644
Binary files a/assets/z_br2.png and b/assets/z_br2.png differ
diff --git a/assets/z_br3.png b/assets/z_br3.png
index 52dd159..85c4eb2 100644
Binary files a/assets/z_br3.png and b/assets/z_br3.png differ
diff --git a/assets/z_br4.png b/assets/z_br4.png
index 475b973..973dd46 100644
Binary files a/assets/z_br4.png and b/assets/z_br4.png differ
diff --git a/assets/z_brbounce.png b/assets/z_brbounce.png
index cd27266..713d94b 100644
Binary files a/assets/z_brbounce.png and b/assets/z_brbounce.png differ
diff --git a/assets/z_brfall1.png b/assets/z_brfall1.png
index c7b04df..73f1429 100644
Binary files a/assets/z_brfall1.png and b/assets/z_brfall1.png differ
diff --git a/assets/z_brfall2.png b/assets/z_brfall2.png
index 41cc54c..0e4633d 100644
Binary files a/assets/z_brfall2.png and b/assets/z_brfall2.png differ
diff --git a/assets/z_brfall3.png b/assets/z_brfall3.png
index 962a79c..2b53db8 100644
Binary files a/assets/z_brfall3.png and b/assets/z_brfall3.png differ
diff --git a/assets/z_brrise.png b/assets/z_brrise.png
index eade522..daab3fc 100644
Binary files a/assets/z_brrise.png and b/assets/z_brrise.png differ
diff --git a/assets/z_brserve.png b/assets/z_brserve.png
index a759d71..65ddad7 100644
Binary files a/assets/z_brserve.png and b/assets/z_brserve.png differ
diff --git a/assets/z_brtoss.png b/assets/z_brtoss.png
index d16410b..49c7c45 100644
Binary files a/assets/z_brtoss.png and b/assets/z_brtoss.png differ
diff --git a/assets/z_brw1.png b/assets/z_brw1.png
index ba2cc3e..9d9644e 100644
Binary files a/assets/z_brw1.png and b/assets/z_brw1.png differ
diff --git a/assets/z_brw1b.png b/assets/z_brw1b.png
index 7a76926..320f1e3 100644
Binary files a/assets/z_brw1b.png and b/assets/z_brw1b.png differ
diff --git a/assets/z_brw1bounce.png b/assets/z_brw1bounce.png
index 5853711..d874c1e 100644
Binary files a/assets/z_brw1bounce.png and b/assets/z_brw1bounce.png differ
diff --git a/assets/z_brw1fall1.png b/assets/z_brw1fall1.png
index e8d1a71..1b7aebb 100644
Binary files a/assets/z_brw1fall1.png and b/assets/z_brw1fall1.png differ
diff --git a/assets/z_brw1fall2.png b/assets/z_brw1fall2.png
index 2712bc4..1762d26 100644
Binary files a/assets/z_brw1fall2.png and b/assets/z_brw1fall2.png differ
diff --git a/assets/z_brw1fall3.png b/assets/z_brw1fall3.png
index 38c2024..2443705 100644
Binary files a/assets/z_brw1fall3.png and b/assets/z_brw1fall3.png differ
diff --git a/assets/z_brw1rise.png b/assets/z_brw1rise.png
index e168bba..d9dfd62 100644
Binary files a/assets/z_brw1rise.png and b/assets/z_brw1rise.png differ
diff --git a/assets/z_brw1s.png b/assets/z_brw1s.png
index 3930775..df8717e 100644
Binary files a/assets/z_brw1s.png and b/assets/z_brw1s.png differ
diff --git a/assets/z_brw1serve.png b/assets/z_brw1serve.png
index 70e4567..050a432 100644
Binary files a/assets/z_brw1serve.png and b/assets/z_brw1serve.png differ
diff --git a/assets/z_brw1toss.png b/assets/z_brw1toss.png
index 652bf55..62b924c 100644
Binary files a/assets/z_brw1toss.png and b/assets/z_brw1toss.png differ
diff --git a/assets/z_brw2.png b/assets/z_brw2.png
index ba2cc3e..83c94bb 100644
Binary files a/assets/z_brw2.png and b/assets/z_brw2.png differ
diff --git a/assets/z_brw2b.png b/assets/z_brw2b.png
index 7a76926..ebe3507 100644
Binary files a/assets/z_brw2b.png and b/assets/z_brw2b.png differ
diff --git a/assets/z_brw2bounce.png b/assets/z_brw2bounce.png
index 64efbb7..fdaedec 100644
Binary files a/assets/z_brw2bounce.png and b/assets/z_brw2bounce.png differ
diff --git a/assets/z_brw2fall1.png b/assets/z_brw2fall1.png
index 920a5c5..37fa161 100644
Binary files a/assets/z_brw2fall1.png and b/assets/z_brw2fall1.png differ
diff --git a/assets/z_brw2fall2.png b/assets/z_brw2fall2.png
index 86aa31b..7aaa7ca 100644
Binary files a/assets/z_brw2fall2.png and b/assets/z_brw2fall2.png differ
diff --git a/assets/z_brw2fall3.png b/assets/z_brw2fall3.png
index 240079d..c7e5cef 100644
Binary files a/assets/z_brw2fall3.png and b/assets/z_brw2fall3.png differ
diff --git a/assets/z_brw2rise.png b/assets/z_brw2rise.png
index 859c892..cc75f59 100644
Binary files a/assets/z_brw2rise.png and b/assets/z_brw2rise.png differ
diff --git a/assets/z_brw2s.png b/assets/z_brw2s.png
index 3930775..6b95d7f 100644
Binary files a/assets/z_brw2s.png and b/assets/z_brw2s.png differ
diff --git a/assets/z_brw2serve.png b/assets/z_brw2serve.png
index d2dd036..f1a683b 100644
Binary files a/assets/z_brw2serve.png and b/assets/z_brw2serve.png differ
diff --git a/assets/z_brw2toss.png b/assets/z_brw2toss.png
index 81807d2..ea6cd1c 100644
Binary files a/assets/z_brw2toss.png and b/assets/z_brw2toss.png differ
diff --git a/index.html b/index.html
index 70a2f03..313f7c9 100644
--- a/index.html
+++ b/index.html
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
-
+