# _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)