Files
splatter-tennis/assets/_fine.py
T
wuwiandClaude Opus 5 f6dfc57a06 Draw the wind-up from the photographed serve, not from prose
Oliver's idea, and it is the fix for the process as much as for the frames: give
the generator the real serve sequence as an IMAGE and let it copy the poses,
instead of describing a pose in prose and re-rolling until it lands. One
generation — a composite of his five photographed frames over two of our sprites
— returned a magenta row of the whole serve in the game's own character and kit.
The four stages are sliced straight out of it, anchored on the standing figure.

_fine.py gets --defringe: its two de-fringe passes each eat a one-pixel line, and
a racket frame IS a one-pixel line — that is what had been leaving the men holding
a grip and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:00:56 +02:00

114 lines
5.2 KiB
Python

# _fine.py — turn a generated sheet into FULL-RESOLUTION sprites.
#
# _slice.py resamples with NEAREST, which is right when the generator's art-pixel grid is coarse but
# shreds fine detail at a non-integer ratio. This one instead:
# 1. detects the art-pixel BLOCK SIZE the generator actually drew at, and snaps to that grid
# (one output pixel per art pixel — no half-blocks, no broken outlines),
# 2. common-scales the sheet to the target height with a box filter,
# 3. quantises every pixel back to the sheet's own palette, so the result is flat pixel art
# rather than a blurred photo of pixel art.
# Usage: _fine.py sheet.png out1.png out2.png ... [--h 224] [--cut X,Y,...]
import sys
from PIL import Image
import numpy as np
TARGET_H = 224
KEY_D2 = 60 * 60
MIN_GAP = 12
MIN_BLOB = 60
def key(im, defringe=2):
a = np.array(im.convert('RGBA'))
bg = a[0, 0, :3].astype(int)
d = ((a[..., :3].astype(int) - bg) ** 2).sum(2)
magenta = np.minimum(a[..., 0], a[..., 2]).astype(int) > a[..., 1].astype(int) + 40
a[(d < KEY_D2) | magenta] = 0
# De-fringe. The generator does not always give you a pure key colour, and whatever it does give
# bleeds a halo one pixel into the sprite — which survives as a magenta rim once the sprite is on
# a green court. Anything on the EDGE that is still leaning towards the background goes too.
# ...but each pass also eats a 1-pixel line, and a racket frame IS a 1-pixel line: two passes
# removed the rackets from a whole serve sheet and left men holding a grip. --defringe 1 (or 0)
# is for sheets whose art is that thin.
for _ in range(defringe):
solid = a[..., 3] > 0
edge = solid & ~(np.roll(solid, 1, 0) & np.roll(solid, -1, 0) & np.roll(solid, 1, 1) & np.roll(solid, -1, 1))
near = ((a[..., :3].astype(int) - bg) ** 2).sum(2) < KEY_D2 * 9
a[edge & near] = 0
return a
def block_size(a):
ink = a[..., 3] > 0
ys, xs = np.where(ink)
sub = a[ys.min():ys.max()+1, xs.min():xs.max()+1]
h, w = sub.shape[:2]
best = 1
for b in range(2, 13):
if h < b * 6: break
ok = tot = 0
for y in range(0, h - b, b):
for x in range(0, w - b, b):
blk = sub[y:y+b, x:x+b].reshape(-1, 4)
tot += 1
if (blk == blk[0]).all(): ok += 1
if tot and ok / tot > 0.82: best = b
return best
def frames(a, cuts):
ink = (a[..., 3] > 0)
cols = ink.any(0)
out, start, gap = [], None, 0
for x in range(len(cols)):
if cols[x]:
if start is None: start = x
gap = 0
elif start is not None:
gap += 1
if gap >= MIN_GAP: out.append((start, x - gap + 1)); start = None
if start is not None: out.append((start, len(cols)))
for c in sorted(cuts, reverse=True): # manual splits where two figures touch
for i, (x0, x1) in enumerate(out):
if x0 < c < x1: out[i:i+1] = [(x0, c), (c, x1)]; break
return out
def main(sheet, outs, target_h, cuts, anchor, defringe=2):
a = key(Image.open(sheet), defringe)
b = block_size(a)
crops = []
for x0, x1 in frames(a, cuts):
sub = a[:, x0:x1]
ys = np.where((sub[..., 3] > 0).any(1))[0]; xs = np.where((sub[..., 3] > 0).any(0))[0]
crops.append(sub[ys.min():ys.max()+1, xs.min():xs.max()+1])
if len(crops) != len(outs):
sys.exit(f'ERROR: found {len(crops)} frames, expected {len(outs)} ({sheet})')
# palette = the colours the generator actually used, on the largest frame
big = max(crops, key=lambda c: (c[..., 3] > 0).sum())
px = big[big[..., 3] > 200][:, :3]
pal = np.unique((px // 8) * 8, axis=0)
# ONE scale for the sheet, anchored on a chosen frame — the STANDING pose is the 224 reference,
# so a smash with the arm overhead stays legitimately taller (the game scales off img.height).
anchor_h = crops[anchor].shape[0] if 0 <= anchor < len(crops) else max(c.shape[0] for c in crops)
k = target_h / (anchor_h / b)
for c, out in zip(crops, outs):
h, w = c.shape[:2]
snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX)
fh = max(1, round(snap.height * k)); fw = max(1, round(snap.width * k))
im = snap.resize((fw, fh), Image.BOX)
arr = np.array(im).astype(int)
solid = arr[..., 3] > 110
flat = arr[..., :3].reshape(-1, 3)
idx = ((flat[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1)
arr[..., :3] = pal[idx].reshape(arr.shape[0], arr.shape[1], 3)
arr[..., 3] = np.where(solid, 255, 0)
Image.fromarray(arr.astype('uint8'), 'RGBA').save(out)
print(f'{out}: {fw}x{fh} (art block {b} in source)')
if __name__ == '__main__':
args = sys.argv[1:]
th, cuts, anchor = TARGET_H, [], -1
if '--h' in args: i = args.index('--h'); th = int(args[i+1]); del args[i:i+2]
if '--cut' in args: i = args.index('--cut'); cuts = [int(v) for v in args[i+1].split(',')]; del args[i:i+2]
de = 2
if '--defringe' in args: i = args.index('--defringe'); de = int(args[i+1]); del args[i:i+2]
if '--anchor' in args: i = args.index('--anchor'); anchor = int(args[i+1]); del args[i:i+2]
main(args[0], args[1:], th, cuts, anchor, de)