Files
emotes/tools/capture/fit.py
T
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

167 lines
7.1 KiB
Python

"""Turns measured rigs (frames/<name>/rig.json) into an Emotecraft emote our loader reads.
.venv/bin/python fit.py <name> [--out path.json] [--angle-tol 1] [--offset-tol 0.1]
Every channel is keyed independently, keeping only the ticks linear interpolation can't reproduce
within the tolerances. Values go through the inverse of EmotecraftLoader's conversions.
"""
import argparse
import json
from pathlib import Path
import numpy as np
from model import INDEX, Model, rest_rig, rig_to_pose
PARTS = {"root": "body", "head": "head", "body": "torso", "right_arm": "rightArm", "left_arm": "leftArm",
"right_leg": "rightLeg", "left_leg": "leftLeg"}
# EmotecraftLoader.REST_PIVOTS: limb positions in the file are absolute pivots.
REST = {"right_arm": (-5, 2, 0), "left_arm": (5, 2, 0), "right_leg": (-1.9, 12, 0.1), "left_leg": (1.9, 12, 0.1)}
CHANNELS = ["x", "y", "z", "pitch", "yaw", "roll", "bend", "axis"]
def to_file(bone, channel, value):
"""The inverse of EmotecraftLoader.convert, writing degrees."""
if channel in ("x", "y", "z"):
if bone == "root":
return value / 16 if channel == "z" else -value / 16
return value + REST.get(bone, (0, 0, 0))["xyz".index(channel)]
degrees = np.degrees(value)
if channel == "bend":
return -degrees
if bone == "root" and channel in ("pitch", "yaw"):
return -degrees
return degrees
def keep(values, tol):
"""Indices to key so linear interpolation stays within tol of every sample (Douglas-Peucker)."""
keys = {0, len(values) - 1}
def split(a, b):
if b - a < 2:
return
t = np.arange(a, b + 1)
line = values[a] + (values[b] - values[a]) * (t - a) / (b - a)
err = np.abs(values[a:b + 1] - line)
i = int(np.argmax(err))
if err[i] > tol:
keys.add(a + i)
split(a, a + i)
split(a + i, b)
split(0, len(values) - 1)
return sorted(keys)
def ground(rigs):
"""Shifts every tick vertically so the lowest foot over the whole emote is where a standing
player's feet are. The viewer offset was a guess (measure.py keeps it fixed), so the measured
height is only right up to a constant."""
model = Model()
lowest = lambda rig: max(model.pose(rig_to_pose(rig))[leg][:, 1].max() for leg in ("right_leg", "left_leg"))
shift = lowest(rest_rig()) - max(lowest(r) for r in rigs) # model Y points down
for r in rigs:
r[INDEX["root_y"]] += shift
return shift
def periodic_smooth(values, harmonics, angle):
"""A loop's channel kept to its first few Fourier harmonics: removes the fit's tick-to-tick noise
and joins the loop's seam. Returns the smoothed samples plus the loop's next sample (its start
again, a whole number of turns on for an angle that spins)."""
n = len(values)
turns = 0.0
if angle:
# How far the angle travels in one loop: nothing for a sway, whole turns for a spin.
step = (values[0] - values[-1] + np.pi) % (2 * np.pi) - np.pi
turns = values[-1] + step - values[0]
ramp = turns * np.arange(n + 1) / n
spectrum = np.fft.rfft(values - ramp[:n])
spectrum[harmonics + 1:] = 0
smooth = np.fft.irfft(spectrum, n)
return np.append(smooth, smooth[0]) + ramp
def main():
parser = argparse.ArgumentParser()
parser.add_argument("name")
parser.add_argument("--out")
parser.add_argument("--angle-tol", type=float, default=1.0, help="degrees")
parser.add_argument("--offset-tol", type=float, default=0.1, help="pixels")
parser.add_argument("--no-ground", action="store_true", help="keep the measured height")
parser.add_argument("--harmonics", type=int, default=4,
help="for a whole loop, how many Fourier harmonics each channel keeps (0 = no smoothing)")
parser.add_argument("--rig", help="read this rig file instead of frames/<name>/rig.json")
parser.add_argument("--symmetric", action="store_true",
help="for a whole loop that sways once per loop, remove its left/right lopsidedness "
"(see symmetry.py)")
parser.add_argument("--title", help="the emote's name (default: from <name>)")
parser.add_argument("--description", help="default: where it was measured from")
parser.add_argument("--author", default="Saturn capture")
args = parser.parse_args()
base = Path("frames") / args.name
data = json.loads(Path(args.rig).read_text() if args.rig else (base / "rig.json").read_text())
meta = json.loads((base / "views.json").read_text())
ticks = data["ticks"]
loop = meta.get("loop_ticks")
rigs = [np.array(t["rig"]) for t in ticks]
if args.symmetric:
if not (loop and len(rigs) == loop):
parser.error("--symmetric needs a whole loop")
from symmetry import symmetrize
rigs = list(symmetrize(rigs, sway=True))
print("made symmetric: each tick averaged with the mirror of the pose half a loop on")
if not args.no_ground:
print(f"grounded: moved {ground(rigs):+.2f} px down")
poses = [rig_to_pose(r) for r in rigs]
first = ticks[0]["tick"]
whole_loop = bool(loop) and len(poses) == loop
if whole_loop and args.harmonics:
print(f"smoothed each channel to {args.harmonics} harmonics of the {loop}-tick loop")
moves = {}
total = 0
for bone, part in PARTS.items():
for channel in CHANNELS:
raw = np.array([getattr(p[bone], channel) for p in poses], float)
angle = channel not in ("x", "y", "z")
if angle:
raw = np.unwrap(raw)
if whole_loop:
# A loop's last tick blends back into its first.
raw = periodic_smooth(raw, args.harmonics, angle) if args.harmonics else np.append(raw, raw[0])
values = np.array([to_file(bone, channel, v) for v in raw])
if np.all(np.abs(values - values[0]) < 1e-6) and abs(values[0]) < 1e-6 and channel != "x":
continue
tol = args.offset_tol if channel in ("x", "y", "z") else args.angle_tol
if bone == "root" and channel in ("x", "y", "z"):
tol /= 16
for i in keep(values, tol):
moves.setdefault(i, {}).setdefault(part, {})[channel] = round(float(values[i]), 4)
total += 1
emote = {
"version": 3,
"name": args.title or args.name.replace("_", " ").title(),
"description": args.description or f"Measured from {meta['url']}",
"author": args.author,
"emote": {
"degrees": True,
"isLoop": bool(loop),
"returnTick": 0,
"endTick": len(poses) - 1,
"stopTick": len(poses) if whole_loop else len(poses) - 1,
"moves": [{"tick": i, "easing": "LINEAR", **parts} for i, parts in sorted(moves.items())],
},
}
out = Path(args.out) if args.out else base / f"{args.name}.json"
out.write_text(json.dumps(emote, indent=2))
print(f"wrote {out}: {len(emote['emote']['moves'])} ticks keyed, {total} channel keyframes "
f"(from {len(poses)} samples, starting at captured tick {first})")
if __name__ == "__main__":
main()