227 lines
10 KiB
Python
227 lines
10 KiB
Python
"""Runs scripts/bvh-to-fbx.py in the real Blender the daemon runs (VILI_BLENDER), with the environment the daemon
|
|
builds for it (scripts/vili-daemon.mjs exportEnv). The recipe's test-worker step runs this against the unpacked
|
|
blender tree; there is no skip: without VILI_BLENDER it errors.
|
|
|
|
VILI_BLENDER=<blender> python3.12 -m unittest scripts/test_bvh_to_fbx.py
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
BLENDER = os.environ["VILI_BLENDER"]
|
|
SCRIPT = pathlib.Path(__file__).resolve().with_name("bvh-to-fbx.py")
|
|
|
|
# Reads an FBX back into a factory scene and reports what the avatar cut will see. With a BVH as its second
|
|
# argument it also imports that BVH the way Blender itself does (native Euler order), as the reference pose.
|
|
INSPECT = r'''
|
|
import bpy, json, sys
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
fbx = argv[0]
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_scene.fbx(filepath=fbx)
|
|
scene = bpy.context.scene
|
|
arm = next(o for o in scene.objects if o.type == "ARMATURE")
|
|
|
|
def world(name, frame):
|
|
scene.frame_set(frame)
|
|
return (arm.matrix_world @ arm.pose.bones[name].matrix).translation.copy()
|
|
|
|
out = {
|
|
"bones": [b.name for b in arm.data.bones],
|
|
"scale": list(arm.scale),
|
|
"hips_rest": arm.data.bones["Hips"].head_local.z * arm.scale.z,
|
|
}
|
|
action = arm.animation_data.action if arm.animation_data else None
|
|
if action is not None:
|
|
out["action"] = action.name
|
|
first, last = (int(f) for f in action.frame_range)
|
|
out["frame_range"] = [first, last]
|
|
# The importer starts the take a frame late: sample i is at frame first + 1 + i, and the range ends before the
|
|
# last key. Read two frames past it.
|
|
frames = range(first, last + 3)
|
|
out["hips_travel"] = max((world("Hips", f) - world("Hips", first)).length for f in frames)
|
|
out["hips_mid"] = (world("Hips", first + 1 + 15) - world("Hips", first)).length
|
|
out["hips_curve"] = [list(world("Hips", first + 1 + i)) for i in range(31)]
|
|
out["root_wander"] = max((world("Root", f) - world("Root", first)).length for f in frames)
|
|
out["head_from_hips"] = list(world("Head", first + 1 + 15) - world("Hips", first + 1 + 15))
|
|
if len(argv) > 1:
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_anim.bvh(filepath=argv[1], global_scale=0.01, axis_forward="-Z", axis_up="Y", rotate_mode="NATIVE",
|
|
use_fps_scale=False, update_scene_fps=True, frame_start=1)
|
|
scene = bpy.context.scene
|
|
arm = bpy.context.view_layer.objects.active
|
|
out["reference_head_from_hips"] = list(world("Head", 1 + 15) - world("Hips", 1 + 15))
|
|
out["reference_hips_curve"] = [list(world("Hips", 1 + i)) for i in range(31)]
|
|
print("INSPECT " + json.dumps(out))
|
|
'''
|
|
|
|
|
|
def export_env():
|
|
env = {
|
|
"HOME": "/nonexistent",
|
|
"PYTHONHASHSEED": "0",
|
|
"PYTHONNOUSERSITE": "1",
|
|
"LD_LIBRARY_PATH": os.path.join(os.path.dirname(BLENDER), "lib"),
|
|
}
|
|
for name in ("PATH", "LANG"):
|
|
if name in os.environ:
|
|
env[name] = os.environ[name]
|
|
return env
|
|
|
|
|
|
def blender(*argv, env=None):
|
|
return subprocess.run(
|
|
[BLENDER, "-b", "--factory-startup", "--python-use-system-env", "--python-exit-code", "1", *argv],
|
|
env=export_env() if env is None else env, capture_output=True, text=True, timeout=600,
|
|
)
|
|
|
|
|
|
def convert(bvh, fbx, clip, env=None):
|
|
return blender("--python", str(SCRIPT), "--", str(bvh), str(fbx), clip, env=env)
|
|
|
|
|
|
def result(run):
|
|
line = next(line for line in run.stdout.splitlines() if line.startswith("VILI-FBX "))
|
|
return json.loads(line[len("VILI-FBX "):])
|
|
|
|
|
|
def unit_scale_factor(fbx):
|
|
"""GlobalSettings' UnitScaleFactor from a binary FBX: the property is four strings, then a double."""
|
|
raw = pathlib.Path(fbx).read_bytes()
|
|
position = raw.index(b"UnitScaleFactor") + len(b"UnitScaleFactor")
|
|
for _ in range(3): # type "double", label "Number", flags ""
|
|
assert raw[position:position + 1] == b"S"
|
|
position += 5 + struct.unpack("<I", raw[position + 1:position + 5])[0]
|
|
assert raw[position:position + 1] == b"D"
|
|
return struct.unpack("<d", raw[position + 1:position + 9])[0]
|
|
|
|
|
|
def fbx_header_time(fbx):
|
|
"""The FBXHeaderExtension's CreationTimeStamp fields (Year ... Millisecond), read from a binary FBX. Each is a node
|
|
whose one property is an int32: name length, name, type code 'I', value."""
|
|
raw = pathlib.Path(fbx).read_bytes()
|
|
position = raw.index(b"CreationTimeStamp")
|
|
fields = []
|
|
for name in ("Year", "Month", "Day", "Hour", "Minute", "Second", "Millisecond"):
|
|
tag = bytes([len(name)]) + name.encode()
|
|
position = raw.index(tag, position) + len(tag)
|
|
assert raw[position:position + 1] == b"I", name
|
|
fields.append(struct.unpack("<i", raw[position + 1:position + 5])[0])
|
|
position += 5
|
|
return fields
|
|
|
|
|
|
def fbx_key_counts(fbx):
|
|
"""The key count of every animation curve in a binary FBX: each KeyTime node's one property is an int64 array,
|
|
whose header is type code 'l', element count, encoding, compressed length."""
|
|
raw = pathlib.Path(fbx).read_bytes()
|
|
counts, position = [], 0
|
|
while True:
|
|
position = raw.find(b"\x07KeyTime", position)
|
|
if position < 0:
|
|
return counts
|
|
position += len(b"\x07KeyTime")
|
|
assert raw[position:position + 1] == b"l"
|
|
counts.append(struct.unpack("<I", raw[position + 1:position + 5])[0])
|
|
|
|
|
|
def rest_bvh():
|
|
spec = importlib.util.find_spec("kimodo")
|
|
return pathlib.Path(spec.submodule_search_locations[0]) / "assets" / "skeletons" / "somaskel77" / "somaskel77_standard_tpose.bvh"
|
|
|
|
|
|
class BvhToFbx(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.tmp = tempfile.TemporaryDirectory()
|
|
cls.dir = pathlib.Path(cls.tmp.name)
|
|
cls.rest = rest_bvh()
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.tmp.cleanup()
|
|
|
|
def inspect(self, fbx, bvh=None):
|
|
script = self.dir / "inspect.py"
|
|
script.write_text(INSPECT)
|
|
run = blender("--python", str(script), "--", str(fbx), *([str(bvh)] if bvh else []))
|
|
self.assertEqual(run.returncode, 0, run.stderr[-2000:])
|
|
line = next(line for line in run.stdout.splitlines() if line.startswith("INSPECT "))
|
|
return json.loads(line[len("INSPECT "):])
|
|
|
|
def test_rest_pose_keeps_the_bvh_joint_names_and_height(self):
|
|
fbx = self.dir / "rest.fbx"
|
|
run = convert(self.rest, fbx, "SOMA77_Rest")
|
|
self.assertEqual(run.returncode, 0, run.stderr[-2000:])
|
|
self.assertEqual(result(run), {"bones": 78, "frames": [1, 1], "fps": 30})
|
|
joints = re.findall(r"^\s*(?:ROOT|JOINT)\s+(\S+)", self.rest.read_text(), re.M)
|
|
seen = self.inspect(fbx)
|
|
self.assertEqual(len(joints), 78)
|
|
self.assertEqual(sorted(seen["bones"]), sorted(joints))
|
|
self.assertFalse(any("mixamorig" in name for name in seen["bones"]))
|
|
for axis in seen["scale"]:
|
|
self.assertAlmostEqual(axis, 1.0, places=4)
|
|
self.assertAlmostEqual(seen["hips_rest"], 1.0, delta=0.01)
|
|
self.assertEqual(unit_scale_factor(fbx), 100.0, "the file says its unit is the metre")
|
|
|
|
def test_two_exports_of_one_bvh_are_byte_identical(self):
|
|
first, second = self.dir / "a.fbx", self.dir / "b.fbx"
|
|
for target in (first, second):
|
|
self.assertEqual(convert(self.rest, target, "twin").returncode, 0)
|
|
self.assertEqual(first.read_bytes(), second.read_bytes())
|
|
|
|
def test_the_header_is_stamped_with_the_epoch_not_the_clock(self):
|
|
fbx = self.dir / "epoch.fbx"
|
|
self.assertEqual(convert(self.rest, fbx, "epoch").returncode, 0)
|
|
self.assertEqual(fbx_header_time(fbx), [1970, 1, 1, 0, 0, 0, 0])
|
|
|
|
def test_without_a_fixed_hash_seed_the_script_exits_3(self):
|
|
env = export_env()
|
|
del env["PYTHONHASHSEED"]
|
|
target = self.dir / "unseeded.fbx"
|
|
self.assertEqual(convert(self.rest, target, "unseeded", env=env).returncode, 3)
|
|
self.assertFalse(target.exists())
|
|
|
|
def test_a_clip_keeps_its_travel_timing_pose_and_name(self):
|
|
text = self.rest.read_text()
|
|
header = text[:text.index("MOTION")]
|
|
channels = sum(int(n) for n in re.findall(r"CHANNELS\s+(\d+)", header))
|
|
frames = 31
|
|
rows = []
|
|
for frame in range(frames):
|
|
row = ["0"] * channels
|
|
row[8] = repr(100.0 * (frame / (frames - 1)) ** 2) # Hips Zposition, cm: one metre, accelerating
|
|
row[12], row[13], row[14] = "30", "20", "40" # Spine1 Z, Y, X rotation: the Euler order matters
|
|
rows.append(" ".join(row))
|
|
bvh = self.dir / "walk.bvh"
|
|
bvh.write_text(f"{header}MOTION\nFrames: {frames}\nFrame Time: 0.0333333\n" + "\n".join(rows) + "\n")
|
|
fbx = self.dir / "walk.fbx"
|
|
run = convert(bvh, fbx, "walk")
|
|
self.assertEqual(run.returncode, 0, run.stderr[-2000:])
|
|
self.assertEqual(result(run), {"bones": 78, "frames": [1, frames], "fps": 30})
|
|
seen = self.inspect(fbx, bvh)
|
|
self.assertIn("walk", seen["action"], "the clip name reaches the FBX take")
|
|
self.assertGreaterEqual(seen["frame_range"][1], frames, "the take keeps the whole clip")
|
|
self.assertLessEqual(seen["frame_range"][1], frames + 2, "the take is not the scene's default range")
|
|
self.assertAlmostEqual(seen["hips_travel"], 1.0, delta=0.001, msg=json.dumps(seen))
|
|
self.assertAlmostEqual(seen["hips_mid"], 0.25, delta=0.002, msg="every key is kept, none simplified away")
|
|
self.assertLess(seen["root_wander"], 1e-4)
|
|
for index, (got, want) in enumerate(zip(seen["hips_curve"], seen["reference_hips_curve"])):
|
|
for axis in range(3):
|
|
self.assertAlmostEqual(got[axis] - seen["hips_curve"][0][axis], want[axis] - seen["reference_hips_curve"][0][axis],
|
|
delta=2e-4, msg=f"frame {index} axis {axis}: no key is dropped or resampled")
|
|
counts = fbx_key_counts(fbx)
|
|
self.assertTrue(counts, "the FBX carries animation curves")
|
|
self.assertEqual(set(counts), {frames}, "every curve keeps one key per BVH frame, none simplified away")
|
|
for got, want in zip(seen["head_from_hips"], seen["reference_head_from_hips"]):
|
|
self.assertAlmostEqual(got, want, delta=0.002, msg="the rotated pose is Blender's own native reading of the BVH")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|