Compare commits
7
Commits
7f8de39253
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa740ba6c | ||
|
|
c92abd4401 | ||
|
|
9d0fe6fb6b | ||
|
|
8794f3fb65 | ||
|
|
970e9aef51 | ||
|
|
0518a2793a | ||
|
|
df9a2587e6 |
@@ -17,9 +17,15 @@ A player emote library for Saturn Client, with limb bending.
|
||||
`/emotes stop` and `/emotes info`. Press F5 to see yourself. It also has a Fabric client gametest
|
||||
that creates a world and screenshots each animation from the front and side, into
|
||||
`versions/<mc>/build/run/clientGameTest/screenshots/`.
|
||||
- `tools/capture/`: a Python tool that measures an emote from a store page's 3D preview and writes
|
||||
it as Emotecraft JSON. See its README.
|
||||
- `tools/emotecraft/convert.py`: converts Emotecraft's binary `.emotecraft` files to Emotecraft JSON
|
||||
(standard-library Python).
|
||||
|
||||
Supported versions: 1.21.4 to 1.21.11, the same range as Saturn Client. Each version's Fabric API
|
||||
version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
|
||||
Supported versions: 1.21.4 to 1.21.11, 26.1 (and its hotfixes), 26.2 and 26.3. Each version's
|
||||
Fabric API version is set in `stonecutter.properties.toml`. The code uses Mojang mappings, which
|
||||
26.1+ ships as its own names, so `loom-back-compat` applies the remapping Loom for 1.21.x and the
|
||||
plain one for 26.x. 26.x builds need Java 25; Gradle downloads it when it isn't installed.
|
||||
|
||||
## Using it
|
||||
|
||||
@@ -45,8 +51,11 @@ pitch/yaw/roll in radians, bend and bend axis) for any of `ROOT` (the whole play
|
||||
|
||||
`EmotecraftLoader` follows player-animation-lib's reader: absolute part pivots become offsets,
|
||||
`body` (and `torso` before format version 3) is the whole player in blocks, angles are degrees unless
|
||||
`"degrees": false`, `turn` adds whole turns, and loops jump back to `returnTick` after `endTick`. Scale
|
||||
channels, `easingArg` and blending with the vanilla pose aren't supported yet (issue #4).
|
||||
`"degrees": false`, `turn` adds whole turns, and loops jump back to `returnTick` after `endTick`.
|
||||
Emotecraft's two-segment bends become our curved ones: a limb's bend changes sign, the torso's
|
||||
doesn't, and a bend on the whole player bends the torso. Scale channels, `easingArg` and blending
|
||||
with the vanilla pose aren't supported yet (issue #4). Binary `.emotecraft` files can be converted to
|
||||
JSON first with `tools/emotecraft/convert.py`.
|
||||
|
||||
## How it hooks in
|
||||
|
||||
@@ -107,8 +116,8 @@ version whose code is uncommented in `src/`. Switch versions with the Stonecutte
|
||||
or the `Set active project to <v>` Gradle task. Run `Reset active project` before committing, so
|
||||
the tree is committed with `vcsVersion` (1.21.11) active.
|
||||
|
||||
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17 until the
|
||||
toolchain moves. Minecraft 26.x will need that upgrade.
|
||||
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17, which builds
|
||||
26.x as well (with a Java 25 toolchain for those versions only).
|
||||
|
||||
## Publishing
|
||||
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ val testmod: SourceSet = sourceSets.create("testmod") {
|
||||
}
|
||||
|
||||
loom {
|
||||
createRemapConfigurations(testmod)
|
||||
// The testmod's mod* configurations; 26.1+ has nothing to remap, and loomx aliases them there.
|
||||
if (!loomx.isUnobfuscated) createRemapConfigurations(testmod)
|
||||
|
||||
mods {
|
||||
register("saturn_emotes") { sourceSet(sourceSets.main.get()) }
|
||||
|
||||
@@ -31,7 +31,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
* subtracted to get an offset.</li>
|
||||
* <li>Whole-player positions are in blocks with Y up, and its rotations are around the world axes
|
||||
* before Minecraft flips the model, so X, Y, pitch and yaw change sign.</li>
|
||||
* <li>Emotecraft bends the other way, so {@code bend} changes sign.</li>
|
||||
* <li>Emotecraft bends limbs the other way, so a limb's {@code bend} changes sign. The torso's doesn't:
|
||||
* ours is anchored at the hips, which already turns it round, so the two agree (a positive bend with
|
||||
* axis 0 leans forward in both).</li>
|
||||
* <li>The whole player can't bend. In Emotecraft its {@code bend} and {@code axis} bend the torso,
|
||||
* so they go to {@link Bone#BODY}.</li>
|
||||
* <li>Angles are in degrees unless {@code "degrees": false}.</li>
|
||||
* <li>{@code turn} adds whole turns to the move's angles, for spins and flips.</li>
|
||||
* <li>Looping emotes jump back to {@code returnTick} after {@code endTick}, and only loop when they
|
||||
@@ -112,8 +116,11 @@ public final class EmotecraftLoader {
|
||||
for (Map.Entry<String, Channel> axis : AXES.entrySet()) {
|
||||
JsonNode value = axes.get(axis.getKey());
|
||||
if (value != null && value.isNumber()) {
|
||||
float converted = convert(bone, axis.getValue(), value.floatValue(), degrees, turn);
|
||||
animation.keyframe(bone, axis.getValue(), tick, converted, easing);
|
||||
Channel channel = axis.getValue();
|
||||
boolean bend = channel == Channel.BEND || channel == Channel.BEND_AXIS;
|
||||
Bone target = bone == Bone.ROOT && bend ? Bone.BODY : bone;
|
||||
float converted = convert(target, channel, value.floatValue(), degrees, turn);
|
||||
animation.keyframe(target, channel, tick, converted, easing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +153,7 @@ public final class EmotecraftLoader {
|
||||
float radians = angle(value, degrees, turn);
|
||||
yield bone == Bone.ROOT && channel != Channel.ROLL ? -radians : radians;
|
||||
}
|
||||
case BEND -> -angle(value, degrees, turn);
|
||||
case BEND -> bone == Bone.BODY ? angle(value, degrees, turn) : -angle(value, degrees, turn);
|
||||
case BEND_AXIS -> angle(value, degrees, turn);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,22 @@ class EmotecraftLoaderTest {
|
||||
assertEquals(0.96f, bone(load("cry"), Bone.RIGHT_ARM, 30).bend(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheTorsoBendAndGivesItTheWholeBodysBend() throws IOException {
|
||||
// A positive torso bend at axis 0 leans forward in both, so it keeps its sign (Get the Griddy
|
||||
// leans forward with a bend of +0.4).
|
||||
Emote torso = EmotecraftLoader.load("torso",
|
||||
"{\"version\": 3, \"emote\": {\"degrees\": false, \"moves\": [{\"tick\": 0, \"torso\": {\"bend\": 0.4}}]}}");
|
||||
assertEquals(0.4f, bone(torso, Bone.BODY, 0).bend(), 1e-6);
|
||||
|
||||
// Emotecraft's whole-body bend bends the torso; the whole player can't bend.
|
||||
Emote body = inline("{\"tick\": 0, \"body\": {\"bend\": 0.4, \"axis\": 0.2, \"pitch\": 0.1}}", "\"degrees\": false, ");
|
||||
assertEquals(0.4f, bone(body, Bone.BODY, 0).bend(), 1e-6);
|
||||
assertEquals(0.2f, bone(body, Bone.BODY, 0).bendAxis(), 1e-6);
|
||||
assertEquals(0, bone(body, Bone.ROOT, 0).bend(), 1e-6);
|
||||
assertEquals(-0.1f, bone(body, Bone.ROOT, 0).pitch(), 1e-6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsTheWholeBodyToRoot() throws IOException {
|
||||
// front_flip turns "body" pitch to -2π by tick 15, which is a forward flip: +2π around model X.
|
||||
|
||||
+3
-1
@@ -9,6 +9,8 @@ pluginManagement {
|
||||
}
|
||||
|
||||
plugins {
|
||||
// Downloads the Java toolchain a version needs (Java 25 for 26.1+) when it isn't installed.
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
id("dev.kikugie.stonecutter") version "0.9.8"
|
||||
// Picks the right Loom variant per Minecraft version (remapping for 1.21.x, plain for 26.1+).
|
||||
id("dev.kikugie.loom-back-compat") version "0.4.2"
|
||||
@@ -17,7 +19,7 @@ plugins {
|
||||
stonecutter {
|
||||
create(rootProject) {
|
||||
// Same range as Saturn Client. Per-version dependencies live in stonecutter.properties.toml.
|
||||
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11")
|
||||
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11", "26.1", "26.2", "26.3")
|
||||
vcsVersion = "1.21.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ public final class BendTransforms {
|
||||
bend.bendPoint(0, y, 0, center);
|
||||
Vec3 axis = bend.axis();
|
||||
poseStack.translate(center[0] / 16, center[1] / 16, center[2] / 16);
|
||||
poseStack.mulPose(new Quaternionf().rotationAxis(bend.angleAt(y), axis.x(), axis.y(), axis.z()));
|
||||
Quaternionf rotation = new Quaternionf().rotationAxis(bend.angleAt(y), axis.x(), axis.y(), axis.z());
|
||||
//? if >=26.3 {
|
||||
/*poseStack.rotate(rotation);
|
||||
*///?} else
|
||||
poseStack.mulPose(rotation);
|
||||
poseStack.translate(0, -y / 16, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
/** Client-side entry point: which player is playing which animation. */
|
||||
public final class Emotes implements ClientModInitializer {
|
||||
public static final String MOD_ID = "saturn_emotes";
|
||||
public static final String VERSION = /*$ mod_version*/ "0.1.0";
|
||||
public static final String VERSION = /*$ mod_version*/ "0.1.2";
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
private static final EmoteRegistry REGISTRY = new EmoteRegistry();
|
||||
|
||||
@@ -7,6 +7,7 @@ import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext;
|
||||
import net.minecraft.client.CameraType;
|
||||
import net.minecraft.client.Minecraft;
|
||||
|
||||
/**
|
||||
* Screenshots each test animation at set points, from the front, the side and behind.
|
||||
@@ -50,9 +51,14 @@ public final class EmotesGameTest implements FabricClientGameTest {
|
||||
@Override
|
||||
public void runTest(ClientGameTestContext context) {
|
||||
try (TestSingleplayerContext singleplayer = context.worldBuilder().create()) {
|
||||
//? if >=26.2 {
|
||||
/*singleplayer.getConnection().waitForChunksRender();
|
||||
*///?} else if >=26.1 {
|
||||
/*singleplayer.getClientLevel().waitForChunksRender();
|
||||
*///?} else
|
||||
singleplayer.getClientWorld().waitForChunksRender();
|
||||
context.runOnClient(client -> {
|
||||
client.options.hideGui = true;
|
||||
setHudHidden(client, true);
|
||||
client.options.setCameraType(CameraType.THIRD_PERSON_FRONT);
|
||||
});
|
||||
context.waitTicks(20);
|
||||
@@ -80,7 +86,7 @@ public final class EmotesGameTest implements FabricClientGameTest {
|
||||
singleplayer.getServer().runCommand("clear @a");
|
||||
context.waitTicks(2);
|
||||
context.runOnClient(client -> {
|
||||
client.options.hideGui = false;
|
||||
setHudHidden(client, false);
|
||||
client.options.setCameraType(CameraType.FIRST_PERSON);
|
||||
});
|
||||
// Give the hand time to come back up after the item change.
|
||||
@@ -88,11 +94,21 @@ public final class EmotesGameTest implements FabricClientGameTest {
|
||||
context.takeScreenshot(MINECRAFT + "-hold-firstperson");
|
||||
context.runOnClient(client -> {
|
||||
Emotes.stop(client.player.getUUID());
|
||||
client.options.hideGui = true;
|
||||
setHudHidden(client, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void setHudHidden(Minecraft client, boolean hidden) {
|
||||
// From 26.2 the HUD can only be toggled.
|
||||
//? if >=26.2 {
|
||||
/*if (client.gui.hud.isHidden() != hidden) {
|
||||
client.gui.hud.toggle();
|
||||
}
|
||||
*///?} else
|
||||
client.options.hideGui = hidden;
|
||||
}
|
||||
|
||||
/** Turns the body (not the camera) by {@code turn} degrees, then takes a screenshot. */
|
||||
private static void screenshot(ClientGameTestContext context, String name, float turn) {
|
||||
context.runOnClient(client -> {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package org.saturnclient.emotes.testmod;
|
||||
|
||||
//? if >=26.1 {
|
||||
/*import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.argument;
|
||||
import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.literal;
|
||||
*///?} else {
|
||||
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument;
|
||||
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal;
|
||||
//?}
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
mod.id = "saturn_emotes"
|
||||
mod.name = "Saturn Emotes"
|
||||
mod.group = "org.saturnclient"
|
||||
mod.version = "0.1.0"
|
||||
mod.version = "0.1.2"
|
||||
|
||||
deps.fabric_loader = "0.19.5"
|
||||
loomx.loom_version = "1.17.21"
|
||||
@@ -38,3 +38,16 @@ deps.fabric_api = "0.138.4+1.21.10"
|
||||
["1.21.11"]
|
||||
mod.mc_compat = "1.21.11"
|
||||
deps.fabric_api = "0.141.6+1.21.11"
|
||||
|
||||
# Built against 26.1 and allowed on its hotfixes (26.1.1, 26.1.2).
|
||||
["26.1"]
|
||||
mod.mc_compat = "~26.1"
|
||||
deps.fabric_api = "0.145.1+26.1"
|
||||
|
||||
["26.2"]
|
||||
mod.mc_compat = "26.2"
|
||||
deps.fabric_api = "0.161.0+26.2"
|
||||
|
||||
["26.3"]
|
||||
mod.mc_compat = "26.3"
|
||||
deps.fabric_api = "0.161.0+26.3"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
# Captures, fetched skins and scratch output: large, and regenerated by the scripts.
|
||||
frames/
|
||||
skins/
|
||||
*.log
|
||||
*.png
|
||||
@@ -0,0 +1,19 @@
|
||||
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||
|
||||
/** Prints LimbBend results for check_port.py to compare with the Python port. */
|
||||
public class CheckPort {
|
||||
public static void main(String[] args) {
|
||||
float[][] cases = { {-2, 10, 0.5f, 0, 0}, {-2, 10, 1.75f, 3.5f, 0}, {0, 12, 1.2f, 2.79f, 0},
|
||||
{0, 12, 0.7f, 3.14159f, 1}, {0, 12, -0.9f, 1.0f, 1} };
|
||||
float[][] points = { {0, 0, 0}, {1.5f, 3, -2.25f}, {-2.25f, 10.25f, 2.25f}, {2, -2.25f, 1}, {0.5f, 12.25f, -1} };
|
||||
float[] out = new float[3];
|
||||
for (float[] c : cases) {
|
||||
LimbBend b = c[4] == 1 ? LimbBend.anchoredAtEnd(c[0], c[1], c[2], c[3], 0.5f, 12)
|
||||
: new LimbBend(c[0], c[1], c[2], c[3], 0.5f, 12);
|
||||
for (float[] p : points) {
|
||||
b.bendPoint(p[0], p[1], p[2], out);
|
||||
System.out.printf("%f %f %f %f%n", out[0], out[1], out[2], b.angleAt(p[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Emote capture
|
||||
|
||||
Measures an emote from a store page's 3D preview and writes it as an Emotecraft emote our loader
|
||||
reads. It works from pixels only: it renders our own player model in the same pose from the same
|
||||
cameras and adjusts the pose until outlines and colours match. It never reads the page's animation
|
||||
data.
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/playwright install firefox # Playwright's own Firefox, in its cache folder
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
```sh
|
||||
.venv/bin/python capture.py <store url> <name> --skin Steve # frames/<name>/<view>/<tick>.png + views.json
|
||||
.venv/bin/python measure.py <name> --ticks 0:13 # frames/<name>/rig.json (one loop: 13 ticks)
|
||||
.venv/bin/python fit.py <name> --symmetric --title "Twerk" --out <path>.json
|
||||
```
|
||||
|
||||
- **`capture.py`** opens the page in headless Firefox with `viewer.js` injected, switches the
|
||||
preview to 3D and puts a skin on it. `--skin` takes one of the dialog's presets or any Minecraft
|
||||
username. `viewer.js` replaces the page's clock, so frames step one tick (50 ms) at a time however
|
||||
fast the emote moves. It also finds the camera through three.js's devtools hook. Each tick is taken
|
||||
from 10 fixed cameras with a transparent background. It also measures the loop length from the
|
||||
frames.
|
||||
- **`skin.py`** fetches that username's skin from Mojang, so our model can wear the same texture.
|
||||
- **`measure.py`** fits our rig to every tick. It renders the model with `model.py` (a port of
|
||||
`LimbBend` and `PoseApplier`) and `render.py`, and scores it by outline distance and colour. It
|
||||
searches with CMA-ES on all cores. The first tick is searched in stages: torso and head, then each
|
||||
limb from several starts, then everything, then its back-to-front twins (`flip.py`). It also finds
|
||||
the viewer's scale then. For a whole loop, later rounds refit every tick held near the loop's
|
||||
smoothed curve, so drift can't build up. Leaning further forward while arching further back looks
|
||||
almost the same from every camera, so a small cost on the torso's bend picks the straightest torso.
|
||||
- **`fit.py`** smooths each channel over the loop (Fourier harmonics) and stands the emote on the
|
||||
ground. It keys each channel wherever straight-line interpolation misses by more than 1° or 0.1 px.
|
||||
`--symmetric` removes left/right lopsidedness (`symmetry.py`): each tick is averaged with the mirror
|
||||
of the pose half a loop on, which keeps an even sway and drops any lean to one side.
|
||||
|
||||
## Checking
|
||||
|
||||
- `check_port.py` compares `model.py`'s bend maths with the Java classes (after
|
||||
`./gradlew :core:compileJava`).
|
||||
- `synth_test.py` renders our own twerk test animation as a fake capture, so `measure.py` can be
|
||||
scored against a known answer.
|
||||
- `symmetry.py <name>` lists the most lopsided channels.
|
||||
- `compare.py <name> <tick>` puts the captured frames next to our render of the fit.
|
||||
- `overlay.py <name>` draws both outlines on top of each other.
|
||||
- `sheet.py` tiles captured frames.
|
||||
|
||||
To see it in game, add the JSON to the testmod's `player_animations` and `EMOTECRAFT_EMOTES`, and add
|
||||
shots to `EmotesGameTest`.
|
||||
|
||||
## Limits
|
||||
|
||||
- A loop takes about 45 minutes on 7 cores.
|
||||
- The emote comes out with a keyframe on almost every tick for every channel.
|
||||
- Other rigs differ from ours (a knee made of two rigid boxes, a torso that doesn't bend), so a fit is
|
||||
our closest match, not an exact copy.
|
||||
- Extreme poses can still land in a wrong answer on the first tick. `synth_test.py`'s 80° lean does.
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Captures an emote from a store page's 3D viewer, one frame per tick from several fixed cameras.
|
||||
|
||||
.venv/bin/python capture.py <store url> <name> [--seconds 6]
|
||||
|
||||
Writes frames/<name>/<view>/<tick>.png (RGBA, transparent background) and frames/<name>/views.json,
|
||||
which records each camera exactly so measure.py can render the same views.
|
||||
|
||||
The page runs on a clock that viewer.js steps by hand, so every view of a tick shows the same moment
|
||||
and nothing is missed however fast the emote moves.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
TICK_MS = 50
|
||||
# Where the cameras look (viewer world units) and from how far. The model stands about one unit per
|
||||
# model pixel, with its middle about 12 units below the viewer's origin.
|
||||
TARGET = [0, -12, 0]
|
||||
DISTANCE = 50
|
||||
FOV = 50
|
||||
VIEWS = {f"y{yaw:03d}": (yaw, 0) for yaw in range(0, 360, 45)}
|
||||
VIEWS |= {"top000": (0, 50), "top090": (90, 50)}
|
||||
|
||||
|
||||
def step(page, ms, grab=False):
|
||||
url = page.evaluate(f"window.__capture.step({ms}, {'true' if grab else 'false'})")
|
||||
if not grab:
|
||||
return None
|
||||
return Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).convert("RGBA")
|
||||
|
||||
|
||||
def warm_up(page, frames):
|
||||
for _ in range(frames):
|
||||
step(page, 16)
|
||||
page.wait_for_timeout(15)
|
||||
|
||||
|
||||
def set_view(page, yaw, pitch):
|
||||
view = {"target": TARGET, "distance": DISTANCE, "yaw": yaw, "pitch": pitch, "fov": FOV}
|
||||
page.evaluate(f"window.__capture.setView({json.dumps(view)})")
|
||||
return view
|
||||
|
||||
|
||||
def choose_skin(page, skin):
|
||||
"""Puts a skin we have the texture of on the model, so measure.py can match colours too."""
|
||||
page.evaluate("[...document.querySelectorAll('button')].find(b => b.innerText.includes(\"'s skin\")).click()")
|
||||
warm_up(page, 40)
|
||||
page.get_by_text("Change preview skin").last.click(force=True)
|
||||
warm_up(page, 60)
|
||||
preset = page.get_by_role("button", name=skin, exact=True)
|
||||
if preset.count():
|
||||
preset.first.click(force=True)
|
||||
else:
|
||||
page.get_by_placeholder("Enter a Minecraft username").fill(skin)
|
||||
page.keyboard.press("Enter")
|
||||
warm_up(page, 150)
|
||||
page.keyboard.press("Escape")
|
||||
|
||||
|
||||
def loop_ticks(masks):
|
||||
"""The loop length in ticks: the shift that best maps the front view's masks onto themselves."""
|
||||
best, best_err = None, None
|
||||
for period in range(8, len(masks) // 2):
|
||||
err = np.mean([np.mean(masks[i] != masks[i + period]) for i in range(len(masks) - period)])
|
||||
if best_err is None or err < best_err:
|
||||
best, best_err = period, err
|
||||
return best, best_err
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("url")
|
||||
parser.add_argument("name")
|
||||
parser.add_argument("--seconds", type=float, default=6)
|
||||
parser.add_argument("--skin", default="Steve",
|
||||
help="a preset from the viewer's skin dialog (Steve, Alex, ...) or a Minecraft username")
|
||||
args = parser.parse_args()
|
||||
|
||||
out = Path("frames") / args.name
|
||||
ticks = round(args.seconds * 1000 / TICK_MS)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.firefox.launch()
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
page.add_init_script(path=str(Path(__file__).with_name("viewer.js")))
|
||||
page.goto(args.url, wait_until="domcontentloaded", timeout=60000)
|
||||
warm_up(page, 200)
|
||||
# The toggle between the video and the 3D viewer sits in the preview's top right corner.
|
||||
page.mouse.click(794, 177)
|
||||
page.wait_for_selector("#skin-viewer", timeout=30000)
|
||||
warm_up(page, 100)
|
||||
choose_skin(page, args.skin)
|
||||
warm_up(page, 200)
|
||||
|
||||
cameras = {}
|
||||
for name, (yaw, pitch) in VIEWS.items():
|
||||
(out / name).mkdir(parents=True, exist_ok=True)
|
||||
cameras[name] = set_view(page, yaw, pitch)
|
||||
|
||||
info = page.evaluate("window.__capture.camera_info()")
|
||||
start = page.evaluate("window.__capture.time()")
|
||||
front_masks = []
|
||||
for tick in range(ticks):
|
||||
for i, (name, (yaw, pitch)) in enumerate(VIEWS.items()):
|
||||
set_view(page, yaw, pitch)
|
||||
# The first view advances the clock by a tick; the rest redraw the same moment.
|
||||
frame = step(page, TICK_MS if i == 0 else 0, grab=True)
|
||||
frame.save(out / name / f"{tick:04d}.png")
|
||||
if name == "y000":
|
||||
front_masks.append(np.asarray(frame)[:, :, 3] > 127)
|
||||
if tick % 20 == 0:
|
||||
print(f"tick {tick}/{ticks}")
|
||||
|
||||
period, err = loop_ticks(front_masks)
|
||||
meta = {
|
||||
"url": args.url,
|
||||
"skin": args.skin,
|
||||
"tick_ms": TICK_MS,
|
||||
"ticks": ticks,
|
||||
"start_ms": start,
|
||||
"size": list(front_masks[0].shape[::-1]),
|
||||
"aspect": info["aspect"],
|
||||
"near": info["near"],
|
||||
"views": cameras,
|
||||
"loop_ticks": period,
|
||||
"loop_error": err,
|
||||
}
|
||||
(out / "views.json").write_text(json.dumps(meta, indent=2))
|
||||
print(f"loop: {period} ticks ({period * TICK_MS / 1000:.2f} s), mismatch {err:.4f}")
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Compares model.LimbBend with the Java LimbBend, point for point.
|
||||
|
||||
.venv/bin/python check_port.py ../../core/build/classes/java/main # after ./gradlew :core:compileJava
|
||||
|
||||
Runs CheckPort.java against the compiled core classes and fails if any result differs by 1e-3 or more.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
from model import LimbBend
|
||||
|
||||
CASES = [(-2, 10, 0.5, 0, 0), (-2, 10, 1.75, 3.5, 0), (0, 12, 1.2, 2.79, 0), (0, 12, 0.7, 3.14159, 1), (0, 12, -0.9, 1.0, 1)]
|
||||
POINTS = np.array([[0, 0, 0], [1.5, 3, -2.25], [-2.25, 10.25, 2.25], [2, -2.25, 1], [0.5, 12.25, -1]])
|
||||
|
||||
classes = sys.argv[1]
|
||||
java = subprocess.run(["java", "-cp", classes, "CheckPort.java"], capture_output=True, text=True, check=True).stdout
|
||||
expected = np.array([[float(v) for v in line.split()] for line in java.strip().splitlines()])
|
||||
got = []
|
||||
for start, end, angle, axis, anchored in CASES:
|
||||
b = LimbBend(start, end, angle, axis, anchored_at_end=bool(anchored))
|
||||
bent = b.bend_points(POINTS)
|
||||
got += [[*bent[i], b.angle_at(POINTS[i, 1])] for i in range(len(POINTS))]
|
||||
err = np.abs(np.array(got) - expected).max()
|
||||
print(f"max difference from Java: {err:.2e}")
|
||||
sys.exit(0 if err < 1e-3 else 1)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Puts captured frames next to our render of the fitted pose, with the same skin and cameras.
|
||||
|
||||
.venv/bin/python compare.py <name> [tick] [views]
|
||||
|
||||
Writes frames/<name>/compare.png: for each view, theirs on the left and ours on the right.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from model import Model, rig_to_pose
|
||||
from render import Camera, render_colors
|
||||
|
||||
name = sys.argv[1]
|
||||
tick = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
views = sys.argv[3].split(",") if len(sys.argv) > 3 else ["y000", "y090", "y180", "y270", "y045"]
|
||||
base = Path("frames") / name
|
||||
meta = json.loads((base / "views.json").read_text())
|
||||
data = json.loads((base / "rig.json").read_text())
|
||||
rig = np.array(next(t["rig"] for t in data["ticks"] if t["tick"] == tick))
|
||||
skin = np.asarray(Image.open(Path("skins") / f"{meta.get('skin', 'Steve')}.png").convert("RGBA"))
|
||||
model = Model(spacing=0.12, skin=skin)
|
||||
scale = 2
|
||||
cams = {v: Camera(meta["views"][v], meta["size"], meta["aspect"], scale) for v in views}
|
||||
ours = render_colors(np.concatenate(list(model.pose(rig_to_pose(rig)).values())), model.colors, data["calib"], cams)
|
||||
bg = np.array([40, 40, 52], np.uint8)
|
||||
tiles = []
|
||||
for v in views:
|
||||
theirs = np.asarray(Image.open(base / v / f"{tick:04d}.png").convert("RGBA").reduce(scale))
|
||||
a = np.where(theirs[..., 3:] > 127, theirs[..., :3], bg)
|
||||
image, mask = ours[v]
|
||||
b = np.where(mask[..., None], (image * 255).astype(np.uint8), bg)
|
||||
tiles.append(np.concatenate([a, b], axis=1))
|
||||
sheet = np.concatenate(tiles, axis=0)
|
||||
ys, xs = np.nonzero((sheet != bg).any(axis=2))
|
||||
Image.fromarray(sheet[:, max(xs.min() - 8, 0):xs.max() + 8]).save(base / "compare.png")
|
||||
print("wrote", base / "compare.png")
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The back-to-front twin of a rig, which covers the same space with the textures turned round.
|
||||
|
||||
The head and torso boxes are centred on their pivots, so spinning either half a turn around its own
|
||||
vertical axis leaves it covering the same space; only which face shows changes. The arms hang from
|
||||
the torso's sides, so spinning the torso carries each arm to the other side. That twin (torso and
|
||||
head facing backwards, arms swapped) is a separate local minimum the search can't walk out of, so
|
||||
measure.py jumps to it directly, refines it, and keeps whichever scores lower. Only the colours tell
|
||||
them apart.
|
||||
|
||||
In rig terms: the torso and head get R Ry(pi); each arm takes the other's rotation times Ry(pi) (a
|
||||
half turn around its own length, which moves its off-centre box to the other side of its pivot); and
|
||||
bends keep their shape with their direction turned half round, since the frame they're measured in
|
||||
turned. The legs hang from the hips and don't change.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from model import INDEX, rotation_zyx
|
||||
|
||||
SPIN = np.diag([-1.0, 1.0, -1.0]) # Ry(pi)
|
||||
ARMS = {"right_arm": "left_arm", "left_arm": "right_arm"}
|
||||
|
||||
|
||||
def euler_zyx(r):
|
||||
"""(pitch, yaw, roll) with rotation_zyx(pitch, yaw, roll) == r."""
|
||||
yaw = np.arcsin(np.clip(-r[2, 0], -1, 1))
|
||||
return np.arctan2(r[2, 1], r[2, 2]), yaw, np.arctan2(r[1, 0], r[0, 0])
|
||||
|
||||
|
||||
def _spun(rig, source):
|
||||
g = lambda c: rig[INDEX[f"{source}_{c}"]]
|
||||
return euler_zyx(rotation_zyx(g("pitch"), g("yaw"), g("roll")) @ SPIN)
|
||||
|
||||
|
||||
def flip_head(rig):
|
||||
"""Just the head spun round: a cube on its pivot, so the same space facing the other way."""
|
||||
out = rig.copy()
|
||||
for c, v in zip(("pitch", "yaw", "roll"), _spun(rig, "head")):
|
||||
out[INDEX[f"head_{c}"]] = v
|
||||
return out
|
||||
|
||||
|
||||
def twins(rig):
|
||||
"""Every back-to-front twin worth trying: the torso with its arms, the head, and both."""
|
||||
return [flip(rig), flip_head(rig), flip_head(flip(rig))]
|
||||
|
||||
|
||||
def flip(rig):
|
||||
out = rig.copy()
|
||||
for part, source in [("body", "body"), ("head", "head"), *ARMS.items()]:
|
||||
for c, v in zip(("pitch", "yaw", "roll"), _spun(rig, source)):
|
||||
out[INDEX[f"{part}_{c}"]] = v
|
||||
for part, source in [("body", "body"), *ARMS.items()]:
|
||||
out[INDEX[f"{part}_bend"]] = rig[INDEX[f"{source}_bend"]]
|
||||
out[INDEX[f"{part}_axis"]] = rig[INDEX[f"{source}_axis"]] + np.pi
|
||||
return out
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Fits our rig to captured frames, tick by tick, by rendering it and matching outlines and colours.
|
||||
|
||||
.venv/bin/python measure.py <name> [--ticks 0:13] [--workers 7]
|
||||
|
||||
Writes frames/<name>/rig.json: the viewer calibration and one rig vector per tick (see model.RIG).
|
||||
|
||||
The first tick has nothing to start from, so it's searched in stages: the torso and head, then each
|
||||
limb from several starts, then everything together, then its back-to-front twins (see flip.py). It
|
||||
also finds the viewer's scale. Later ticks start from the tick before.
|
||||
|
||||
For a whole loop, that first pass is only a starting point: warm starts carry any drift from tick to
|
||||
tick. Each later round smooths every channel over the loop (a few Fourier harmonics, which have to
|
||||
close), and refits every tick held near that curve instead of near its neighbour.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import multiprocessing
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cma
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from flip import twins
|
||||
from model import INDEX, RIG, Model, rest_rig, rig_to_pose
|
||||
from render import Capture, score
|
||||
|
||||
# How far CMA-ES first looks along each parameter: pixels for offsets, radians for angles.
|
||||
STEP = np.array([2.0 if name.endswith(("_x", "_y", "_z")) else 0.35 for name in RIG])
|
||||
CALIB = ["scale", "view_x", "view_y", "view_z"]
|
||||
CALIB_STEP = np.array([0.05, 2, 2, 2])
|
||||
# Only the scale is fitted. Moving the viewer, the whole player and the hips all slide the whole model,
|
||||
# so no frame can tell them apart: the viewer's offset stays fixed (it centres the player on its
|
||||
# origin), and fit.py stands the finished emote on the ground instead.
|
||||
CALIB_FREE = [0]
|
||||
# Channels a silhouette barely sees (a limb's twist, which way a straight limb would bend) drift
|
||||
# unless something holds them near zero.
|
||||
QUIET = np.array([1.0 if name.endswith(("_yaw", "_axis")) and not name.startswith(("root", "body")) else 0.0
|
||||
for name in RIG])
|
||||
QUIET_WEIGHT = 0.02
|
||||
SMOOTH_WEIGHT = 0.05
|
||||
# The search scores colour lightly in a few views: outlines give it a smooth slope to follow, and
|
||||
# colour changes abruptly as parts slide across texture edges, which strands it. Choosing between a
|
||||
# fit and its back-to-front twins (whose outlines match) is colour's job, so that comparison weighs
|
||||
# colour heavily in every view.
|
||||
COLOR_VIEWS = {"y000", "y090", "y180", "y270"}
|
||||
JUDGE_COLOR_WEIGHT = 2.0
|
||||
|
||||
# Anatomy, as soft limits: in a tight pose like a crouch, many limb twists cast nearly the same outline,
|
||||
# and these pick the natural one. Nothing is penalised inside the ranges.
|
||||
FOLD = {"right_leg": np.pi, "left_leg": np.pi, "right_arm": 0.0, "left_arm": 0.0} # knees back, elbows forward
|
||||
FOLD_RANGE = np.radians(70)
|
||||
ROLL_RANGE = np.radians(60)
|
||||
PRIOR_WEIGHT = 0.5
|
||||
HIP_CENTRE_WEIGHT = 0.002
|
||||
# Leaning the torso further forward while arching it further back looks nearly the same from every
|
||||
# camera, so a small cost on the torso's bend picks the straightest torso that fits.
|
||||
BODY_BEND_WEIGHT = 0.008
|
||||
# Where each bend direction is kept (see canonical): half a turn either side of this. Knees fold back
|
||||
# and elbows forward; a torso may hunch (0) or arch (pi), so both sit well inside its range.
|
||||
AXIS_CENTRE = FOLD | {"body": np.pi / 2}
|
||||
|
||||
|
||||
def wrap(a):
|
||||
return (a + np.pi) % (2 * np.pi) - np.pi
|
||||
|
||||
|
||||
ANGLES = np.array([not name.endswith(("_x", "_y", "_z")) for name in RIG])
|
||||
|
||||
|
||||
def change(rig, previous):
|
||||
"""How far each channel moved, the short way round for angles: canonical() keeps them within
|
||||
-pi..pi, so a knee folding back straddles the edge and would look like it swung a whole turn."""
|
||||
d = rig - previous
|
||||
return np.where(ANGLES, wrap(d), d)
|
||||
|
||||
|
||||
def anatomy(rig):
|
||||
"""How far outside natural joint ranges the rig is (0 inside them)."""
|
||||
total = 0.0
|
||||
for limb, fold in FOLD.items():
|
||||
bend = rig[INDEX[f"{limb}_bend"]]
|
||||
# A negative bend folds the other way.
|
||||
axis = rig[INDEX[f"{limb}_axis"]] + (np.pi if bend < 0 else 0)
|
||||
# Which way a limb folds only matters as much as it bends.
|
||||
total += np.sin(min(abs(bend), np.pi) / 2) * max(0.0, abs(wrap(axis - fold)) - FOLD_RANGE) ** 2
|
||||
total += max(0.0, abs(wrap(rig[INDEX[f"{limb}_roll"]])) - ROLL_RANGE) ** 2
|
||||
return PRIOR_WEIGHT * total + HIP_CENTRE_WEIGHT * rig[INDEX["hip_x"]] ** 2
|
||||
|
||||
# The hips carry everything, so the whole player's offset would only duplicate them; it stays at zero.
|
||||
# Turning the whole player duplicates turning every part, so it's only fitted with --root, for flips
|
||||
# and spins, where it keeps the keyframes simple.
|
||||
FITTED = [i for i, n in enumerate(RIG) if not n.startswith("root")]
|
||||
ROOT_TURN = [INDEX[n] for n in ("root_pitch", "root_yaw", "root_roll")]
|
||||
LIMBS = ("right_leg", "left_leg", "right_arm", "left_arm")
|
||||
ALL_PARTS = ("head", "body", "right_arm", "left_arm", "right_leg", "left_leg")
|
||||
|
||||
# Per-process state, set by init_worker, so the pool only receives parameter vectors.
|
||||
_state = {}
|
||||
|
||||
|
||||
def init_worker(directory, skin_path, tick):
|
||||
skin = np.asarray(Image.open(skin_path).convert("RGBA"))
|
||||
cap = Capture(directory)
|
||||
model = Model(skin=skin)
|
||||
# Where each part's points sit in model.colors, to score some of the parts on their own.
|
||||
bounds = np.cumsum([0] + [len(model.local[p]) for p in model.local])
|
||||
spans = {p: (bounds[i], bounds[i + 1]) for i, p in enumerate(model.local)}
|
||||
_state.update(model=model, spans=spans, cameras=cap.cameras, targets=cap.targets(tick))
|
||||
|
||||
|
||||
def loss(job):
|
||||
"""job = (full vector: rig + calib, previous rig or None, parts to score[, judging]). Scoring only
|
||||
some parts (the torso stage) counts just their points outside the captured outline. Judging weighs
|
||||
colour heavily in every view, to pick between twins."""
|
||||
v, previous, parts, *judging = job
|
||||
judging = bool(judging and judging[0])
|
||||
rig, calib = v[:len(RIG)], v[len(RIG):]
|
||||
m = _state["model"]
|
||||
posed = m.pose(rig_to_pose(rig))
|
||||
parts = parts or ALL_PARTS
|
||||
pts = np.concatenate([posed[p] for p in parts])
|
||||
colors = np.concatenate([m.colors[slice(*_state["spans"][p])] for p in parts])
|
||||
total = score(pts, calib, _state["cameras"], _state["targets"], colors=colors,
|
||||
color_views=None if judging else COLOR_VIEWS, one_way=len(parts) < len(ALL_PARTS),
|
||||
color_weight=JUDGE_COLOR_WEIGHT if judging else None)
|
||||
total += QUIET_WEIGHT * np.sum(QUIET * np.sin(rig) ** 2)
|
||||
total += anatomy(rig)
|
||||
total += BODY_BEND_WEIGHT * rig[INDEX["body_bend"]] ** 2
|
||||
if previous is not None:
|
||||
total += SMOOTH_WEIGHT * np.sum((change(rig, previous) / STEP) ** 2) / len(rig)
|
||||
return total
|
||||
|
||||
|
||||
def fit(pool, x0, free, sigma, evals, previous=None, parts=None, popsize=24, seed=1):
|
||||
"""CMA-ES over the indices in free (of rig + calib); the rest stay at x0."""
|
||||
x0 = np.asarray(x0, float)
|
||||
steps = np.concatenate([STEP, CALIB_STEP])[free]
|
||||
|
||||
def expand(sub):
|
||||
v = x0.copy()
|
||||
v[free] = sub
|
||||
return v
|
||||
|
||||
es = cma.CMAEvolutionStrategy(x0[free], sigma, {
|
||||
"CMA_stds": steps, "popsize": popsize, "maxfevals": evals, "verbose": -9, "seed": seed, "tolfun": 1e-4,
|
||||
})
|
||||
while not es.stop():
|
||||
subs = es.ask()
|
||||
es.tell(subs, pool.map(loss, [(expand(s), previous, parts) for s in subs]))
|
||||
return expand(es.result.xbest), es.result.fbest
|
||||
|
||||
|
||||
def canonical(v):
|
||||
"""A negative bend is the same shape as a positive one folding the other way. Bend directions are
|
||||
kept within half a turn of AXIS_CENTRE, so a knee folding back doesn't sit on the edge of the range
|
||||
and jump a whole turn between ticks."""
|
||||
v = v.copy()
|
||||
for part, centre in AXIS_CENTRE.items():
|
||||
b, a = INDEX[f"{part}_bend"], INDEX[f"{part}_axis"]
|
||||
if v[b] < 0:
|
||||
v[b], v[a] = -v[b], v[a] + np.pi
|
||||
v[a] = wrap(v[a] - centre) + centre
|
||||
return v
|
||||
|
||||
|
||||
def loop_curve(rigs, harmonics):
|
||||
"""Each channel of a loop's rigs kept to its first few Fourier harmonics: the loop's own smooth
|
||||
path, which has to come back to where it started."""
|
||||
from fit import periodic_smooth
|
||||
rigs = np.array(rigs)
|
||||
curve = np.empty_like(rigs)
|
||||
for i in range(rigs.shape[1]):
|
||||
values = np.unwrap(rigs[:, i]) if ANGLES[i] else rigs[:, i]
|
||||
curve[:, i] = periodic_smooth(values, harmonics, bool(ANGLES[i]))[:-1]
|
||||
return curve
|
||||
|
||||
|
||||
# Starting points for each limb on its own: swung back, hanging, or forward, straight or folded the
|
||||
# natural way. One start lands in a different local minimum every run; the best of these doesn't.
|
||||
LIMB_STARTS = [(pitch, bend) for pitch in (-1.2, 0.0, 1.2) for bend in (0.0, 1.5)]
|
||||
|
||||
|
||||
def fit_limbs(pool, x, fitted, starts, evals):
|
||||
"""Fits each limb in turn with the others held, keeping the best of the starts."""
|
||||
fx = None
|
||||
for limb in LIMBS:
|
||||
free = [i for i in fitted if RIG[i].startswith(limb)]
|
||||
best = None
|
||||
for pitch, bend in starts or [(None, None)]:
|
||||
x0 = x.copy()
|
||||
if pitch is not None:
|
||||
x0[free] = 0
|
||||
x0[INDEX[f"{limb}_pitch"]] = pitch
|
||||
x0[INDEX[f"{limb}_bend"]] = bend
|
||||
x0[INDEX[f"{limb}_axis"]] = FOLD[limb]
|
||||
result = fit(pool, x0, free, 1.0 if starts else 0.4, evals)
|
||||
if best is None or result[1] < best[1]:
|
||||
best = result
|
||||
x, fx = best
|
||||
return x, fx
|
||||
|
||||
|
||||
def refine_scale(pool, x):
|
||||
"""The viewer scale on its own, once the pose explains the whole outline."""
|
||||
from scipy.optimize import minimize_scalar
|
||||
|
||||
def f(scale):
|
||||
v = x.copy()
|
||||
v[len(RIG)] = scale
|
||||
return pool.map(loss, [(v, None, None)])[0]
|
||||
|
||||
result = minimize_scalar(f, bounds=(x[len(RIG)] * 0.85, x[len(RIG)] * 1.15), method="bounded",
|
||||
options={"xatol": 1e-3})
|
||||
v = x.copy()
|
||||
v[len(RIG)] = result.x
|
||||
return v
|
||||
|
||||
|
||||
def settle_facing(pool, x, fx, fitted, previous, log):
|
||||
"""Tries each back-to-front twin of a fit, refined, and keeps the best. They cover nearly the same
|
||||
outline, so the search can't get from one to another by itself; only colour decides."""
|
||||
judge = lambda v: pool.map(loss, [(v, previous, None, True)])[0]
|
||||
best, best_judged = (x, fx), judge(x)
|
||||
for twin in twins(x[:len(RIG)]):
|
||||
candidate = fit(pool, np.concatenate([twin, x[len(RIG):]]), fitted, 0.3, 1500, previous)
|
||||
judged = judge(candidate[0])
|
||||
if judged < best_judged:
|
||||
log(f" a turned-round twin fits better: {judged:.3f} < {best_judged:.3f} (judged on colour)")
|
||||
best, best_judged = candidate, judged
|
||||
return best
|
||||
|
||||
|
||||
def first_tick(pool, calib, fitted, log):
|
||||
"""Tries the player facing both ways, all the way through, since no single stage can tell them
|
||||
apart reliably: the torso, then each limb from several starts, then each limb again with the
|
||||
others in place, then everything together. The scale is held until the pose explains the whole
|
||||
outline (shrinking always helps a partly wrong pose), then fitted on its own."""
|
||||
torso = [i for i in fitted if RIG[i].startswith(("root", "hip", "body", "head"))]
|
||||
x = np.concatenate([rest_rig(), calib])
|
||||
# The torso and head alone, kept inside the outline and matched by colour.
|
||||
x, fx = fit(pool, x, torso, 1.0, 3000, parts=("head", "body"))
|
||||
log(f" torso {fx:.3f}")
|
||||
x, fx = fit_limbs(pool, x, fitted, LIMB_STARTS, 500)
|
||||
log(f" limbs {fx:.3f}")
|
||||
x, fx = fit_limbs(pool, x, fitted, None, 1000)
|
||||
x, fx = fit(pool, x, fitted, 0.3, 3000)
|
||||
log(f" together {fx:.3f}")
|
||||
# Facing can be wrong at any stage; settle it once the pose explains the whole outline.
|
||||
x, fx = settle_facing(pool, x, fx, fitted, None, log)
|
||||
x, fx = fit(pool, x, fitted, 0.3, 6000)
|
||||
x = refine_scale(pool, x)
|
||||
return fit(pool, x, fitted, 0.2, 3000)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("name")
|
||||
parser.add_argument("--ticks", default="0:13")
|
||||
parser.add_argument("--calib", default="0.9375,0,-1.5,0",
|
||||
help="viewer scale (a starting guess) and offset (kept) as scale,x,y,z")
|
||||
parser.add_argument("--workers", type=int, default=max(1, multiprocessing.cpu_count() - 1))
|
||||
parser.add_argument("--rounds", type=int, default=2,
|
||||
help="for a whole loop, how many times to refit every tick held near the loop's smoothed curve")
|
||||
parser.add_argument("--harmonics", type=int, default=2,
|
||||
help="Fourier harmonics in that curve: few, since it only decides what the frames can't")
|
||||
parser.add_argument("--root", action="store_true", help="also turn the whole player (flips and spins)")
|
||||
parser.add_argument("--resume", action="store_true",
|
||||
help="start from the existing rig.json and only run the rounds")
|
||||
args = parser.parse_args()
|
||||
|
||||
directory = Path("frames") / args.name
|
||||
meta = json.loads((directory / "views.json").read_text())
|
||||
skin_path = Path("skins") / f"{meta.get('skin', 'Steve')}.png"
|
||||
if not skin_path.exists():
|
||||
from skin import fetch
|
||||
skin_path.write_bytes(fetch(meta["skin"])[0])
|
||||
calib = [float(c) for c in args.calib.split(",")]
|
||||
fitted = sorted(FITTED + (ROOT_TURN if args.root else []))
|
||||
start, end = map(int, args.ticks.split(":"))
|
||||
|
||||
ticks = list(range(start, end))
|
||||
whole_loop = meta.get("loop_ticks") == len(ticks)
|
||||
out = directory / "rig.json"
|
||||
by_tick = {}
|
||||
|
||||
def save():
|
||||
results = [{k: v for k, v in by_tick[t].items() if k != "rig_array"} for t in ticks if t in by_tick]
|
||||
out.write_text(json.dumps({"rig": RIG, "calib": calib, "scale": 4, "ticks": results}, indent=2))
|
||||
|
||||
def record(tick, x, fx, label, t0):
|
||||
rig = canonical(x[:len(RIG)])
|
||||
init_worker(directory, skin_path, tick)
|
||||
per_view = score(np.concatenate(list(_state["model"].pose(rig_to_pose(rig)).values())), calib,
|
||||
_state["cameras"], _state["targets"], True, _state["model"].colors, COLOR_VIEWS)
|
||||
print(f"tick {tick}{label}: score {fx:.3f}, worst view {max(per_view, key=per_view.get)} "
|
||||
f"{max(per_view.values()):.2f}, {time.time() - t0:.0f}s", flush=True)
|
||||
by_tick[tick] = {"tick": tick, "rig": rig.tolist(), "rig_array": rig, "score": fx, "views": per_view}
|
||||
save()
|
||||
|
||||
log = lambda m: print(m, flush=True)
|
||||
if args.resume:
|
||||
saved = json.loads(out.read_text())
|
||||
calib = saved["calib"]
|
||||
for t in saved["ticks"]:
|
||||
by_tick[t["tick"]] = t | {"rig_array": canonical(np.array(t["rig"]))}
|
||||
else:
|
||||
# First pass: each tick warm-started from the one before.
|
||||
previous = None
|
||||
for tick in ticks:
|
||||
t0 = time.time()
|
||||
with multiprocessing.Pool(args.workers, init_worker, (directory, skin_path, tick)) as pool:
|
||||
if previous is None:
|
||||
x, fx = first_tick(pool, calib, fitted, log)
|
||||
calib = [float(c) for c in x[len(RIG):]]
|
||||
log("calibration: " + ", ".join(f"{name} {c:.3f}" for name, c in zip(CALIB, calib)))
|
||||
else:
|
||||
x, fx = fit(pool, np.concatenate([previous, calib]), fitted, 0.3, 4000, previous)
|
||||
x, fx = settle_facing(pool, x, fx, fitted, previous, log)
|
||||
record(tick, x, fx, "", t0)
|
||||
previous = by_tick[tick]["rig_array"]
|
||||
|
||||
# Later rounds, for a loop: every tick held near the loop's smoothed curve. No tick depends on the
|
||||
# one fitted before it, so drift can't build up, and the curve has to close.
|
||||
for r in range(args.rounds if whole_loop else 0):
|
||||
curve = loop_curve([by_tick[t]["rig_array"] for t in ticks], args.harmonics)
|
||||
for i, tick in enumerate(ticks):
|
||||
t0 = time.time()
|
||||
anchor = canonical(curve[i])
|
||||
with multiprocessing.Pool(args.workers, init_worker, (directory, skin_path, tick)) as pool:
|
||||
# From its own fit and from the curve: the curve can pull a tick off a ridge its own
|
||||
# fit slid along.
|
||||
x, fx = min((fit(pool, np.concatenate([start, calib]), fitted, 0.2, 2000, anchor)
|
||||
for start in (by_tick[tick]["rig_array"], anchor)), key=lambda c: c[1])
|
||||
record(tick, x, fx, f" (round {r + 1})", t0)
|
||||
print("wrote", directory / "rig.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,351 @@
|
||||
"""The player model and our pose maths, ported from the library so poses can be rendered in Python.
|
||||
|
||||
Mirrors core's LimbBend and Bezier and the fabric mod's PoseApplier: same part boxes and pivots, same
|
||||
rotation order (ModelPart's Z, then Y, then X), the same bends, and the same carrying of the head and
|
||||
arms by a bent torso. check_port.py compares it against the Java classes.
|
||||
|
||||
Model space is Minecraft's: pixels, Y down, the player faces -Z, and its right side is at -X.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
BONES = ["root", "head", "body", "right_arm", "left_arm", "right_leg", "left_leg"]
|
||||
|
||||
# Default pivots and boxes (from, size) of the vanilla player model with wide arms.
|
||||
PIVOTS = {
|
||||
"head": (0, 0, 0),
|
||||
"body": (0, 0, 0),
|
||||
"right_arm": (-5, 2, 0),
|
||||
"left_arm": (5, 2, 0),
|
||||
"right_leg": (-1.9, 12, 0),
|
||||
"left_leg": (1.9, 12, 0),
|
||||
}
|
||||
BOXES = {
|
||||
"head": ((-4, -8, -4), (8, 8, 8)),
|
||||
"body": ((-4, 0, -2), (8, 12, 4)),
|
||||
"right_arm": ((-3, -2, -2), (4, 12, 4)),
|
||||
"left_arm": ((-1, -2, -2), (4, 12, 4)),
|
||||
"right_leg": ((-2, 0, -2), (4, 12, 4)),
|
||||
"left_leg": ((-2, 0, -2), (4, 12, 4)),
|
||||
}
|
||||
# The outer skin layer (hat, jacket, sleeves, pants) is this much bigger on every side.
|
||||
OVERLAY = {"head": 0.5, "body": 0.25, "right_arm": 0.25, "left_arm": 0.25, "right_leg": 0.25, "left_leg": 0.25}
|
||||
# Where each part's base and outer layer start in a 64x64 skin.
|
||||
UV = {
|
||||
"head": ((0, 0), (32, 0)),
|
||||
"body": ((16, 16), (16, 32)),
|
||||
"right_arm": ((40, 16), (40, 32)),
|
||||
"left_arm": ((32, 48), (48, 48)),
|
||||
"right_leg": ((0, 16), (0, 32)),
|
||||
"left_leg": ((16, 48), (0, 48)),
|
||||
}
|
||||
|
||||
SHARPNESS = 0.5
|
||||
SEGMENTS = 12
|
||||
ARC_SAMPLES = 64
|
||||
ROOT_PIVOT_Y = 12.8
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transform:
|
||||
"""BoneTransform: offset in pixels, rotations and bend in radians."""
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
z: float = 0
|
||||
pitch: float = 0
|
||||
yaw: float = 0
|
||||
roll: float = 0
|
||||
bend: float = 0
|
||||
axis: float = 0
|
||||
|
||||
|
||||
def rotation_zyx(pitch, yaw, roll):
|
||||
"""The matrix ModelPart.translateAndRotate applies: Rz(roll) Ry(yaw) Rx(pitch)."""
|
||||
cx, sx = np.cos(pitch), np.sin(pitch)
|
||||
cy, sy = np.cos(yaw), np.sin(yaw)
|
||||
cz, sz = np.cos(roll), np.sin(roll)
|
||||
rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
|
||||
ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
|
||||
rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
|
||||
return rz @ ry @ rx
|
||||
|
||||
|
||||
def axis_angle(axis, angle):
|
||||
x, y, z = axis
|
||||
c, s = np.cos(angle), np.sin(angle)
|
||||
k = np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]])
|
||||
return np.eye(3) + s * k + (1 - c) * (k @ k)
|
||||
|
||||
|
||||
def _bezier(points, t):
|
||||
"""Points on a cubic Bézier at each t (an array), like core's Bezier.point."""
|
||||
t = np.asarray(t, float)[:, None]
|
||||
p0, p1, p2, p3 = points
|
||||
u = 1 - t
|
||||
return u ** 3 * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t ** 3 * p3
|
||||
|
||||
|
||||
def _bezier_derivative(points, t):
|
||||
t = np.asarray(t, float)[:, None]
|
||||
p0, p1, p2, p3 = points
|
||||
u = 1 - t
|
||||
return 3 * u * u * (p1 - p0) + 6 * u * t * (p2 - p1) + 3 * t * t * (p3 - p2)
|
||||
|
||||
|
||||
class LimbBend:
|
||||
"""Port of core's LimbBend. Vectorised over points."""
|
||||
|
||||
def __init__(self, start, end, angle, axis, sharpness=SHARPNESS, segments=SEGMENTS, anchored_at_end=False):
|
||||
self.start = start
|
||||
self.length = end - start
|
||||
self.anchored_at_end = anchored_at_end
|
||||
self.segments = segments
|
||||
self.angle = angle
|
||||
self.dir = np.array([np.sin(axis), 0, -np.cos(axis)])
|
||||
self.n = np.array([self.dir[2], 0, -self.dir[0]])
|
||||
self.ring = np.zeros((segments + 1, 3))
|
||||
self.ring_angle = np.zeros(segments + 1)
|
||||
if self.straight:
|
||||
self.ring[:, 1] = start + self.length * np.arange(segments + 1) / segments
|
||||
return
|
||||
self._build(self._spine(min(1, max(0, sharpness))))
|
||||
|
||||
@property
|
||||
def straight(self):
|
||||
return abs(self.angle) < 1e-4
|
||||
|
||||
def _spine(self, sharpness):
|
||||
half = self.length / 2
|
||||
top = np.array([0, self.start, 0.0])
|
||||
joint = np.array([0, self.start + half, 0.0])
|
||||
lower = np.array([self.dir[0] * np.sin(self.angle), np.cos(self.angle), self.dir[2] * np.sin(self.angle)])
|
||||
bottom = joint + lower * half
|
||||
pull = 2 / 3 + sharpness / 3
|
||||
return [top, top + (joint - top) * pull, bottom + (joint - bottom) * pull, bottom]
|
||||
|
||||
def _build(self, curve):
|
||||
ts = np.arange(ARC_SAMPLES + 1) / ARC_SAMPLES
|
||||
pts = _bezier(curve, ts)
|
||||
lengths = np.concatenate([[0], np.cumsum(np.linalg.norm(np.diff(pts, axis=0), axis=1))])
|
||||
total = lengths[-1]
|
||||
scale = self.length / total
|
||||
top = pts[0]
|
||||
# For each ring, the arc-length sample it falls in (LimbBend's while loop), then t within it.
|
||||
target = total * np.arange(self.segments + 1) / self.segments
|
||||
sample = np.clip(np.searchsorted(lengths, target, side="left") - 1, 0, ARC_SAMPLES - 1)
|
||||
span = lengths[sample + 1] - lengths[sample]
|
||||
f = np.where(span > 0, (target - lengths[sample]) / np.where(span > 0, span, 1), 0)
|
||||
t = ts[sample] + (ts[sample + 1] - ts[sample]) * f
|
||||
self.ring = top + (_bezier(curve, t) - top) * scale
|
||||
tangent = _bezier_derivative(curve, t)
|
||||
self.ring_angle = np.arctan2(tangent @ self.dir, tangent[:, 1])
|
||||
|
||||
def _mirror(self, y):
|
||||
return 2 * self.start + self.length - y
|
||||
|
||||
def _rotate(self, v, a):
|
||||
"""Rodrigues' rotation of rows of v around n by per-row angles a."""
|
||||
n = self.n
|
||||
cos, sin = np.cos(a)[:, None], np.sin(a)[:, None]
|
||||
cross = np.cross(np.broadcast_to(n, v.shape), v)
|
||||
dot = (v @ n)[:, None]
|
||||
return v * cos + cross * sin + n * dot * (1 - cos)
|
||||
|
||||
def _from_start(self, p):
|
||||
along = (p[:, 1] - self.start) / self.length * self.segments
|
||||
ring = np.clip(np.floor(along).astype(int), 0, self.segments - 1)
|
||||
f = np.clip(along - ring, 0, 1)
|
||||
extra = np.where(along <= 0, p[:, 1] - self.start,
|
||||
np.where(along >= self.segments, p[:, 1] - (self.start + self.length), 0))
|
||||
f = np.where(along <= 0, 0, np.where(along >= self.segments, 1, f))
|
||||
center = self.ring[ring] + (self.ring[ring + 1] - self.ring[ring]) * f[:, None]
|
||||
a = self.ring_angle[ring] + (self.ring_angle[ring + 1] - self.ring_angle[ring]) * f
|
||||
local = np.stack([p[:, 0], extra, p[:, 2]], axis=1)
|
||||
return self._rotate(local, a) + center
|
||||
|
||||
def bend_points(self, p):
|
||||
if self.anchored_at_end:
|
||||
q = p.copy()
|
||||
q[:, 1] = self._mirror(q[:, 1])
|
||||
out = self._from_start(q)
|
||||
out[:, 1] = self._mirror(out[:, 1])
|
||||
return out
|
||||
return self._from_start(p)
|
||||
|
||||
def _angle_from_start(self, y):
|
||||
along = np.clip((y - self.start) / self.length * self.segments, 0, self.segments)
|
||||
ring = min(int(along), self.segments - 1)
|
||||
return self.ring_angle[ring] + (self.ring_angle[ring + 1] - self.ring_angle[ring]) * (along - ring)
|
||||
|
||||
def angle_at(self, y):
|
||||
return -self._angle_from_start(self._mirror(y)) if self.anchored_at_end else self._angle_from_start(y)
|
||||
|
||||
|
||||
def limb_bend(bone, t):
|
||||
if t is None or t.bend == 0:
|
||||
return None
|
||||
if bone in ("right_arm", "left_arm"):
|
||||
return LimbBend(-2, 10, t.bend, t.axis)
|
||||
if bone in ("right_leg", "left_leg"):
|
||||
return LimbBend(0, 12, t.bend, t.axis)
|
||||
if bone == "body":
|
||||
return LimbBend(0, 12, t.bend, t.axis, anchored_at_end=True)
|
||||
return None
|
||||
|
||||
|
||||
def textured_points(bone, skin, spacing=0.5):
|
||||
"""Points on a part's base and outer layer with their skin colours, in the part's own space.
|
||||
|
||||
Follows ModelPart.Cube: each face's corners and UV rectangle, and Polygon's corner-to-UV order.
|
||||
Outer layer points are only kept where the skin has pixels there.
|
||||
"""
|
||||
(fx, fy, fz), (dx, dy, dz) = BOXES[bone]
|
||||
pts, cols = [], []
|
||||
for layer, (u, v) in enumerate(UV[bone]):
|
||||
g = OVERLAY[bone] if layer else 0
|
||||
f, gg, h = fx - g, fy - g, fz - g
|
||||
i, j, k = fx + dx + g, fy + dy + g, fz + dz + g
|
||||
c = [(f, gg, h), (i, gg, h), (i, j, h), (f, j, h), (f, gg, k), (i, gg, k), (i, j, k), (f, j, k)]
|
||||
l, m, n, o = u, u + dz, u + dz + dx, u + dz + dx + dx
|
||||
pp, q = u + dz + dx + dz, u + dz + dx + dz + dx
|
||||
r, ss, t = v, v + dz, v + dz + dy
|
||||
faces = [ # corners, (u1, v1, u2, v2)
|
||||
((5, 4, 0, 1), (m, r, n, ss)), # DOWN: minY, the top in model space
|
||||
((2, 3, 7, 6), (n, ss, o, r)), # UP: maxY, the bottom
|
||||
((0, 4, 7, 3), (l, ss, m, t)), # WEST: -X, the player's right
|
||||
((1, 0, 3, 2), (m, ss, n, t)), # NORTH: -Z, the front
|
||||
((5, 1, 2, 6), (n, ss, pp, t)), # EAST: +X, the player's left
|
||||
((4, 5, 6, 7), (pp, ss, q, t)), # SOUTH: +Z, the back
|
||||
]
|
||||
for corners, (u1, v1, u2, v2) in faces:
|
||||
a, b, _, d = (np.array(c[x], float) for x in corners)
|
||||
ns = max(2, int(np.ceil(np.linalg.norm(b - a) / spacing)) + 1)
|
||||
nt = max(2, int(np.ceil(np.linalg.norm(d - a) / spacing)) + 1)
|
||||
# Sample cell centres so every point falls inside one texture pixel.
|
||||
sv, tv = np.meshgrid((np.arange(ns) + 0.5) / ns, (np.arange(nt) + 0.5) / nt, indexing="ij")
|
||||
sv, tv = sv.ravel(), tv.ravel()
|
||||
face = a + sv[:, None] * (b - a) + tv[:, None] * (d - a)
|
||||
tu = np.clip(np.floor(u2 + sv * (u1 - u2)).astype(int), min(u1, u2), max(u1, u2) - 1)
|
||||
tvv = np.clip(np.floor(v1 + tv * (v2 - v1)).astype(int), min(v1, v2), max(v1, v2) - 1)
|
||||
rgba = skin[tvv, tu]
|
||||
keep = rgba[:, 3] > 0 if layer else np.ones(len(face), bool)
|
||||
pts.append(face[keep])
|
||||
cols.append(rgba[keep, :3])
|
||||
return np.concatenate(pts), np.concatenate(cols).astype(float) / 255
|
||||
|
||||
|
||||
def surface_points(bone, spacing=0.5, overlay=True):
|
||||
"""Points on the surface of a part's box (outer layer included), in the part's own space."""
|
||||
(fx, fy, fz), (sx, sy, sz) = BOXES[bone]
|
||||
g = OVERLAY[bone] if overlay else 0
|
||||
lo = np.array([fx - g, fy - g, fz - g])
|
||||
hi = np.array([fx + sx + g, fy + sy + g, fz + sz + g])
|
||||
axes = [np.linspace(lo[i], hi[i], max(2, int(np.ceil((hi[i] - lo[i]) / spacing)) + 1)) for i in range(3)]
|
||||
pts = []
|
||||
for fixed in range(3):
|
||||
a, b = [i for i in range(3) if i != fixed]
|
||||
u, v = np.meshgrid(axes[a], axes[b], indexing="ij")
|
||||
for side in (lo[fixed], hi[fixed]):
|
||||
face = np.empty((u.size, 3))
|
||||
face[:, a], face[:, b], face[:, fixed] = u.ravel(), v.ravel(), side
|
||||
pts.append(face)
|
||||
return np.unique(np.round(np.concatenate(pts), 4), axis=0)
|
||||
|
||||
|
||||
class Model:
|
||||
"""Poses the player's parts. pose() returns model-space points for each part.
|
||||
|
||||
With a skin (an RGBA array), the points carry the skin's colours in self.colors, and the outer layer
|
||||
only exists where the skin has pixels. Without one, parts are plain boxes including the outer layer.
|
||||
"""
|
||||
|
||||
def __init__(self, spacing=0.5, skin=None):
|
||||
if skin is None:
|
||||
self.local = {bone: surface_points(bone, spacing) for bone in PIVOTS}
|
||||
self.colors = None
|
||||
else:
|
||||
textured = {bone: textured_points(bone, skin, spacing) for bone in PIVOTS}
|
||||
self.local = {bone: pts for bone, (pts, _) in textured.items()}
|
||||
self.colors = np.concatenate([cols for _, cols in textured.values()])
|
||||
|
||||
def part_frames(self, pose):
|
||||
"""Each part's pivot, rotation and bend after PoseApplier.apply, in root space."""
|
||||
frames = {}
|
||||
for bone in PIVOTS:
|
||||
t = pose.get(bone)
|
||||
pivot = np.array(PIVOTS[bone], float)
|
||||
rot = np.eye(3)
|
||||
if t is not None:
|
||||
pivot = pivot + [t.x, t.y, t.z]
|
||||
rot = rotation_zyx(t.pitch, t.yaw, t.roll)
|
||||
frames[bone] = [pivot, rot, limb_bend(bone, t)]
|
||||
|
||||
body_pivot, body_rot, body_bend = frames["body"]
|
||||
if body_bend is not None and not body_bend.straight:
|
||||
for bone in ("head", "right_arm", "left_arm"):
|
||||
pivot, rot, bend = frames[bone]
|
||||
local = body_rot.T @ (pivot - body_pivot)
|
||||
moved = body_bend.bend_points(local[None, :])[0]
|
||||
turn = axis_angle(body_bend.n, body_bend.angle_at(local[1]))
|
||||
frames[bone] = [body_pivot + body_rot @ moved, body_rot @ turn @ body_rot.T @ rot, bend]
|
||||
return frames
|
||||
|
||||
def pose(self, pose):
|
||||
frames = self.part_frames(pose)
|
||||
root = pose.get("root")
|
||||
if root is not None:
|
||||
r_rot = rotation_zyx(root.pitch, root.yaw, root.roll)
|
||||
pivot = np.array([0, ROOT_PIVOT_Y, 0])
|
||||
r_pos = np.array([root.x, root.y, root.z]) + pivot - r_rot @ pivot
|
||||
else:
|
||||
r_rot, r_pos = np.eye(3), np.zeros(3)
|
||||
out = {}
|
||||
for bone, (pivot, rot, bend) in frames.items():
|
||||
p = self.local[bone]
|
||||
if bend is not None and not bend.straight:
|
||||
p = bend.bend_points(p)
|
||||
out[bone] = (p @ rot.T + pivot) @ r_rot.T + r_pos
|
||||
return out
|
||||
|
||||
|
||||
# --- The rig the fitter works in -------------------------------------------------------------------
|
||||
#
|
||||
# Moving every pivot freely lets parts drift apart, so the fitter uses a jointed rig instead and
|
||||
# converts it to BoneTransforms: the legs hang from the hips, the torso's hip end sits on the hips,
|
||||
# and the head and arms hang from the torso (PoseApplier then carries them along its bend).
|
||||
|
||||
RIG = (
|
||||
["root_x", "root_y", "root_z", "root_pitch", "root_yaw", "root_roll", "hip_x", "hip_y", "hip_z"]
|
||||
+ [f"body_{c}" for c in ("pitch", "yaw", "roll", "bend", "axis")]
|
||||
+ [f"head_{c}" for c in ("pitch", "yaw", "roll")]
|
||||
+ [f"{limb}_{c}" for limb in ("right_arm", "left_arm", "right_leg", "left_leg")
|
||||
for c in ("pitch", "yaw", "roll", "bend", "axis")]
|
||||
)
|
||||
INDEX = {name: i for i, name in enumerate(RIG)}
|
||||
|
||||
|
||||
def rig_to_pose(v):
|
||||
g = lambda name: float(v[INDEX[name]])
|
||||
hip = np.array([g("hip_x"), g("hip_y"), g("hip_z")])
|
||||
body_rot = rotation_zyx(g("body_pitch"), g("body_yaw"), g("body_roll"))
|
||||
# The torso's bottom middle (0, 12, 0) in its own space stays on the hips; a bend keeps it fixed.
|
||||
hips_rest = np.array([0, 12, 0.0])
|
||||
body_pivot = hips_rest + hip - body_rot @ hips_rest
|
||||
pose = {
|
||||
"root": Transform(g("root_x"), g("root_y"), g("root_z"), g("root_pitch"), g("root_yaw"), g("root_roll")),
|
||||
"body": Transform(*body_pivot, g("body_pitch"), g("body_yaw"), g("body_roll"), g("body_bend"), g("body_axis")),
|
||||
"head": Transform(*body_pivot, g("head_pitch"), g("head_yaw"), g("head_roll")),
|
||||
}
|
||||
for arm in ("right_arm", "left_arm"):
|
||||
rest = np.array(PIVOTS[arm], float)
|
||||
# Where the shoulder sits on the (unbent) tipped torso; PoseApplier adds the bend's carry.
|
||||
offset = body_pivot + body_rot @ rest - rest
|
||||
pose[arm] = Transform(*offset, *(g(f"{arm}_{c}") for c in ("pitch", "yaw", "roll", "bend", "axis")))
|
||||
for leg in ("right_leg", "left_leg"):
|
||||
pose[leg] = Transform(*hip, *(g(f"{leg}_{c}") for c in ("pitch", "yaw", "roll", "bend", "axis")))
|
||||
return pose
|
||||
|
||||
|
||||
def rest_rig():
|
||||
return np.zeros(len(RIG))
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Draws the fitted pose's outline over the captured frames, to see where the fit is off.
|
||||
|
||||
.venv/bin/python overlay.py <name> [--ticks 0:13:3] [--views y000,y090,y045,top000]
|
||||
|
||||
Writes frames/<name>/overlay.png: rows are views, columns ticks. Red is where only our model is, blue
|
||||
where only theirs is, and grey where both are.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from model import Model, rig_to_pose
|
||||
from render import Camera, render_masks
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("name")
|
||||
parser.add_argument("--ticks", default="0:13:3")
|
||||
parser.add_argument("--views", default="y000,y090,y045,top000")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = Path("frames") / args.name
|
||||
meta = json.loads((base / "views.json").read_text())
|
||||
data = json.loads((base / "rig.json").read_text())
|
||||
by_tick = {t["tick"]: np.array(t["rig"]) for t in data["ticks"]}
|
||||
ticks = [t for t in range(*map(int, args.ticks.split(":"))) if t in by_tick]
|
||||
views = args.views.split(",")
|
||||
scale = 2
|
||||
cams = {v: Camera(meta["views"][v], meta["size"], meta["aspect"], scale) for v in views}
|
||||
model = Model(spacing=0.25)
|
||||
|
||||
tiles = []
|
||||
for v in views:
|
||||
row = []
|
||||
for t in ticks:
|
||||
pts = np.concatenate(list(model.pose(rig_to_pose(by_tick[t])).values()))
|
||||
ours = render_masks(pts, data["calib"], {v: cams[v]})[v]
|
||||
frame = Image.open(base / v / f"{t:04d}.png").reduce(scale)
|
||||
theirs = np.asarray(frame)[:, :, 3] > 127
|
||||
img = np.full(ours.shape + (3,), 30, np.uint8)
|
||||
img[theirs & ours] = (150, 150, 150)
|
||||
img[ours & ~theirs] = (230, 60, 60)
|
||||
img[theirs & ~ours] = (60, 110, 240)
|
||||
row.append(img)
|
||||
tiles.append(row)
|
||||
|
||||
both = np.zeros(tiles[0][0].shape[:2], bool)
|
||||
for row in tiles:
|
||||
for img in row:
|
||||
both |= img.sum(axis=2) > 90
|
||||
ys, xs = np.nonzero(both)
|
||||
y0, y1, x0, x1 = max(ys.min() - 4, 0), ys.max() + 4, max(xs.min() - 4, 0), xs.max() + 4
|
||||
sheet = np.concatenate([np.concatenate([img[y0:y1, x0:x1] for img in row], axis=1) for row in tiles], axis=0)
|
||||
Image.fromarray(sheet).save(base / "overlay.png")
|
||||
print("wrote", base / "overlay.png", "ticks", ticks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Renders model points through the cameras capture.py used, and scores them against captured masks."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.ndimage import distance_transform_edt
|
||||
|
||||
# Model space (Y down, facing -Z) to the viewer's world (Y up, facing +Z): Minecraft's scale(-1, -1, 1)
|
||||
# after turning the player 180 degrees to face south.
|
||||
FLIP = np.diag([1.0, -1.0, -1.0])
|
||||
|
||||
|
||||
class Camera:
|
||||
"""A three.js PerspectiveCamera placed with lookAt, as viewer.js places it."""
|
||||
|
||||
def __init__(self, view, size, aspect, scale):
|
||||
yaw, pitch = np.radians(view["yaw"]), np.radians(view["pitch"])
|
||||
target = np.array(view["target"], float)
|
||||
d = view["distance"]
|
||||
self.position = target + d * np.array([np.cos(pitch) * np.sin(yaw), np.sin(pitch), np.cos(pitch) * np.cos(yaw)])
|
||||
z = self.position - target
|
||||
z /= np.linalg.norm(z)
|
||||
x = np.cross([0, 1, 0], z)
|
||||
x /= np.linalg.norm(x)
|
||||
y = np.cross(z, x)
|
||||
self.rotation = np.stack([x, y, z]) # world to camera
|
||||
self.f = 1 / np.tan(np.radians(view["fov"]) / 2)
|
||||
self.aspect = aspect
|
||||
self.width, self.height = size[0] // scale, size[1] // scale
|
||||
|
||||
def project(self, world):
|
||||
c = (world - self.position) @ self.rotation.T
|
||||
depth = -c[:, 2]
|
||||
nx = self.f / self.aspect * c[:, 0] / depth
|
||||
ny = self.f * c[:, 1] / depth
|
||||
return np.stack([(nx + 1) / 2 * self.width, (1 - ny) / 2 * self.height], axis=1)
|
||||
|
||||
|
||||
def to_world(points, calib):
|
||||
"""calib = (scale, tx, ty, tz): where the viewer puts the model."""
|
||||
return calib[0] * points @ FLIP.T + np.asarray(calib[1:4])
|
||||
|
||||
|
||||
def splat(px, width, height):
|
||||
"""A mask covering every projected point, 2x2 pixels each so the surface has no holes."""
|
||||
mask = np.zeros((height, width), bool)
|
||||
x = np.floor(px[:, 0] - 0.5).astype(int)
|
||||
y = np.floor(px[:, 1] - 0.5).astype(int)
|
||||
for dx in (0, 1):
|
||||
for dy in (0, 1):
|
||||
xs, ys = x + dx, y + dy
|
||||
ok = (xs >= 0) & (xs < width) & (ys >= 0) & (ys < height)
|
||||
mask[ys[ok], xs[ok]] = True
|
||||
return mask
|
||||
|
||||
|
||||
def color_features(rgb):
|
||||
"""Lighting-tolerant colour: hue and saturation as opponent channels divided by brightness, plus
|
||||
log brightness. Shading scales all three channels, which leaves the first two alone."""
|
||||
lum = rgb.mean(axis=-1)
|
||||
o1 = rgb[..., 0] - rgb[..., 1]
|
||||
o2 = (rgb[..., 0] + rgb[..., 1]) / 2 - rgb[..., 2]
|
||||
return np.stack([o1 / (lum + 0.05), o2 / (lum + 0.05), np.log(lum + 0.05)], axis=-1)
|
||||
|
||||
|
||||
class Target:
|
||||
"""One captured view of one tick, downscaled, with the distance maps the score needs."""
|
||||
|
||||
def __init__(self, mask, rgb=None):
|
||||
self.mask = mask
|
||||
self.outside = distance_transform_edt(~mask)
|
||||
self.ys, self.xs = np.nonzero(mask)
|
||||
self.features = None if rgb is None else color_features(rgb)
|
||||
|
||||
|
||||
def downscale(alpha, scale):
|
||||
h, w = alpha.shape
|
||||
blocks = alpha[: h // scale * scale, : w // scale * scale].reshape(h // scale, scale, w // scale, scale)
|
||||
return blocks.mean(axis=(1, 3)) > 127
|
||||
|
||||
|
||||
def downscale_rgb(rgba, scale):
|
||||
"""Mean colour of the covered pixels in each block (0-1)."""
|
||||
h, w = rgba.shape[:2]
|
||||
blocks = rgba[: h // scale * scale, : w // scale * scale].astype(float)
|
||||
blocks = blocks.reshape(h // scale, scale, w // scale, scale, 4)
|
||||
alpha = blocks[..., 3:] / 255
|
||||
return (blocks[..., :3] / 255 * alpha).sum(axis=(1, 3)) / np.maximum(alpha.sum(axis=(1, 3)), 1e-6)
|
||||
|
||||
|
||||
class Capture:
|
||||
"""The frames and cameras of one capture."""
|
||||
|
||||
def __init__(self, directory, scale=4, views=None):
|
||||
self.dir = Path(directory)
|
||||
self.meta = json.loads((self.dir / "views.json").read_text())
|
||||
self.scale = scale
|
||||
self.names = views or list(self.meta["views"])
|
||||
self.cameras = {n: Camera(self.meta["views"][n], self.meta["size"], self.meta["aspect"], scale) for n in self.names}
|
||||
|
||||
def targets(self, tick):
|
||||
out = {}
|
||||
for n in self.names:
|
||||
rgba = np.asarray(Image.open(self.dir / n / f"{tick:04d}.png"))
|
||||
out[n] = Target(downscale(rgba[:, :, 3], self.scale), downscale_rgb(rgba, self.scale))
|
||||
return out
|
||||
|
||||
|
||||
# How much a unit of colour difference counts against a pixel of outline distance, and how much
|
||||
# brightness counts next to hue and saturation.
|
||||
COLOR_WEIGHT = 0.5
|
||||
BRIGHTNESS_WEIGHT = 0.3
|
||||
|
||||
|
||||
def score(points, calib, cameras, targets, per_view=False, colors=None, color_views=None, one_way=False,
|
||||
color_weight=None):
|
||||
"""Mean outline distance in pixels, both ways (our points outside theirs and theirs outside ours),
|
||||
plus, with colours, how different the colours are where both outlines cover a pixel.
|
||||
|
||||
one_way only counts our points outside theirs, for fitting some of the parts: the rest of their
|
||||
outline is still unexplained, and shouldn't pull the parts being fitted towards it."""
|
||||
world = to_world(points, calib)
|
||||
total = {}
|
||||
for name, cam in cameras.items():
|
||||
t = targets[name]
|
||||
px = cam.project(world)
|
||||
xi = np.clip(px[:, 0].astype(int), 0, cam.width - 1)
|
||||
yi = np.clip(px[:, 1].astype(int), 0, cam.height - 1)
|
||||
ours_out = t.outside[yi, xi].mean()
|
||||
if colors is not None and t.features is not None and (color_views is None or name in color_views):
|
||||
depth = -((world - cam.position) @ cam.rotation.T)[:, 2]
|
||||
image, mask = splat_color(px, depth, colors, cam.width, cam.height)
|
||||
both = mask & t.mask
|
||||
diff = np.abs(color_features(image[both]) - t.features[both])
|
||||
color = (diff[:, 0] + diff[:, 1] + BRIGHTNESS_WEIGHT * diff[:, 2]).mean() if both.any() else 0
|
||||
else:
|
||||
mask = splat(px, cam.width, cam.height)
|
||||
color = 0
|
||||
theirs_out = 0 if one_way or not len(t.ys) else distance_transform_edt(~mask)[t.ys, t.xs].mean()
|
||||
total[name] = ours_out + theirs_out + (COLOR_WEIGHT if color_weight is None else color_weight) * color
|
||||
return total if per_view else sum(total.values()) / len(total)
|
||||
|
||||
|
||||
def render_masks(points, calib, cameras):
|
||||
world = to_world(points, calib)
|
||||
return {n: splat(c.project(world), c.width, c.height) for n, c in cameras.items()}
|
||||
|
||||
|
||||
def splat_color(px, depth, colors, width, height):
|
||||
"""Colour image of the nearest point in each pixel (2x2 pixels per point), and its coverage mask."""
|
||||
order = np.argsort(-depth) # far to near, so nearer points are written last and win
|
||||
x = np.floor(px[order, 0] - 0.5).astype(int)
|
||||
y = np.floor(px[order, 1] - 0.5).astype(int)
|
||||
# All four pixels of every point in one write; a write per offset would let far points overwrite
|
||||
# pixels near points filled at another offset.
|
||||
xs = np.stack([x, x + 1, x, x + 1], axis=1).ravel()
|
||||
ys = np.stack([y, y, y + 1, y + 1], axis=1).ravel()
|
||||
cs = np.repeat(colors[order], 4, axis=0)
|
||||
ok = (xs >= 0) & (xs < width) & (ys >= 0) & (ys < height)
|
||||
image = np.zeros((height, width, 3))
|
||||
mask = np.zeros((height, width), bool)
|
||||
image[ys[ok], xs[ok]] = cs[ok]
|
||||
mask[ys[ok], xs[ok]] = True
|
||||
return image, mask
|
||||
|
||||
|
||||
def render_colors(points, colors, calib, cameras):
|
||||
world = to_world(points, calib)
|
||||
out = {}
|
||||
for n, c in cameras.items():
|
||||
depth = -((world - c.position) @ c.rotation.T)[:, 2]
|
||||
out[n] = splat_color(c.project(world), depth, colors, c.width, c.height)
|
||||
return out
|
||||
@@ -0,0 +1,5 @@
|
||||
cma==4.5.0
|
||||
numpy==2.5.3
|
||||
pillow==12.3.0
|
||||
playwright==1.63.0
|
||||
scipy==1.18.1
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tiles frames into a contact sheet: rows are views, columns are ticks, cropped to the player."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
name, views, ticks = sys.argv[1], sys.argv[2].split(","), range(*map(int, sys.argv[3].split(":")))
|
||||
out = sys.argv[4] if len(sys.argv) > 4 else "sheet.png"
|
||||
frames = [[Image.open(Path("frames") / name / v / f"{t:04d}.png") for t in ticks] for v in views]
|
||||
alpha = np.maximum.reduce([np.asarray(f)[:, :, 3] for row in frames for f in row])
|
||||
ys, xs = np.nonzero(alpha)
|
||||
box = (xs.min() - 4, ys.min() - 4, xs.max() + 4, ys.max() + 4)
|
||||
w, h = box[2] - box[0], box[3] - box[1]
|
||||
sheet = Image.new("RGBA", (w * len(ticks), h * len(views)), (40, 40, 52, 255))
|
||||
for r, row in enumerate(frames):
|
||||
for c, f in enumerate(row):
|
||||
sheet.alpha_composite(f.crop(box), (c * w, r * h))
|
||||
sheet.save(out)
|
||||
print(sheet.size)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Fetches a player's skin texture from Mojang, the same one the store viewer shows for that username.
|
||||
|
||||
.venv/bin/python skin.py <username> [out.png]
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def get(url):
|
||||
# curl uses the system's certificate store, which python.org's Python doesn't.
|
||||
return subprocess.run(["curl", "-sSf", url], capture_output=True, check=True).stdout
|
||||
|
||||
|
||||
def fetch(username):
|
||||
"""Returns (png bytes, slim arms)."""
|
||||
uuid = json.loads(get(f"https://api.mojang.com/users/profiles/minecraft/{username}"))["id"]
|
||||
profile = json.loads(get(f"https://sessionserver.mojang.com/session/minecraft/profile/{uuid}"))
|
||||
prop = next(p for p in profile["properties"] if p["name"] == "textures")
|
||||
skin = json.loads(base64.b64decode(prop["value"]))["textures"]["SKIN"]
|
||||
return get(skin["url"]), skin.get("metadata", {}).get("model") == "slim"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
png, slim = fetch(sys.argv[1])
|
||||
out = sys.argv[2] if len(sys.argv) > 2 else f"skins/{sys.argv[1]}.png"
|
||||
open(out, "wb").write(png)
|
||||
print(out, "slim" if slim else "wide")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""End-to-end check of measure.py on frames with a known answer: our own twerk test animation, rendered
|
||||
by model.py as if it were a capture, then measured like one.
|
||||
|
||||
.venv/bin/python synth_test.py make <capture to copy cameras from> [ticks] # writes frames/synth
|
||||
.venv/bin/python measure.py synth --ticks 0:4
|
||||
.venv/bin/python synth_test.py check
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
from model import INDEX, Model, rest_rig, rig_to_pose
|
||||
from render import Camera, render_colors
|
||||
|
||||
# Where the fake viewer puts the model: Minecraft's player scale, feet 25 units below the origin.
|
||||
CALIB = (0.9375, 0, -1.5, 0)
|
||||
SKIN = "Steve"
|
||||
OUT = Path("frames/synth")
|
||||
|
||||
|
||||
def twerk(t):
|
||||
"""TestAnimations.twerk, written in rig terms."""
|
||||
up = 0.5 * (1 - np.cos(t / 8 * 2 * np.pi))
|
||||
thigh, knee, splay, tilt, arch = -1.1 + 0.15 * up, 1.75 - 0.3 * up, 0.55, 1.4, 0.4 + 0.3 * up
|
||||
drop = 12 - 6 * (np.cos(thigh) + np.cos(thigh + knee)) * np.cos(splay)
|
||||
back = -6 * (np.sin(thigh) + np.sin(thigh + knee))
|
||||
chest = tilt - arch
|
||||
v = rest_rig()
|
||||
s = lambda name, value: v.__setitem__(INDEX[name], value)
|
||||
s("hip_y", drop), s("hip_z", back)
|
||||
s("body_pitch", tilt), s("body_bend", arch), s("body_axis", np.pi)
|
||||
s("head_pitch", tilt - chest * 0.5)
|
||||
for arm, roll in (("right_arm", 0.3), ("left_arm", -0.3)):
|
||||
s(f"{arm}_pitch", tilt - chest * 1.1), s(f"{arm}_roll", roll), s(f"{arm}_bend", 0.5)
|
||||
for leg, sign in (("right_leg", 1), ("left_leg", -1)):
|
||||
s(f"{leg}_pitch", thigh), s(f"{leg}_roll", sign * splay), s(f"{leg}_bend", knee)
|
||||
s(f"{leg}_axis", np.pi - sign * 0.35)
|
||||
return v
|
||||
|
||||
|
||||
def skin():
|
||||
return np.asarray(Image.open(f"skins/{SKIN}.png").convert("RGBA"))
|
||||
|
||||
|
||||
def make(source, ticks):
|
||||
meta = json.loads((Path("frames") / source / "views.json").read_text())
|
||||
model = Model(spacing=0.08, skin=skin())
|
||||
cams = {n: Camera(v, meta["size"], meta["aspect"], 1) for n, v in meta["views"].items()}
|
||||
for tick in range(ticks):
|
||||
pts = np.concatenate(list(model.pose(rig_to_pose(twerk(tick))).values()))
|
||||
for name, (image, mask) in render_colors(pts, model.colors, CALIB, cams).items():
|
||||
(OUT / name).mkdir(parents=True, exist_ok=True)
|
||||
rgba = np.concatenate([image * 255, mask[..., None] * 255], axis=-1).astype(np.uint8)
|
||||
Image.fromarray(rgba).save(OUT / name / f"{tick:04d}.png")
|
||||
meta |= {"url": "synthetic twerk", "skin": SKIN, "ticks": ticks, "loop_ticks": None}
|
||||
(OUT / "views.json").write_text(json.dumps(meta, indent=2))
|
||||
print(f"wrote {ticks} ticks to {OUT}")
|
||||
|
||||
|
||||
def check():
|
||||
data = json.loads((OUT / "rig.json").read_text())
|
||||
print("calibration:", np.round(data["calib"], 3), "true:", CALIB)
|
||||
model = Model(skin=skin())
|
||||
for entry in data["ticks"]:
|
||||
truth, got = model.pose(rig_to_pose(twerk(entry["tick"]))), model.pose(rig_to_pose(np.array(entry["rig"])))
|
||||
# The fitted pose sits in the fitted calibration's frame; compare where the viewer shows them.
|
||||
to = lambda p, c: c[0] * p + np.asarray(c[1:]) * [1, -1, -1]
|
||||
parts = []
|
||||
for part in truth:
|
||||
a, b = to(truth[part], CALIB), to(got[part], data["calib"])
|
||||
parts.append((part, np.linalg.norm(a - b, axis=1).mean(), cKDTree(b).query(a)[0].mean()))
|
||||
print(f"tick {entry['tick']}: score {entry['score']:.3f} "
|
||||
+ " ".join(f"{p} {d:.2f}/{s:.2f}" for p, d, s in parts))
|
||||
print("(per part: mean point-for-point / shape-only distance, in model pixels)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1] == "make":
|
||||
make(sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 4)
|
||||
else:
|
||||
check()
|
||||
@@ -0,0 +1,88 @@
|
||||
// Injected before the page loads. It puts the page on a clock we step by hand, and uses three.js's
|
||||
// devtools hook to find the renderer and the camera it draws with. Only the clock and the camera are
|
||||
// touched: the frames are measured from pixels, never from the scene's animation data.
|
||||
(() => {
|
||||
let now = 0;
|
||||
let callbacks = [];
|
||||
let nextId = 1;
|
||||
|
||||
const realDateNow = Date.now.bind(Date);
|
||||
const epoch = realDateNow();
|
||||
performance.now = () => now;
|
||||
Date.now = () => epoch + now;
|
||||
window.requestAnimationFrame = (cb) => {
|
||||
const id = nextId++;
|
||||
callbacks.push({ id, cb });
|
||||
return id;
|
||||
};
|
||||
window.cancelAnimationFrame = (id) => {
|
||||
callbacks = callbacks.filter((c) => c.id !== id);
|
||||
};
|
||||
|
||||
// The camera placement to force on every render: a point the camera orbits, its distance, and the
|
||||
// yaw/pitch it looks from (degrees; yaw 0 looks at the model's front).
|
||||
let view = null;
|
||||
let lastFrame = null;
|
||||
|
||||
const capture = {
|
||||
renderers: [],
|
||||
camera: null,
|
||||
/** Advances the clock by ms and runs one animation frame. Returns the frame as a PNG data URL. */
|
||||
step(ms, grab) {
|
||||
now += ms;
|
||||
lastFrame = null;
|
||||
const due = callbacks;
|
||||
callbacks = [];
|
||||
for (const { cb } of due) {
|
||||
try { cb(now); } catch (e) { console.error(e); }
|
||||
}
|
||||
return grab ? lastFrame : null;
|
||||
},
|
||||
time() { return now; },
|
||||
setView(v) { view = v; },
|
||||
camera_info() {
|
||||
const cam = capture.camera;
|
||||
return cam && { fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far,
|
||||
position: cam.position.toArray(), quaternion: cam.quaternion.toArray() };
|
||||
},
|
||||
};
|
||||
window.__capture = capture;
|
||||
|
||||
function place(camera) {
|
||||
const toRad = Math.PI / 180;
|
||||
const yaw = view.yaw * toRad;
|
||||
const pitch = view.pitch * toRad;
|
||||
const [tx, ty, tz] = view.target;
|
||||
camera.position.set(
|
||||
tx + view.distance * Math.cos(pitch) * Math.sin(yaw),
|
||||
ty + view.distance * Math.sin(pitch),
|
||||
tz + view.distance * Math.cos(pitch) * Math.cos(yaw));
|
||||
camera.up.set(0, 1, 0);
|
||||
camera.lookAt(tx, ty, tz);
|
||||
if (view.fov) {
|
||||
camera.fov = view.fov;
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
camera.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
const hook = new EventTarget();
|
||||
hook.addEventListener('observe', (event) => {
|
||||
const obj = event.detail;
|
||||
if (obj && obj.isWebGLRenderer && !capture.renderers.includes(obj)) {
|
||||
capture.renderers.push(obj);
|
||||
const render = obj.render.bind(obj);
|
||||
obj.render = (scene, camera) => {
|
||||
capture.camera = camera;
|
||||
if (view) {
|
||||
place(camera);
|
||||
}
|
||||
const result = render(scene, camera);
|
||||
// Read the drawing buffer in the same task as the draw, before the browser clears it.
|
||||
lastFrame = obj.domElement.toDataURL('image/png');
|
||||
return result;
|
||||
};
|
||||
}
|
||||
});
|
||||
window.__THREE_DEVTOOLS__ = hook;
|
||||
})();
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Converts Emotecraft's binary .emotecraft files to the Emotecraft JSON our EmotecraftLoader reads.
|
||||
|
||||
python3 convert.py <file.emotecraft>... [--out-dir DIR]
|
||||
|
||||
Writes <id>.json next to each file (or into DIR), with the id made from the file's name, and prints
|
||||
what each file holds. Standard library only.
|
||||
|
||||
The binary is Emotecraft's network packet (EmotePacket): an int version, a purpose byte and a count of
|
||||
sub-packets, each an id byte, a version byte, an int size and its data. Sub-packet 0 is the animation
|
||||
in player-animator's legacy format (PlayerAnimationLibrary's LegacyAnimationBinary), whose values are
|
||||
the same raw values Emotecraft JSON holds (radians, absolute pivots, the whole player in blocks), so
|
||||
they're written out as they are with "degrees": false. Sub-packet 17 is the name, description and
|
||||
author (header version 1 and up). Anything else (the icon, the scale channels, easing arguments) is left out, since our loader
|
||||
doesn't read it.
|
||||
|
||||
One thing moves: in the legacy format the whole-player part "body" also carries the torso's bend.
|
||||
In JSON, "body" is only the whole player, so its bend goes to "torso", as PlayerAnimationLibrary does.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ANIMATION, HEADER = 0, 17
|
||||
AXES = ["x", "y", "z", "pitch", "yaw", "roll"]
|
||||
BEND_AXES = ["axis", "bend"]
|
||||
SCALE_AXES = ["scaleX", "scaleY", "scaleZ"]
|
||||
# The fixed part order before version 2 named its parts.
|
||||
V1_PARTS = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"]
|
||||
# PlayerAnimationLibrary's EasingType ids. Our Easing.fromName reads the same names.
|
||||
EASINGS = {
|
||||
0: "LINEAR", 1: "CONSTANT", 6: "EASEINSINE", 7: "EASEOUTSINE", 8: "EASEINOUTSINE",
|
||||
9: "EASEINCUBIC", 10: "EASEOUTCUBIC", 11: "EASEINOUTCUBIC", 12: "EASEINQUAD", 13: "EASEOUTQUAD",
|
||||
14: "EASEINOUTQUAD", 15: "EASEINQUART", 16: "EASEOUTQUART", 17: "EASEINOUTQUART",
|
||||
18: "EASEINQUINT", 19: "EASEOUTQUINT", 20: "EASEINOUTQUINT", 21: "EASEINEXPO", 22: "EASEOUTEXPO",
|
||||
23: "EASEINOUTEXPO", 24: "EASEINCIRC", 25: "EASEOUTCIRC", 26: "EASEINOUTCIRC", 27: "EASEINBACK",
|
||||
28: "EASEOUTBACK", 29: "EASEINOUTBACK", 30: "EASEINELASTIC", 31: "EASEOUTELASTIC",
|
||||
32: "EASEINOUTELASTIC", 33: "EASEINBOUNCE", 34: "EASEOUTBOUNCE", 35: "EASEINOUTBOUNCE",
|
||||
}
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, data):
|
||||
self.data, self.pos = data, 0
|
||||
|
||||
def take(self, fmt):
|
||||
values = struct.unpack_from(">" + fmt, self.data, self.pos)
|
||||
self.pos += struct.calcsize(">" + fmt)
|
||||
return values[0] if len(values) == 1 else values
|
||||
|
||||
def bool(self):
|
||||
return self.take("b") != 0
|
||||
|
||||
def string(self):
|
||||
size = self.take("i")
|
||||
text = self.data[self.pos:self.pos + size].decode("utf-8")
|
||||
self.pos += size
|
||||
return text
|
||||
|
||||
|
||||
def read_channel(r, version, keyframe_size):
|
||||
"""[(tick, value, easing id)], or None when the channel is off."""
|
||||
if version >= 2:
|
||||
enabled, count = r.bool(), r.take("i")
|
||||
else:
|
||||
count = r.take("i")
|
||||
enabled = count >= 0
|
||||
if not enabled:
|
||||
r.pos += max(count, 0) * keyframe_size
|
||||
return None
|
||||
keys = []
|
||||
for _ in range(count):
|
||||
start = r.pos
|
||||
tick, value, easing = r.take("ifb")
|
||||
keys.append((tick, value, easing))
|
||||
r.pos = start + keyframe_size
|
||||
return keys
|
||||
|
||||
|
||||
def read_part(r, name, version, keyframe_size):
|
||||
channels = {axis: read_channel(r, version, keyframe_size) for axis in AXES}
|
||||
# Every part but the head (and held items) has bend channels.
|
||||
if name not in ("head", "left_item", "right_item", "leftItem", "rightItem"):
|
||||
for axis in BEND_AXES:
|
||||
channels[axis] = read_channel(r, version, keyframe_size)
|
||||
if version >= 3:
|
||||
for axis in SCALE_AXES:
|
||||
channels[axis] = read_channel(r, version, keyframe_size)
|
||||
return {axis: keys for axis, keys in channels.items() if keys}
|
||||
|
||||
|
||||
def read_animation(data, version):
|
||||
r = Reader(data)
|
||||
r.take("i") # the tick the packet was sent at
|
||||
begin, end, stop = r.take("iii")
|
||||
loop = r.bool()
|
||||
return_tick = r.take("i")
|
||||
ease_before = r.bool()
|
||||
r.bool() # a tag nothing reads
|
||||
keyframe_size = r.take("b")
|
||||
parts = {}
|
||||
if version >= 2:
|
||||
for _ in range(r.take("i")):
|
||||
name = r.string()
|
||||
parts[name] = read_part(r, name, version, keyframe_size)
|
||||
else:
|
||||
for name in V1_PARTS:
|
||||
parts[name] = read_part(r, name, version, keyframe_size)
|
||||
return {"begin": begin, "end": end, "stop": stop, "loop": loop, "return": return_tick,
|
||||
"ease_before": ease_before, "parts": {n: p for n, p in parts.items() if p}}
|
||||
|
||||
|
||||
def plain_text(component):
|
||||
"""The header's strings are Minecraft text components in JSON: a quoted string, or an object
|
||||
with "text", or a translation with a "fallback"."""
|
||||
try:
|
||||
value = json.loads(component)
|
||||
except ValueError:
|
||||
return component
|
||||
if isinstance(value, dict):
|
||||
return value.get("text") or value.get("fallback") or value.get("translate", "")
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def read_file(path):
|
||||
r = Reader(Path(path).read_bytes())
|
||||
r.take("i") # network version
|
||||
r.take("b") # purpose
|
||||
info, animation, skipped = {}, None, []
|
||||
for _ in range(r.take("B")):
|
||||
packet, version, size = r.take("bbi")
|
||||
data = r.data[r.pos:r.pos + size]
|
||||
r.pos += size
|
||||
if packet == ANIMATION:
|
||||
animation = read_animation(data, version)
|
||||
elif packet == HEADER:
|
||||
h = Reader(data)
|
||||
info = {key: plain_text(h.string()) for key in ("name", "description", "author")}
|
||||
else:
|
||||
skipped.append(packet)
|
||||
if animation is None:
|
||||
raise ValueError(f"{path}: no legacy animation sub-packet (maybe the newer format)")
|
||||
return info, animation, skipped
|
||||
|
||||
|
||||
def to_json(info, animation):
|
||||
parts = {name: dict(channels) for name, channels in animation["parts"].items()}
|
||||
body = parts.get("body", {})
|
||||
for axis in BEND_AXES:
|
||||
if axis in body:
|
||||
parts.setdefault("torso", {})[axis] = body.pop(axis)
|
||||
moves = {}
|
||||
for name, channels in parts.items():
|
||||
for axis, keys in channels.items():
|
||||
if axis in SCALE_AXES:
|
||||
continue
|
||||
for tick, value, easing in keys:
|
||||
move = moves.setdefault((tick, EASINGS.get(easing, "LINEAR")), {})
|
||||
move.setdefault(name, {})[axis] = round(value, 6)
|
||||
return {
|
||||
"version": 3,
|
||||
"name": info.get("name", ""),
|
||||
"description": info.get("description", ""),
|
||||
"author": info.get("author", ""),
|
||||
"emote": {
|
||||
"beginTick": animation["begin"],
|
||||
"endTick": animation["end"],
|
||||
"stopTick": animation["stop"],
|
||||
"isLoop": animation["loop"],
|
||||
"returnTick": animation["return"],
|
||||
"easeBeforeKeyframe": animation["ease_before"],
|
||||
"degrees": False,
|
||||
"moves": [{"tick": tick, "easing": easing, **move} for (tick, easing), move in sorted(moves.items())],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def emote_id(name):
|
||||
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "emote"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("files", nargs="+")
|
||||
parser.add_argument("--out-dir")
|
||||
args = parser.parse_args()
|
||||
failed = False
|
||||
for path in args.files:
|
||||
try:
|
||||
info, animation, skipped = read_file(path)
|
||||
except Exception as e: # report and go on to the next file
|
||||
print(f"{path}: {e}", file=sys.stderr)
|
||||
failed = True
|
||||
continue
|
||||
name = info.get("name") or Path(path).stem
|
||||
# The file's name, since emote names can be in any language.
|
||||
out = Path(args.out_dir or Path(path).parent) / f"{emote_id(Path(path).stem)}.json"
|
||||
out.write_text(json.dumps(to_json(info, animation), indent=2))
|
||||
bends = sorted(n for n, c in animation["parts"].items() if "bend" in c)
|
||||
scaled = sorted(n for n, c in animation["parts"].items() if any(a in c for a in SCALE_AXES))
|
||||
print(f"{Path(path).name} -> {out.name}: '{name}' by {info.get('author', '?')}, "
|
||||
f"ticks {animation['begin']}-{animation['end']} (stop {animation['stop']}), "
|
||||
f"{'loops' if animation['loop'] else 'once'}, parts {sorted(animation['parts'])}, "
|
||||
f"bends {bends or 'none'}" + (f", scale on {scaled} (dropped)" if scaled else ""))
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user