Draw the whole serve right-handed, and let the pull wind it up
The racket kept changing hands because the art disagreed with itself: p_idle, p_bounce and p_toss held it in his LEFT hand while p_smash and p_swing used his right, so every new frame copied whichever reference it was handed. The idle, the bounce pair, the toss and the four wind-up frames are all regenerated with the side named — drawn from behind, his right hand is the right-hand side of the image, and only the racket HEAD crosses behind his back. What separates the four wind-up stages is how far the racket has fallen, and that needed the racket head pinned to a body landmark per frame — above his head, beside his ear, between his shoulder blades, at the back of his thighs. Described as "further into it each time" it drew four near-identical poses twice running. They are wired late in the pull, Oliver's call: frame 1 owns the first half and the three coiled frames are packed into the top half. assets/_derive.py is deleted. Fixing generated art by mangling it afterwards is always worse than regenerating; three guards in _regen.py turn the bad rolls that used to install silently into automatic re-rolls — a portrait reference canvas (the generator answered with a 2x2 grid), a non-magenta background (a green sheet keyed away four racket heads), and a mirrored frame. The bounce pair is one drawing from one sheet now, differing only in the free hand, so BOUNCE_IDLE moved to his left side and was re-measured off the sprite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
231cd8650a
commit
bcff17812f
17 files changed
+181
-170
No files matched your search
+41
-4
@@ -32,7 +32,9 @@ STYLE = (
|
||||
"colours. Hard black outline around each figure. The background must be ONE FLAT solid magenta "
|
||||
"#FF00FF across the entire canvas — no scenery, no gradient, no vignette. 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."
|
||||
"of the canvas: no figure or limb may touch or run off an edge. 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."
|
||||
)
|
||||
|
||||
def batches():
|
||||
@@ -40,15 +42,21 @@ def batches():
|
||||
return json.load(f)
|
||||
|
||||
# ---------------------------------------------------------------- helpers ----
|
||||
def ref_sheet(names, out, scale=2.0, gap=220, pad=150):
|
||||
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 = [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
|
||||
w = max(w, int(h * 0.62 * max(frames, len(ims)))) # one landscape slot per frame asked for
|
||||
s = Image.new('RGBA', (w, h), (255, 0, 255, 255))
|
||||
step = (w - pad * 2 - sum(i.width for i in ims)) // max(1, len(ims) - 1) if len(ims) > 1 else 0
|
||||
x = pad
|
||||
for i in ims:
|
||||
s.paste(i, (x, h - pad - i.height), i); x += i.width + gap
|
||||
s.paste(i, (x, h - pad - i.height), i); x += i.width + max(gap, step)
|
||||
s.save(out)
|
||||
return out
|
||||
|
||||
@@ -60,6 +68,15 @@ def density(path):
|
||||
d = np.abs(np.diff(a[..., :3].astype(int), axis=1)).sum(2)
|
||||
return round(float(((d > 40) & solid[:, :-1]).sum() / max(1, solid.sum())), 3)
|
||||
|
||||
def not_magenta(sheet):
|
||||
"""The generator sometimes ignores the magenta and paints its own background. That was written
|
||||
off as harmless — the key uses the corner colour — right up until it returned a GREEN sheet and
|
||||
keyed away four grey-green racket heads with it, installing four men holding a grip and nothing
|
||||
else. A wrong background is now a rejected roll, because the art that dies to it is exactly the
|
||||
thin art the sprites are made of."""
|
||||
a = np.array(Image.open(sheet).convert('RGB'))[0, 0].astype(int)
|
||||
return not (a[0] > 140 and a[2] > 140 and a[1] < a[0] - 60 and a[1] < a[2] - 60)
|
||||
|
||||
def touches_edge(sheet):
|
||||
"""A CLIPPED figure, not a speck of noise: a run of 8+ ink pixels along a border. The strict
|
||||
version rejected sheets over single stray pixels and cost several regenerations."""
|
||||
@@ -74,6 +91,19 @@ def touches_edge(sheet):
|
||||
return best
|
||||
return max(run(fg[0]), run(fg[-1]), run(fg[:, 0]), run(fg[:, -1])) >= 8
|
||||
|
||||
def arm_not_left(outs):
|
||||
"""The generator MIRRORS the later frames of a serve about half the time — the coil is a strong
|
||||
silhouette and it draws it whichever way it likes, which puts the racket in the other hand
|
||||
halfway through the animation. Oliver spotted it twice. Every frame after the first must reach
|
||||
its raised arm up the LEFT-hand side, so a frame whose left half has nothing near the top (the
|
||||
arm went right instead) is a rejected roll, not something to notice later in the file browser."""
|
||||
for o in outs[1:]:
|
||||
ink = np.array(Image.open(o).convert('RGBA'))[..., 3] > 128
|
||||
h, w = ink.shape
|
||||
left = np.nonzero(ink[:, :w // 2].any(1))[0]
|
||||
if not len(left) or left.min() > 0.18 * h: return o
|
||||
return None
|
||||
|
||||
def colour_match(src, dst_name):
|
||||
"""Fit the new frame onto the palette statistics of the sprite it replaces, so a redrawn body
|
||||
does not change colour halfway through a walk cycle."""
|
||||
@@ -93,7 +123,8 @@ def run(name, spec, dry=False):
|
||||
lead = spec.get('ref') # generated for scale, thrown away after
|
||||
shown = spec.get('ref_only') or (([lead] if lead else []) + sprites)
|
||||
if spec.get('ref_only'): lead = None # ref_only sheets return exactly the frames asked for
|
||||
ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0))
|
||||
ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0),
|
||||
frames=len(sprites) + (1 if lead else 0))
|
||||
sheet = f'{SCRATCH}/{name}_hi.png'
|
||||
prompt = f"{spec['subject']} {STYLE}"
|
||||
if dry:
|
||||
@@ -103,6 +134,8 @@ def run(name, spec, dry=False):
|
||||
capture_output=True, text=True, cwd=os.path.dirname(HERE))
|
||||
if not os.path.exists(sheet):
|
||||
print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-400:]}'); return False
|
||||
if not_magenta(sheet):
|
||||
print(f'[{name}] REJECTED: background is not magenta — thin art keys away with it'); return False
|
||||
if touches_edge(sheet):
|
||||
print(f'[{name}] REJECTED: a figure runs off the canvas edge — re-run'); return False
|
||||
outs = [f'{SCRATCH}/{name}_{i}.png' for i in range(len(sprites) + (1 if lead else 0))]
|
||||
@@ -113,6 +146,10 @@ def run(name, spec, dry=False):
|
||||
print(r.stdout.strip() or r.stderr.strip())
|
||||
if r.returncode or not all(os.path.exists(o) for o in outs):
|
||||
print(f'[{name}] REJECTED: slicing failed (frames merged?) — re-run or add "cut"'); return False
|
||||
if spec.get('arm_left'):
|
||||
bad = arm_not_left(outs)
|
||||
if bad:
|
||||
print(f'[{name}] REJECTED: {bad} was drawn MIRRORED — racket in the wrong hand'); return False
|
||||
if lead: outs = outs[1:] # the scale reference is not installed
|
||||
before = [density(os.path.join(HERE, s + '.png')) for s in sprites]
|
||||
after = [density(o) for o in outs]
|
||||
|
||||
Reference in new issue
Block a user