Files
selimaj-devandclaude df9a2587e6 Add a tool that measures emotes from a store's 3D preview
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]>
2026-09-29 16:55:08 +02:00

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()