tools/emotecraft/convert.py turns Emotecraft's binary .emotecraft files (its network packet, with the animation in player-animator's legacy format) into the Emotecraft JSON EmotecraftLoader reads. Converting a few community emotes showed the loader bent the torso the wrong way: Get the Griddy, which hunches forward, arched back. Emotecraft bends limbs the other way from us, but not the torso, whose bend is anchored at the hips here and so already turned round. The loader now keeps the torso's bend sign, and gives the whole player's bend to the torso, as Emotecraft does. fit.py writes torso bends to match, so re-exported captures look the same. Bumps the version to 0.1.1 for the fix. Co-Authored-By: Claude Opus 5.5 <[email protected]>
168 lines
7.2 KiB
Python
168 lines
7.2 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":
|
|
# Limbs bend the other way in Emotecraft; the torso agrees with ours.
|
|
return degrees if bone == "body" else -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()
|