THE STRIDE GATE: a walk cycle is one drawing with the legs moved, and now something checks

Oliver, on the sprites, again: "when people on a tennis court walk or run their
legs move... this pattern is almost always failing." It has failed repeatedly
because NOTHING IN THE PIPELINE MEASURED IT. Frame count, magenta background,
clipped edges and detail density all pass cleanly on two unrelated
illustrations of the same character, which is exactly what the generator
produces when asked for "the same frame with the other leg forward".

Three numbers per declared pair, silhouette only, on the feet-aligned centred
overlay drawStatic actually draws:
  HGT  height ratio — off 1.00 means the body grows and shrinks once per step
  TOP  difference ABOVE the hip — near zero for a real pair: same man, same camera
  LEG  difference BELOW it — this is the step
Calibrated on the pairs that read (brute wound lurch 1.00/0.000/0.638, shambler
1.00/0.070/0.520) against the ones that do not (TOP 0.47-0.85, or LEG 0.09).
Opt-in per batch via `stride: [[i, j]]`, because the player's four-frame RUN
sits at TOP 0.23 and is right — a run swings the arms too.

`--audit` runs the same gate over the sprites ALREADY INSTALLED and exits
non-zero on a failure: a gate that only fires on new rolls says nothing about
the art already in the game, which is where this was noticed. Five of the six
racket pairs fail it today.

And the kit gate, which already existed and was never switched on here: Oliver
spotted the headless men's trousers had changed colour. Measured — the headless
set's near-white fraction DOUBLES (9.5 -> 19.5) where losing a head only
accounts for ~1.3 of that, and the wounded swing drifted the other way (shorts
60.5 against its own stage's 85). palette_ref + a per-batch tolerance now catch
all three drifted sets and pass the one that looked right.

Fixed while in there, mine from the last commit: the wounded stride stepped off
walkT, a wall clock, while the healthy one steps off ground covered — the exact
foot-slide this project banned for the heavy bodies.

No art regenerated yet: the gate and the audit come first, so a re-roll can be
told from a lucky one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 10:09:31 +02:00
1 parent 19ee62067a
commit 1c405f045f
5 files changed
+138 -9

No files matched your search

+67 -1
View File
@@ -149,6 +149,48 @@ def kit_off(outs, ref_name, tol=(12, 10, 1.5)):
f'(tolerance {tol})')
return None
# ---- THE STRIDE GATE. A walk cycle is ONE DRAWING WITH THE LEGS MOVED. The generator does not know
# that: asked for "frame 2, then the same with the other leg forward", it draws two independent
# illustrations of the character — different torso twist, different arm positions, different camera
# distance — and every other check in this file passes on that happily (the frame count is right, the
# background is magenta, nothing touches an edge, the detail is fine). So the failure has been
# INVISIBLE and it has shipped repeatedly: the turned CPU's z_c1/z_c2 differ by 0.85 above the hip.
#
# Three numbers, silhouette only, on a feet-aligned centred overlay — which is how drawStatic puts
# them on screen, so this measures what the eye actually sees:
# HGT height ratio. drawStatic sizes every frame by its own PNG height, so anything off 1.00 is a
# body that GROWS AND SHRINKS once per step (the shambler's walk does, by 8%).
# TOP how much differs ABOVE the hip. Near zero for a real stride pair — same man, same camera.
# LEG how much differs BELOW it. This is the step. Near zero means the legs never moved.
# Calibrated on the pairs that read: the brute's wound lurch is (1.00, 0.000, 0.638) and the
# shambler's is (1.00, 0.070, 0.520); the failures sit at TOP 0.47-0.85 or LEG 0.09. The player's
# four-frame RUN sits at TOP 0.23 and is fine — a run swings the arms too — which is why this gate is
# opt-in per batch (`stride: [[i, j]]`) rather than applied to everything with two frames in it.
STRIDE_H, STRIDE_TOP, STRIDE_LEG, STRIDE_HIP = 0.04, 0.20, 0.30, 0.62
def stride_stats(pa, pb):
a, b = Image.open(pa).convert('RGBA'), Image.open(pb).convert('RGBA')
w, h = max(a.width, b.width), max(a.height, b.height)
def m(im):
c = Image.new('RGBA', (w, h)); c.paste(im, ((w - im.width) // 2, h - im.height))
return np.array(c)[..., 3] > 110
ma, mb = m(a), m(b)
hip = int(h * STRIDE_HIP)
d = lambda x, y: round(float((x ^ y).sum() / max(1, (x | y).sum())), 3)
return round(a.height / max(1, b.height), 3), d(ma[:hip], mb[:hip]), d(ma[hip:], mb[hip:])
def stride_off(outs, pairs):
for i, j in pairs:
hgt, top, leg = stride_stats(outs[i], outs[j])
why = ('body pulsates' if abs(hgt - 1) > STRIDE_H else
'whole body redrawn, not a leg swap' if top > STRIDE_TOP else
'the legs do not move' if leg < STRIDE_LEG else None)
if why:
return (f'frames {i}/{j} are not one body with the legs moved — {why} '
f'(height x{hgt:.2f}, upper {top:.3f}, legs {leg:.3f}; '
f'want ~1.00 / <={STRIDE_TOP} / >={STRIDE_LEG})')
return None
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
@@ -231,7 +273,10 @@ def run(name, spec, dry=False):
if sum(after) / len(after) < sum(before) / len(before) * bar:
print(f'[{name}] REJECTED: not finer than what it replaces {before} -> {after}'); return False
if spec.get('palette_ref'):
bad = kit_off(outs, spec['palette_ref'])
bad = kit_off(outs, spec['palette_ref'], tuple(spec.get('palette_tol', (12, 10, 1.5))))
if bad: print(f'[{name}] REJECTED: {bad}'); return False
if spec.get('stride'):
bad = stride_off(outs, spec['stride'])
if bad: print(f'[{name}] REJECTED: {bad}'); return False
for o, s in zip(outs, sprites):
if spec.get('check', 'finer') == 'finer': colour_match(o, s)
@@ -239,9 +284,30 @@ def run(name, spec, dry=False):
print(f'[{name}] installed. detail {before} -> {after}')
return True
# --audit runs the stride gate over the sprites ALREADY INSTALLED, which is the other half of it
# being useful: a gate that only fires on new rolls says nothing about the art already in the game,
# and the art already in the game is where this was first noticed.
def audit(B, names):
print(f"{'':34s} {'HGT':>6s} {'TOP':>6s} {'LEG':>6s} verdict")
bad = []
for n in names:
for i, j in B[n].get('stride', []):
a, b = (os.path.join(HERE, B[n]['sprites'][k] + '.png') for k in (i, j))
if not (os.path.exists(a) and os.path.exists(b)): continue
hgt, top, leg = stride_stats(a, b)
why = stride_off([a, b], [[0, 1]])
label = f"{n} {B[n]['sprites'][i]}/{B[n]['sprites'][j]}"
print(f'{label:34s} {hgt:6.2f} {top:6.3f} {leg:6.3f} {"FAIL" if why else "ok"}')
if why: bad.append(label)
print('\nFAILS THE GATE:', ', '.join(bad) if bad else 'none')
return not bad
if __name__ == '__main__':
args = sys.argv[1:]
B = batches()
if '--audit' in args:
names = [a for a in args if not a.startswith('--')] or [k for k, v in B.items() if v.get('stride')]
sys.exit(0 if audit(B, names) else 1)
if not args or '--list' in args:
for k, v in B.items(): print(f'{k:16s} {len(v["sprites"]):2d} frames {", ".join(v["sprites"])}')
sys.exit()