bvh-to-fbx.py applies a --frames range it is handed and chooses none
Optional '--frames A B' after the clip name sets the scene's frame range to A..B (Blender frames, from 1); outside the take, or malformed, it exits 4 or 3 and writes nothing. Without it the whole take, as before. The test's BVH builder is shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f5fa841d58
commit
303cb28683
2 changed files with 57 additions and 14 deletions
|
|
@ -1,6 +1,9 @@
|
|||
# Converts one Kimodo standard-T-pose BVH to one FBX. Blender converts formats and nothing else: no retarget, no
|
||||
# rename, no key reduction, no resample, no root-motion bake. Run by the daemon (scripts/vili-daemon.mjs exportClip):
|
||||
# blender -b --factory-startup --python-use-system-env --python-exit-code 1 --python bvh-to-fbx.py -- <bvh> <fbx> <clipName>
|
||||
# rename, no key reduction, no resample, no root-motion bake. It applies the frame range it is handed and chooses
|
||||
# none: the whole take unless the daemon passes --frames. Run by the daemon (scripts/vili-daemon.mjs exportClip):
|
||||
# blender -b --factory-startup --python-use-system-env --python-exit-code 1 --python bvh-to-fbx.py -- <bvh> <fbx> <clipName> [--frames <A> <B>]
|
||||
# Frames are Blender's, counted from 1: the BVH's frame k is Blender's k + 1. Exit 3 is an argument or a hash seed it
|
||||
# will not take, exit 4 a range outside the take.
|
||||
# with PYTHONHASHSEED=0 in the environment. The settings are Blender API parameters, cross-checked against the
|
||||
# 'unity' preset of wifimetas/kimodo-mcp blender_export.py (Apache-2.0, 83b5361); no code is copied.
|
||||
import datetime
|
||||
|
|
@ -15,7 +18,9 @@ import bpy
|
|||
if sys.flags.hash_randomization != 0:
|
||||
sys.exit(3)
|
||||
|
||||
bvh, fbx, clip = sys.argv[sys.argv.index("--") + 1:]
|
||||
bvh, fbx, clip, *window = sys.argv[sys.argv.index("--") + 1:]
|
||||
if window and not (len(window) == 3 and window[0] == "--frames" and all(arg.isdigit() for arg in window[1:])):
|
||||
sys.exit(3)
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
|
|
@ -38,6 +43,10 @@ action = armature.animation_data.action
|
|||
# The FBX take takes the scene's name.
|
||||
armature.name = armature.data.name = action.name = scene.name = clip
|
||||
first, last = (int(frame) for frame in action.frame_range)
|
||||
if window:
|
||||
first, last = int(window[1]), int(window[2])
|
||||
if not (int(action.frame_range[0]) <= first < last <= int(action.frame_range[1])):
|
||||
sys.exit(4)
|
||||
scene.frame_start, scene.frame_end = first, last
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
|
|
|
|||
|
|
@ -155,6 +155,26 @@ class BvhToFbx(unittest.TestCase):
|
|||
line = next(line for line in run.stdout.splitlines() if line.startswith("INSPECT "))
|
||||
return json.loads(line[len("INSPECT "):])
|
||||
|
||||
def walk(self, name, frames):
|
||||
"""A BVH of the rest pose's skeleton whose Hips travel one metre, accelerating, and whose Spine1 is turned."""
|
||||
text = self.rest.read_text()
|
||||
header = text[:text.index("MOTION")]
|
||||
channels = sum(int(n) for n in re.findall(r"CHANNELS\s+(\d+)", header))
|
||||
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 / f"{name}.bvh"
|
||||
bvh.write_text(f"{header}MOTION
|
||||
Frames: {frames}
|
||||
Frame Time: 0.0333333
|
||||
" + "
|
||||
".join(rows) + "
|
||||
")
|
||||
return bvh
|
||||
|
||||
def test_rest_pose_keeps_the_bvh_joint_names_and_height(self):
|
||||
fbx = self.dir / "rest.fbx"
|
||||
run = convert(self.rest, fbx, "SOMA77_Rest")
|
||||
|
|
@ -189,18 +209,8 @@ class BvhToFbx(unittest.TestCase):
|
|||
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")
|
||||
bvh = self.walk("walk", frames)
|
||||
fbx = self.dir / "walk.fbx"
|
||||
run = convert(bvh, fbx, "walk")
|
||||
self.assertEqual(run.returncode, 0, run.stderr[-2000:])
|
||||
|
|
@ -222,6 +232,30 @@ class BvhToFbx(unittest.TestCase):
|
|||
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")
|
||||
|
||||
def test_frames_exports_that_range_and_nothing_else(self):
|
||||
bvh = self.walk("long", 61)
|
||||
fbx = self.dir / "window.fbx"
|
||||
run = blender("--python", str(SCRIPT), "--", str(bvh), str(fbx), "window", "--frames", "5", "40")
|
||||
self.assertEqual(run.returncode, 0, run.stderr[-2000:])
|
||||
self.assertEqual(result(run), {"bones": 78, "frames": [5, 40], "fps": 30})
|
||||
seen = self.inspect(fbx, bvh)
|
||||
# The BVH's frame k is Blender's k + 1, so frames 5..40 are BVH rows 4..39 and Hips z is 100 (k/60)^2 cm.
|
||||
self.assertAlmostEqual(seen["hips_travel"], (39 ** 2 - 4 ** 2) / 60 ** 2, delta=0.002, msg=json.dumps(seen))
|
||||
self.assertEqual(set(fbx_key_counts(fbx)), {36}, "one key per frame of the window")
|
||||
whole = self.dir / "whole.fbx"
|
||||
self.assertEqual(result(convert(bvh, whole, "whole"))["frames"], [1, 61], "without --frames the take is whole")
|
||||
|
||||
def test_frames_outside_the_take_or_malformed_exit_without_a_file(self):
|
||||
bvh = self.walk("short", 31)
|
||||
for index, (frames, exit_code) in enumerate((
|
||||
(("0", "10"), 4), (("5", "32"), 4), (("10", "10"), 4), (("20", "10"), 4),
|
||||
(("5",), 3), (("a", "9"), 3), (("-1", "9"), 3),
|
||||
)):
|
||||
fbx = self.dir / f"refused{index}.fbx"
|
||||
run = blender("--python", str(SCRIPT), "--", str(bvh), str(fbx), "refused", "--frames", *frames)
|
||||
self.assertEqual(run.returncode, exit_code, frames)
|
||||
self.assertFalse(fbx.exists(), frames)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue