tools/capture drives a store page's skin viewer in headless Firefox, stepping its clock one tick at a time and taking every tick from ten fixed cameras. It then fits our own rig to the frames by rendering the model (a Python port of LimbBend and PoseApplier) and matching outlines and colours, and writes the result as Emotecraft JSON. It measures pixels only and never reads the page's animation data. The README covers the steps, the checks and the limits. Co-Authored-By: Claude Opus 5.5 <[email protected]>
111 lines
4.3 KiB
Python
111 lines
4.3 KiB
Python
"""Left/right symmetry of a measured loop: how lopsided it is, and a symmetric version of it.
|
|
|
|
.venv/bin/python symmetry.py <name> [--rig path.json]
|
|
|
|
A mirror swaps the left and right limbs and flips every sideways channel (the hips' x, yaws, rolls and
|
|
bend directions). A pose that sways once per loop should be the mirror of itself half a loop later;
|
|
one with no sideways motion, the mirror of itself at the same moment. Whatever breaks that is
|
|
lopsidedness the fit picked up (one leg bent more, one side swinging further), not the dance.
|
|
|
|
symmetrize() averages each tick with the mirror of its partner, which keeps the sway and removes the
|
|
rest. fit.py --symmetric uses it.
|
|
"""
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from model import INDEX, RIG
|
|
|
|
SIDES = {"right_arm": "left_arm", "left_arm": "right_arm", "right_leg": "left_leg", "left_leg": "right_leg"}
|
|
ANGLES = np.array([not name.endswith(("_x", "_y", "_z")) for name in RIG])
|
|
|
|
|
|
def wrap(a):
|
|
return (a + np.pi) % (2 * np.pi) - np.pi
|
|
|
|
|
|
def mirror(rig):
|
|
"""The pose reflected through the player's middle (x -> -x). Rotations about x keep their angle and
|
|
those about y and z flip (S Rz Ry Rx S with S = diag(-1, 1, 1)); a bend direction, measured around
|
|
the limb from its front, flips too."""
|
|
out = np.empty_like(rig)
|
|
for i, name in enumerate(RIG):
|
|
part, _, channel = name.rpartition("_")
|
|
source = next((f"{SIDES[p]}{name[len(p):]}" for p in SIDES if name.startswith(p)), name)
|
|
value = rig[INDEX[source]]
|
|
out[i] = -value if channel in ("x", "yaw", "roll", "axis") else value
|
|
return out
|
|
|
|
|
|
def shifted(rigs, fraction):
|
|
"""Each channel of a loop evaluated `fraction` of a loop later, between samples too, through its
|
|
Fourier series. Angles that go round whole turns in a loop keep doing so."""
|
|
rigs = np.asarray(rigs, float)
|
|
n = len(rigs)
|
|
out = np.empty_like(rigs)
|
|
k = np.arange(n // 2 + 1)
|
|
for i in range(rigs.shape[1]):
|
|
values = np.unwrap(rigs[:, i]) if ANGLES[i] else rigs[:, i]
|
|
turns = 0.0
|
|
if ANGLES[i]:
|
|
turns = values[-1] + wrap(values[0] - values[-1]) - values[0]
|
|
ramp = turns * np.arange(n) / n
|
|
spectrum = np.fft.rfft(values - ramp) * np.exp(2j * np.pi * k * fraction)
|
|
out[:, i] = np.fft.irfft(spectrum, n) + ramp + turns * fraction
|
|
return out
|
|
|
|
|
|
def partners(rigs, sway):
|
|
"""The mirrored partner of every tick: half a loop on for a pose that sways once per loop."""
|
|
rigs = np.asarray(rigs, float)
|
|
source = shifted(rigs, 0.5) if sway else rigs
|
|
return np.array([mirror(r) for r in source])
|
|
|
|
|
|
def difference(a, b):
|
|
d = a - b
|
|
return np.where(ANGLES, wrap(d), d)
|
|
|
|
|
|
def symmetrize(rigs, sway):
|
|
"""Each tick averaged with its mirrored partner (the short way round for angles)."""
|
|
rigs = np.asarray(rigs, float)
|
|
return rigs - difference(rigs, partners(rigs, sway)) / 2
|
|
|
|
|
|
def lopsidedness(rigs, sway):
|
|
"""Per channel: how far each tick is from its mirrored partner, as (mean, rms) over the loop. The
|
|
mean is a steady lean to one side; the rms includes one side swinging further than the other."""
|
|
d = difference(np.asarray(rigs, float), partners(rigs, sway))
|
|
return d.mean(axis=0), np.sqrt((d ** 2).mean(axis=0))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("name")
|
|
parser.add_argument("--rig")
|
|
args = parser.parse_args()
|
|
path = Path(args.rig) if args.rig else Path("frames") / args.name / "rig.json"
|
|
rigs = np.array([t["rig"] for t in json.loads(path.read_text())["ticks"]])
|
|
|
|
# Which pairing fits: a sway once per loop, or none.
|
|
for sway in (True, False):
|
|
_, rms = lopsidedness(rigs, sway)
|
|
angle_rms = np.degrees(np.sqrt((rms[ANGLES] ** 2).mean()))
|
|
offset_rms = np.sqrt((rms[~ANGLES] ** 2).mean())
|
|
print(f"{'sway once per loop' if sway else 'no sway':18s}: rms {angle_rms:.1f} deg, {offset_rms:.2f} px")
|
|
|
|
sway = True
|
|
mean, rms = lopsidedness(rigs, sway)
|
|
print("\nMost lopsided channels (tick vs its mirrored partner half a loop on):")
|
|
order = np.argsort(-np.where(ANGLES, np.degrees(rms), rms * 5))
|
|
for i in order[:14]:
|
|
unit, f = ("deg", np.degrees) if ANGLES[i] else ("px", lambda v: v)
|
|
print(f" {RIG[i]:18s} steady {f(mean[i]):+6.1f} {unit}, rms {f(rms[i]):5.1f} {unit}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|