- autorig: landmark detection from vertex cloud (markers JSON override), mixamorig 22-bone skeleton fit, bone-heat weights + nearest-bone rescue - retarget: world-space quaternion delta transfer, hip-height scale compensation, fuzzy/explicit bone mapping (CMU map bundled) - smoke: procedural test humanoid + synthetic BVH -> rig -> retarget -> verify - deploy: push to gitea, pull + smoke on m3ultra and m1ultra Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
5.2 KiB
Python
Executable File
167 lines
5.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""MIRPAMO — local Mixamo-style character pipeline.
|
|
|
|
Orchestrates headless Blender. Subcommands:
|
|
|
|
mirpamo rig <mesh> -o rigged.glb [--markers m.json] [--name N]
|
|
mirpamo anim <rigged.glb> <clip> [<clip> ...] -o out.glb
|
|
[--bonemap map.json] [--inplace] [--fps 30]
|
|
mirpamo batch <rigged.glb> <clips_dir> -o out_dir [--bonemap ...] [--inplace]
|
|
mirpamo verify <file.glb> [--expect-anim]
|
|
mirpamo smoke [--keep]
|
|
|
|
Blender binary resolution: $MIRPAMO_BLENDER, then
|
|
/Applications/Blender.app/Contents/MacOS/Blender, then `blender` on PATH.
|
|
"""
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
BPY = os.path.join(ROOT, "bpy")
|
|
|
|
|
|
def blender_bin():
|
|
cand = os.environ.get("MIRPAMO_BLENDER")
|
|
if cand and os.path.exists(cand):
|
|
return cand
|
|
mac_default = "/Applications/Blender.app/Contents/MacOS/Blender"
|
|
if os.path.exists(mac_default):
|
|
return mac_default
|
|
path = shutil.which("blender")
|
|
if path:
|
|
return path
|
|
sys.exit("mirpamo: Blender not found. Install Blender or set MIRPAMO_BLENDER.")
|
|
|
|
|
|
def run_stage(script, stage_argv):
|
|
cmd = [blender_bin(), "-b", "--python", os.path.join(BPY, script),
|
|
"--", *stage_argv]
|
|
proc = subprocess.run(cmd)
|
|
if proc.returncode != 0:
|
|
sys.exit(f"mirpamo: stage {script} failed (exit {proc.returncode})")
|
|
|
|
|
|
def cmd_rig(a):
|
|
argv = ["--in", a.mesh, "--out", a.out]
|
|
if a.markers:
|
|
argv += ["--markers", a.markers]
|
|
if a.name:
|
|
argv += ["--name", a.name]
|
|
run_stage("autorig.py", argv)
|
|
|
|
|
|
def anim_argv(rig, clips, out, a):
|
|
argv = ["--rig", rig, "--out", out]
|
|
for c in clips:
|
|
argv += ["--clip", c]
|
|
if a.bonemap:
|
|
argv += ["--bonemap", a.bonemap]
|
|
if a.inplace:
|
|
argv += ["--inplace"]
|
|
if getattr(a, "fps", None):
|
|
argv += ["--fps", str(a.fps)]
|
|
return argv
|
|
|
|
|
|
def cmd_anim(a):
|
|
run_stage("retarget.py", anim_argv(a.rig, a.clips, a.out, a))
|
|
|
|
|
|
def cmd_batch(a):
|
|
clips = sorted(
|
|
p for ext in ("*.bvh", "*.fbx", "*.glb")
|
|
for p in glob.glob(os.path.join(a.clips_dir, ext)))
|
|
if not clips:
|
|
sys.exit(f"mirpamo: no clips (.bvh/.fbx/.glb) in {a.clips_dir}")
|
|
os.makedirs(a.out, exist_ok=True)
|
|
base = os.path.splitext(os.path.basename(a.rig))[0]
|
|
for c in clips:
|
|
cn = os.path.splitext(os.path.basename(c))[0]
|
|
out = os.path.join(a.out, f"{base}@{cn}.glb")
|
|
print(f"== {c} -> {out}")
|
|
run_stage("retarget.py", anim_argv(a.rig, [c], out, a))
|
|
|
|
|
|
def cmd_verify(a):
|
|
argv = ["--in", a.file]
|
|
if a.expect_anim:
|
|
argv += ["--expect-anim"]
|
|
run_stage("verify_glb.py", argv)
|
|
|
|
|
|
def cmd_smoke(a):
|
|
out = os.path.join(ROOT, "out", "smoke")
|
|
os.makedirs(out, exist_ok=True)
|
|
mesh = os.path.join(out, "test_human.glb")
|
|
clip = os.path.join(out, "test_clip.bvh")
|
|
rigged = os.path.join(out, "test_human_rigged.glb")
|
|
final = os.path.join(out, "test_human_walking.glb")
|
|
|
|
print("== smoke 1/5: generate test mesh")
|
|
run_stage("smoke_mesh.py", ["--out", mesh])
|
|
print("== smoke 2/5: generate test clip")
|
|
subprocess.run([sys.executable,
|
|
os.path.join(ROOT, "tests", "gen_test_bvh.py"), clip],
|
|
check=True)
|
|
print("== smoke 3/5: autorig")
|
|
run_stage("autorig.py", ["--in", mesh, "--out", rigged])
|
|
print("== smoke 4/5: retarget")
|
|
run_stage("retarget.py", ["--rig", rigged, "--clip", clip, "--out", final])
|
|
print("== smoke 5/5: verify")
|
|
run_stage("verify_glb.py", ["--in", final, "--expect-anim",
|
|
"--min-frames", "60"])
|
|
if not a.keep:
|
|
pass # outputs stay in out/smoke for inspection; gitignored
|
|
print("\nMIRPAMO SMOKE TEST PASSED")
|
|
print(f" inspect: {final}")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(prog="mirpamo")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
|
|
p = sub.add_parser("rig", help="auto-rig a raw mesh")
|
|
p.add_argument("mesh")
|
|
p.add_argument("-o", "--out", required=True)
|
|
p.add_argument("--markers")
|
|
p.add_argument("--name")
|
|
p.set_defaults(fn=cmd_rig)
|
|
|
|
p = sub.add_parser("anim", help="retarget clip(s) onto a rig")
|
|
p.add_argument("rig")
|
|
p.add_argument("clips", nargs="+")
|
|
p.add_argument("-o", "--out", required=True)
|
|
p.add_argument("--bonemap")
|
|
p.add_argument("--inplace", action="store_true")
|
|
p.add_argument("--fps", type=int)
|
|
p.set_defaults(fn=cmd_anim)
|
|
|
|
p = sub.add_parser("batch", help="retarget every clip in a dir")
|
|
p.add_argument("rig")
|
|
p.add_argument("clips_dir")
|
|
p.add_argument("-o", "--out", required=True)
|
|
p.add_argument("--bonemap")
|
|
p.add_argument("--inplace", action="store_true")
|
|
p.add_argument("--fps", type=int)
|
|
p.set_defaults(fn=cmd_batch)
|
|
|
|
p = sub.add_parser("verify", help="verify an output GLB")
|
|
p.add_argument("file")
|
|
p.add_argument("--expect-anim", action="store_true")
|
|
p.set_defaults(fn=cmd_verify)
|
|
|
|
p = sub.add_parser("smoke", help="end-to-end self test")
|
|
p.add_argument("--keep", action="store_true")
|
|
p.set_defaults(fn=cmd_smoke)
|
|
|
|
a = ap.parse_args()
|
|
a.fn(a)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|