Exact touchdown, the boy finishes his shift, healing announces itself, 4K heads
Five of Oliver's six (the sixth is a play-mode discussion): - THE DOUBLE FAULTS WERE FRAME QUANTIZATION: the bounce registered a full integration step after true contact — ~0.75 m of travel at serve pace — so serves aimed half a metre safe were judged long. Classic enforces the service box, the match only the court, hence 'zombie tennis does not'. The bounce now rewinds to y=0 before anything reads the position. Measured in-pane: 30/40 faults on good taps before, 0/40 after (mistimed and smudged taps also 0/40; aimed hard serves keep their scatter risk). Also: the strike flick dead zone is 44 px so a smudged tap stays a tap, and the aim base clamps 0.18 m inside the lines — scatter, not the aim, is what faults. - The mop beat holds the walk-on until the boy actually LEAVES the court (ordered off at beat end, 6 s cap). - A match game win that restores blood shows the duel's WOUNDS TREATED popup; full-blood wins stay silent. - The opponent walk-on card holds 4.0 s (was 2.4). - Card heads are cut from the 4K sheets at 144 px (assets/_heads.py, z_<fam>head.png, HEAD_CROP stays as fallback) — the old 65 px crops were the 'very low res' heads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,95 @@
|
||||
# _heads.py — cut the CARD HEAD ICONS from the 4K family sheets.
|
||||
#
|
||||
# uv run --with pillow --with numpy python assets/_heads.py # build all -> _refcheck/heads/
|
||||
# uv run --with pillow --with numpy python assets/_heads.py --install # -> assets/z_<fam>head.png
|
||||
#
|
||||
# WHY. The walk-on card first cropped heads out of the INSTALLED 224 px frames (config.HEAD_CROP):
|
||||
# a ~65 px source head drawn at 144 device px is mush, and the oldest families (sh/bb/st/ru) were
|
||||
# the mushiest — "the first heads in the roster are very low res". The 4K sheets in _refcheck have
|
||||
# the same heads at ~4x the pixels, so the icon is cut THERE and downscaled once, cleanly.
|
||||
# drawCard prefers z_<fam>head and degrades to the HEAD_CROP crop, so a family whose 4K sheet is
|
||||
# gone (they are gitignored scratch) simply keeps the old icon. Re-run after re-rolling a family.
|
||||
import os, sys
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _fine
|
||||
from _zsheet import bands
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRATCH = os.path.join(HERE, '_refcheck')
|
||||
OUT_H = 144 # the card draws the icon at CARD_H-14 = 48 logical px; RES caps at 3 -> 144 device
|
||||
|
||||
FAMS = ['br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma']
|
||||
# head depth as a fraction of the standing figure, where the shoulder-widening rule fails:
|
||||
# the brute's shoulders start high, the keeper's hat brim is wider than his face, the champion
|
||||
# wears a headwrap, and the mascot's head IS partly the ball. Same knowledge as HEAD_CROP's note.
|
||||
DEPTH = {'br': 0.27, 'gk': 0.30, 'ch': 0.28, 'ma': 0.37}
|
||||
|
||||
|
||||
def head_box(a):
|
||||
"""Head region of one keyed figure: top of the silhouette down to the shoulder widening."""
|
||||
op = a[..., 3] > 0
|
||||
ys = np.nonzero(op.any(1))[0]
|
||||
y0, y1 = ys.min(), ys.max()
|
||||
h = y1 - y0 + 1
|
||||
width = op.sum(1)
|
||||
med = np.median(width[y0:y0 + max(6, h // 16)][width[y0:y0 + max(6, h // 16)] > 0])
|
||||
yS = None
|
||||
for y in range(y0 + h // 20, y1):
|
||||
if width[y] > 1.9 * med: yS = y; break
|
||||
yS = yS or y0 + int(h * 0.2)
|
||||
band = op[y0:yS]
|
||||
xs = np.nonzero(band.any(0))[0]
|
||||
pad = max(3, h // 60)
|
||||
return (max(0, xs.min() - pad), max(0, y0 - pad), xs.max() + pad, yS + pad)
|
||||
|
||||
|
||||
def build(install=False):
|
||||
outdir = HERE if install else os.path.join(SCRATCH, 'heads')
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
for k in FAMS:
|
||||
sheet = os.path.join(SCRATCH, f'fam_{k}.png')
|
||||
if not os.path.exists(sheet):
|
||||
print(f'{k}: no 4K sheet — the card keeps its HEAD_CROP fallback'); continue
|
||||
a = _fine.key(Image.open(sheet))
|
||||
bs = bands(a)
|
||||
band = a[bs[0][0]:bs[0][1]]
|
||||
cols = (band[..., 3] > 0).any(0)
|
||||
x0 = np.nonzero(cols)[0][0] # first figure of the play row
|
||||
gap = 0; x1 = x0
|
||||
for x in range(x0, len(cols)):
|
||||
gap = gap + 1 if not cols[x] else 0
|
||||
if gap > 30: x1 = x - gap; break
|
||||
else: x1 = len(cols)
|
||||
fig = band[:, x0:x1]
|
||||
ys = np.nonzero((fig[..., 3] > 0).any(1))[0]
|
||||
fh = ys.max() - ys.min() + 1
|
||||
bx0, by0, bx1, by1 = head_box(fig)
|
||||
if k in DEPTH: # hand depth beats the widening rule here
|
||||
by1 = ys.min() + int(fh * DEPTH[k])
|
||||
band2 = (fig[..., 3] > 0)[by0:by1]
|
||||
xs = np.nonzero(band2.any(0))[0]
|
||||
bx0, bx1 = max(0, xs.min() - 5), xs.max() + 5
|
||||
crop = fig[by0:by1, bx0:bx1]
|
||||
im = Image.fromarray(crop, 'RGBA')
|
||||
s = OUT_H / im.height
|
||||
im = im.resize((max(1, round(im.width * s)), OUT_H), Image.BOX)
|
||||
# quantise to the crop's own palette so the icon stays flat pixel art, not a soft photo
|
||||
arr = np.array(im).astype(int)
|
||||
solid = arr[..., 3] > 110
|
||||
px = crop[crop[..., 3] > 200][:, :3]
|
||||
pal = np.unique((px // 8) * 8, axis=0)
|
||||
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)
|
||||
out = os.path.join(outdir, f'z_{k}head.png')
|
||||
Image.fromarray(arr.astype('uint8'), 'RGBA').save(out)
|
||||
print(f'{k}: {out} {arr.shape[1]}x{arr.shape[0]}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
build('--install' in sys.argv)
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |