Re-cut the half-resolution sprites at full detail
Audited every sprite by detecting the art-pixel BLOCK SIZE each one was actually drawn at. The set splits cleanly in two: 29 sprites are 112 art-pixels tall on a 2-pixel block — literal 2x upscales, carrying half the detail — against 200-224 for everything drawn since. z_f3 and z_bw are in the first group, z_chairt and z_mach2 in the second, exactly as Oliver called it. Regenerated at full detail so far: the player's idle pair, toss and smash (he is on screen the whole game, and his own run and death frames were already fine, so he was mismatched against himself), plus the two wounded shamblers and the runner's three frames — where the wound skins were the worse case, since their newly drawn second frames were full-detail and their A frames were not. New pipeline step for it, assets/_fine.py: detect the generator's art-pixel grid and snap to it, common-scale in ART pixels off a chosen anchor frame (so a smash with the arm overhead stays legitimately taller), quantise back to the sheet's own palette, and de-fringe the key colour twice — the generator does not always return a pure magenta and the halo survives as a rim on a green court. Still half-resolution, in order of how much they are seen: the brute's seven wound stages, the seven opponent frames, the racket zombie's two wound skins, and x_ump / x_boyrun1 / x_boycarry2 / x_boycarry3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,108 @@
|
||||
# _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):
|
||||
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.
|
||||
for _ in range(2):
|
||||
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):
|
||||
a = key(Image.open(sheet))
|
||||
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]
|
||||
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)
|
||||
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
@@ -97,6 +97,6 @@
|
||||
<p id="build"></p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=66"></script>
|
||||
<script type="module" src="main.js?v=67"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -155,7 +155,7 @@ export function proj(x, y, z) {
|
||||
|
||||
// ---------------------------------------------------------------- sprites ----
|
||||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||||
const SPRV = 60; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||||
const SPRV = 61; // 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',
|
||||
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
||||
|
||||