MIRPAMO v0: local Mixamo — autorig + retarget via headless Blender

- 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>
This commit is contained in:
jing 2026-07-17 01:26:13 +10:00
commit a148b4a543
15 changed files with 1407 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
out/
library/downloads/
__pycache__/
*.pyc
*.blend
*.blend1
.DS_Store

35
CLAUDE.md Normal file
View File

@ -0,0 +1,35 @@
# MIRPAMO — notes for Claude
Local Mixamo replacement: auto-rig + retarget via **headless Blender**.
The CLI ([bin/mirpamo](bin/mirpamo)) is plain python3 stdlib; ALL bpy code
lives in `bpy/` and runs as `blender -b --python bpy/<stage>.py -- <args>`.
Never `pip install` anything — the pipeline must run on a bare Mac with
only Blender.app present.
## Verify
`make smoke` is the ground truth (procedural mesh → rig → retarget →
verify). Run it after touching anything in `bpy/`. It must pass on
Blender 5.0 and 5.1 (m1ultra is one minor version behind).
## Conventions
- Skeleton naming is **mixamorig:** — do not rename bones; the whole point
is 1:1 compatibility with the existing Mixamo clip bank
(`~/Documents/mixamo-fetch/out/` on machines that have it).
- Blender units: meters, Z-up, character on the floor facing -Y.
- Outputs land in `out/` (gitignored). Assets never get committed to this
repo — meshes live in `~/Documents/character_kit/` and
`~/Documents/3D=models/` per the asset-pipeline skill.
- Deploy = `make deploy` (push to Gitea → pull on m3ultra + m1ultra →
remote smoke). Hosts are listed in [scripts/deploy.sh](scripts/deploy.sh).
## Gotchas
- Bone-heat weighting (`ARMATURE_AUTO`) can fail on non-manifold meshes;
autorig dies loudly when a mesh ends up without an armature modifier.
- The retargeter computes pose-bone `matrix_basis` by hand (see formula in
[bpy/retarget.py](bpy/retarget.py)) instead of setting `pose_bone.matrix`,
to avoid a depsgraph update per bone per frame.
- glTF export uses `export_animation_mode='ACTIONS'` when available so each
retargeted clip becomes its own named animation.

15
Makefile Normal file
View File

@ -0,0 +1,15 @@
.PHONY: smoke rig anim verify fetch deploy clean
# End-to-end self test: procedural mesh -> autorig -> retarget -> verify
smoke:
./bin/mirpamo smoke
fetch:
python3 library/fetch.py
# Push to gitea, pull + smoke-test on m3ultra and m1ultra
deploy:
./scripts/deploy.sh
clean:
rm -rf out/*

95
README.md Normal file
View File

@ -0,0 +1,95 @@
# MIRPAMO
**A local, offline Mixamo.** Auto-rigging + animation retargeting as a
headless-Blender pipeline — no cloud, no Adobe account, no upload limits.
Built to run on the studio Macs (M3 Ultra / M1 Ultra) and any laptop with
Blender installed.
```
raw mesh (.glb/.fbx/.obj)
[1] autorig landmark detection → mixamorig skeleton fit → bone-heat weights
rigged.glb ──► [2] retarget ◄── motion clip (.bvh/.fbx/.glb)
│ quaternion delta transfer + hip-height scale compensation
character@clip.glb (drop straight into three.js / game engines)
```
## Requirements
- Blender 4.2+ (tested on 5.0.1 and 5.1.2) at
`/Applications/Blender.app` — or set `MIRPAMO_BLENDER=/path/to/blender`
- Python 3.9+ (only for the thin CLI; all bpy work runs inside Blender)
No pip installs. No GPU requirements — bone heat and retargeting are CPU.
## Quickstart
```bash
make smoke # end-to-end self test: proves the machine can run the pipeline
# rig a raw mesh (landmarks are auto-detected; T- or A-pose, upright, on the floor)
./bin/mirpamo rig mymesh.glb -o rigged.glb
# fix a bad joint guess with Mixamo-style markers (world-space Z-up meters)
./bin/mirpamo rig mymesh.glb -o rigged.glb --markers markers.json
# {"chin": [0,0,1.55], "l_wrist": [0.7,0,1.4], "groin": [0,0,0.9], ...}
# retarget one clip
./bin/mirpamo anim rigged.glb walk.bvh -o walking.glb
# retarget an entire clip folder (e.g. the existing Mixamo bank)
./bin/mirpamo batch rigged.glb ~/Documents/mixamo-fetch/out -o out/clips/
# CMU MotionBuilder-friendly BVH needs the bundled bone map
./bin/mirpamo anim rigged.glb cmu_01_01.bvh -o out.glb \
--bonemap bonemaps/cmu_mb_to_mixamo.json
```
## How it works (the Mixamo parts, replicated)
| Mixamo (cloud) | MIRPAMO (local) |
|---|---|
| Colored marker dots (chin/wrists/elbows/knees/groin) | Automatic landmark detection from the vertex cloud; optional `--markers` JSON override ([bpy/autorig.py](bpy/autorig.py)) |
| Master skeleton template snapped to markers | mixamorig-named 22-bone humanoid fitted to landmarks — existing Mixamo clips retarget 1:1 |
| Spatial-proximity skin weighting with smooth falloff | Blender bone-heat automatic weights |
| Rotational (quaternion) data transfer | World-space rotation-delta transfer per bone per frame ([bpy/retarget.py](bpy/retarget.py)) |
| Scale compensation for different proportions | Root translation scaled by target/source hip-height ratio |
Same caveats as Mixamo apply: loose clothing/hair confuses proximity
weighting, and meshes should be clean, symmetric, T/A-pose.
## Machines
| host | hw | blender | role |
|---|---|---|---|
| laptop | dev | 5.1.2 | build here, push |
| `m3ultra@100.89.131.57` | M3 Ultra, 256GB | 5.1.2 | heavy batch retargets, big meshes |
| `johnking@100.91.239.7` | M1 Ultra, 128GB | 5.0.1 | second test target |
`make deploy` pushes to Gitea, pulls on both hosts, and runs the remote
smoke test on each.
## Clip libraries
- **Existing Mixamo bank**: machines with `~/Documents/mixamo-fetch/out/`
can batch-retarget it directly (mixamorig names match natively).
- `python3 library/fetch.py` downloads the sources in
[library/manifest.json](library/manifest.json) — check each entry's
license before shipping clips in a web game (see the asset-pipeline
licensing zones: research-only datasets stay local).
## Roadmap
- [ ] Finger bones (Mixamo's 65-bone variant) + finger weighting
- [ ] In-place conversion flag per clip in `batch`
- [ ] Multi-clip single-GLB export (one character, N animations) — the
retargeter already stores each clip as a named Action; needs an
export pass
- [ ] MLX texture generation stage (M3 Ultra: PBR texture synthesis
alongside rigging — separate `texgen/` stage)
- [ ] Optional ML landmark detection (replace heuristics for non-standard
bodies)

166
bin/mirpamo Executable file
View File

@ -0,0 +1,166 @@
#!/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()

View File

@ -0,0 +1,34 @@
{
"_comment": "CMU mocap (MotionBuilder-friendly BVH conversion) -> mixamorig. null = skip that source bone.",
"Hips": "mixamorig:Hips",
"LowerBack": "mixamorig:Spine",
"Spine": "mixamorig:Spine1",
"Spine1": "mixamorig:Spine2",
"Neck": "mixamorig:Neck",
"Neck1": null,
"Head": "mixamorig:Head",
"LeftShoulder": "mixamorig:LeftShoulder",
"LeftArm": "mixamorig:LeftArm",
"LeftForeArm": "mixamorig:LeftForeArm",
"LeftHand": "mixamorig:LeftHand",
"LeftFingerBase": null,
"LeftHandIndex1": null,
"LThumb": null,
"RightShoulder": "mixamorig:RightShoulder",
"RightArm": "mixamorig:RightArm",
"RightForeArm": "mixamorig:RightForeArm",
"RightHand": "mixamorig:RightHand",
"RightFingerBase": null,
"RightHandIndex1": null,
"RThumb": null,
"LHipJoint": null,
"LeftUpLeg": "mixamorig:LeftUpLeg",
"LeftLeg": "mixamorig:LeftLeg",
"LeftFoot": "mixamorig:LeftFoot",
"LeftToeBase": "mixamorig:LeftToeBase",
"RHipJoint": null,
"RightUpLeg": "mixamorig:RightUpLeg",
"RightLeg": "mixamorig:RightLeg",
"RightFoot": "mixamorig:RightFoot",
"RightToeBase": "mixamorig:RightToeBase"
}

291
bpy/autorig.py Normal file
View File

@ -0,0 +1,291 @@
"""MIRPAMO stage 1: auto-rig a raw humanoid mesh.
Replicates Mixamo's auto-rigger locally:
1. Landmark detection instead of the user clicking colored dots on
chin/wrists/elbows/knees/groin, we estimate those anchor points from the
vertex cloud (T/A-pose assumed, character upright on the floor).
A markers JSON can override any landmark.
2. Skeletal template fitting a mixamorig-named humanoid skeleton is
stretched to the landmarks, so existing Mixamo clips retarget 1:1.
3. Automatic skin weighting Blender's bone-heat solver
(parent with automatic weights), same spatial-falloff idea Mixamo uses.
Usage:
blender -b --python bpy/autorig.py -- --in mesh.glb --out rigged.glb \
[--markers markers.json] [--name Character]
markers.json (all optional, world-space [x,y,z] in Blender Z-up meters):
{"chin": [...], "groin": [...], "l_wrist": [...], "r_wrist": [...],
"l_elbow": [...], "r_elbow": [...], "l_knee": [...], "r_knee": [...]}
"""
import argparse
import json
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import (clean_scene, die, export_glb, find_meshes, import_any,
select_only, stage_args)
def gather_world_verts(meshes):
"""All vertices of all mesh objects, in world space."""
depsgraph = bpy.context.evaluated_depsgraph_get()
verts = []
for obj in meshes:
ev = obj.evaluated_get(depsgraph)
mesh = ev.to_mesh()
mw = ev.matrix_world
verts.extend(mw @ v.co for v in mesh.vertices)
ev.to_mesh_clear()
if not verts:
die("no vertices found in input mesh")
return verts
def band(verts, zmin, zmax):
return [v for v in verts if zmin <= v.z <= zmax]
def median(vals):
s = sorted(vals)
return s[len(s) // 2] if s else 0.0
def detect_landmarks(verts, overrides):
"""Estimate Mixamo-style anchor points from the vertex cloud.
Assumes: upright character, +Z up, roughly symmetric about X=0,
T- or A-pose. Heuristics follow standard human proportions, but arm
heights are measured from the actual mesh so A-poses work too.
"""
zs = [v.z for v in verts]
z_min, z_max = min(zs), max(zs)
H = z_max - z_min
if H <= 0:
die("mesh has zero height")
cx = median([v.x for v in verts])
cy = median([v.y for v in verts])
L = {}
def z_at(f):
return z_min + f * H
# --- torso column ---
L["groin"] = Vector((cx, cy, z_at(0.52)))
L["chin"] = Vector((cx, cy, z_at(0.87)))
L["head_top"] = Vector((cx, cy, z_max))
# --- arms: farthest-|x| vertex above the waist gives the hand tip;
# wrist/elbow/shoulder sit at fixed fractions along that reach, and we
# measure their actual heights from the mesh so drooping A-pose arms
# land inside the geometry ---
upper = band(verts, z_at(0.55), z_at(0.95))
if not upper:
upper = verts
tip_l = max(upper, key=lambda v: v.x)
tip_r = min(upper, key=lambda v: v.x)
def arm_point(tip, frac):
"""Point at `frac` of the horizontal reach from center, at the
median height/depth of mesh vertices near that x."""
x = cx + (tip.x - cx) * frac
tol = max(0.03 * H, 0.02)
near = [v for v in upper if abs(v.x - x) < tol
and (v.x - cx) * (tip.x - cx) >= 0]
if len(near) < 3:
return Vector((x, tip.y, tip.z))
return Vector((x, median([v.y for v in near]),
median([v.z for v in near])))
for side, tip in (("l", tip_l), ("r", tip_r)):
L[f"{side}_hand_tip"] = Vector(tip)
L[f"{side}_wrist"] = arm_point(tip, 0.82)
L[f"{side}_elbow"] = arm_point(tip, 0.52)
L[f"{side}_shoulder"] = arm_point(tip, 0.23)
# keep the shoulder joint near the torso's height regardless of pose
L[f"{side}_shoulder"].z = max(L[f"{side}_shoulder"].z, z_at(0.78))
# --- legs: cluster low-body vertices left/right of center ---
knee_band = band(verts, z_at(0.22), z_at(0.35))
lknee = [v for v in knee_band if v.x > cx]
rknee = [v for v in knee_band if v.x <= cx]
leg_x_l = median([v.x for v in lknee]) if lknee else cx + 0.05 * H
leg_x_r = median([v.x for v in rknee]) if rknee else cx - 0.05 * H
leg_y_l = median([v.y for v in lknee]) if lknee else cy
leg_y_r = median([v.y for v in rknee]) if rknee else cy
L["l_knee"] = Vector((leg_x_l, leg_y_l, z_at(0.285)))
L["r_knee"] = Vector((leg_x_r, leg_y_r, z_at(0.285)))
L["l_ankle"] = Vector((leg_x_l, leg_y_l, z_at(0.05)))
L["r_ankle"] = Vector((leg_x_r, leg_y_r, z_at(0.05)))
# feet point toward the side of the mesh with more foot-level volume
foot_band = band(verts, z_min, z_at(0.05))
fy = median([v.y for v in foot_band]) if foot_band else cy
toe_dir = -1.0 if fy < cy else 1.0
foot_len = 0.12 * H
for s, ax in (("l", leg_x_l), ("r", leg_x_r)):
L[f"{s}_toe"] = Vector((ax, cy + toe_dir * foot_len, z_min + 0.01 * H))
# --- apply user marker overrides (Mixamo's colored dots) ---
for key, val in overrides.items():
L[key] = Vector(val)
L["_height"] = H
L["_floor"] = z_min
L["_center"] = Vector((cx, cy, 0))
return L
def build_skeleton(L, name):
"""Build a mixamorig-named humanoid armature fitted to the landmarks."""
arm_data = bpy.data.armatures.new(name + "_rig")
arm_obj = bpy.data.objects.new(name + "_rig", arm_data)
bpy.context.collection.objects.link(arm_obj)
select_only([arm_obj], active=arm_obj)
bpy.ops.object.mode_set(mode="EDIT")
eb = arm_data.edit_bones
P = "mixamorig:"
groin, chin, top = L["groin"], L["chin"], L["head_top"]
spine_bottom = groin
neck_base = Vector((chin.x, chin.y, groin.z + (chin.z - groin.z) * 0.92))
def lerp(a, b, t):
return a + (b - a) * t
bones = {}
def add(bname, head, tail, parent=None, connect=False):
b = eb.new(P + bname)
b.head, b.tail = head, tail
if parent:
b.parent = bones[parent]
b.use_connect = connect
bones[bname] = b
return b
# spine column
s1 = lerp(spine_bottom, neck_base, 0.33)
s2 = lerp(spine_bottom, neck_base, 0.66)
add("Hips", groin, s1)
add("Spine", s1, s2, "Hips", True)
add("Spine1", s2, neck_base, "Spine", True)
add("Spine2", neck_base, lerp(neck_base, chin, 0.6), "Spine1", True)
add("Neck", lerp(neck_base, chin, 0.6), chin, "Spine2", True)
add("Head", chin, top, "Neck", True)
# arms
for S, side in (("Left", "l"), ("Right", "r")):
sh, el, wr, tip = (L[f"{side}_shoulder"], L[f"{side}_elbow"],
L[f"{side}_wrist"], L[f"{side}_hand_tip"])
clav = lerp(Vector((groin.x, sh.y, sh.z)), sh, 0.35)
add(f"{S}Shoulder", clav, sh, "Spine2")
add(f"{S}Arm", sh, el, f"{S}Shoulder", True)
add(f"{S}ForeArm", el, wr, f"{S}Arm", True)
add(f"{S}Hand", wr, tip, f"{S}ForeArm", True)
# legs
for S, side in (("Left", "l"), ("Right", "r")):
kn, an, toe = L[f"{side}_knee"], L[f"{side}_ankle"], L[f"{side}_toe"]
hip = Vector((kn.x, groin.y, groin.z * 0.98 + L["_floor"] * 0.02))
add(f"{S}UpLeg", hip, kn, "Hips")
add(f"{S}Leg", kn, an, f"{S}UpLeg", True)
add(f"{S}Foot", an, lerp(an, toe, 0.75), f"{S}Leg", True)
add(f"{S}ToeBase", lerp(an, toe, 0.75), toe, f"{S}Foot", True)
bpy.ops.object.mode_set(mode="OBJECT")
return arm_obj
def point_to_segment_dist(p, a, b):
ab = b - a
denom = ab.length_squared
if denom < 1e-12:
return (p - a).length
t = max(0.0, min(1.0, (p - a).dot(ab) / denom))
return (p - (a + ab * t)).length
def rescue_unweighted(mesh, arm_obj):
"""Bone heat can leave disconnected islands (hands, toes, props) with
zero weights they'd stay frozen at bind pose. Assign such vertices to
the nearest bone by spatial proximity (Mixamo's own fallback idea)."""
bones = [(b.name,
arm_obj.matrix_world @ b.head_local,
arm_obj.matrix_world @ b.tail_local)
for b in arm_obj.data.bones]
vg_index = {vg.index: vg.name for vg in mesh.vertex_groups}
mw = mesh.matrix_world
rescued = 0
for v in mesh.data.vertices:
if any(g.weight > 1e-6 and g.group in vg_index for g in v.groups):
continue
p = mw @ v.co
name = min(bones, key=lambda bn: point_to_segment_dist(p, bn[1], bn[2]))[0]
vg = mesh.vertex_groups.get(name) or mesh.vertex_groups.new(name=name)
vg.add([v.index], 1.0, "REPLACE")
rescued += 1
if rescued:
print(f"[mirpamo] rescued {rescued} unweighted verts on {mesh.name} "
"via nearest-bone proximity")
def skin(meshes, arm_obj):
"""Bone-heat automatic weights (Mixamo's spatial-falloff step)."""
for m in meshes:
# bone heat fails on unapplied transforms more often; apply them
select_only([m], active=m)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
select_only(meshes + [arm_obj], active=arm_obj)
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
for m in meshes:
rescue_unweighted(m, arm_obj)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="inp", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--markers", default=None)
ap.add_argument("--name", default=None)
args = ap.parse_args(stage_args())
overrides = {}
if args.markers:
with open(args.markers) as f:
overrides = json.load(f)
name = args.name or os.path.splitext(os.path.basename(args.inp))[0]
clean_scene()
objs = import_any(args.inp)
meshes = find_meshes(objs)
if not meshes:
die("input contains no mesh")
verts = gather_world_verts(meshes)
L = detect_landmarks(verts, overrides)
print(f"[mirpamo] mesh height {L['_height']:.3f}m, "
f"{len(verts)} verts, landmarks detected")
arm_obj = build_skeleton(L, name)
skin(meshes, arm_obj)
# sanity: every mesh must now have an armature modifier
for m in meshes:
if not any(mod.type == "ARMATURE" for mod in m.modifiers):
die(f"automatic weighting failed for {m.name} "
"(bone heat could not solve — mesh may need cleanup)")
export_glb(args.out, objects=[arm_obj] + meshes)
print("[mirpamo] autorig OK")
main()

115
bpy/common.py Normal file
View File

@ -0,0 +1,115 @@
"""Shared helpers for MIRPAMO's headless-Blender stages.
Every stage script is run as:
blender -b --python bpy/<stage>.py -- <stage args>
"""
import os
import sys
import bpy
def stage_args():
"""Return the argv after the '--' separator (the stage's own args)."""
argv = sys.argv
if "--" in argv:
return argv[argv.index("--") + 1:]
return []
def clean_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
def import_any(path):
"""Import a mesh/animation file; return the list of new objects."""
path = os.path.abspath(path)
if not os.path.exists(path):
raise FileNotFoundError(path)
ext = os.path.splitext(path)[1].lower()
before = set(bpy.data.objects)
if ext in (".glb", ".gltf"):
bpy.ops.import_scene.gltf(filepath=path)
elif ext == ".fbx":
bpy.ops.import_scene.fbx(filepath=path)
elif ext == ".obj":
bpy.ops.wm.obj_import(filepath=path)
elif ext == ".bvh":
bpy.ops.import_anim.bvh(
filepath=path,
rotate_mode="NATIVE",
update_scene_fps=True,
update_scene_duration=True,
)
else:
raise ValueError(f"unsupported file type: {ext}")
return [o for o in bpy.data.objects if o not in before]
def find_armature(objects):
for o in objects:
if o.type == "ARMATURE":
return o
return None
def find_meshes(objects):
"""Mesh objects, excluding bone custom-shape helpers the glTF importer
creates (e.g. 'Icosphere') those are viewport display aids, not assets."""
shapes = set()
for o in bpy.data.objects:
if o.type == "ARMATURE":
for pb in o.pose.bones:
if pb.custom_shape:
shapes.add(pb.custom_shape)
return [o for o in objects if o.type == "MESH" and o not in shapes]
def count_fcurves(action):
"""Number of f-curves in an action, across legacy (<=4.x) and slotted
(5.x layered actions) APIs."""
try:
return len(action.fcurves)
except AttributeError:
return sum(len(cb.fcurves)
for layer in action.layers
for strip in layer.strips
for cb in strip.channelbags)
def select_only(objects, active=None):
bpy.ops.object.select_all(action="DESELECT")
for o in objects:
o.select_set(True)
if active is not None:
bpy.context.view_layer.objects.active = active
def export_glb(path, objects=None):
"""Export the given objects (or everything) as a GLB with animations."""
path = os.path.abspath(path)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
if objects:
select_only(objects, active=objects[0])
use_selection = True
else:
use_selection = False
kwargs = dict(
filepath=path,
export_format="GLB",
export_animations=True,
export_yup=True,
use_selection=use_selection,
)
# Export every action as its own named animation when the option exists
# (property name is stable in 4.2+/5.x, but guard anyway).
props = bpy.ops.export_scene.gltf.get_rna_type().properties.keys()
if "export_animation_mode" in props:
kwargs["export_animation_mode"] = "ACTIONS"
bpy.ops.export_scene.gltf(**kwargs)
print(f"[mirpamo] wrote {path}")
def die(msg):
print(f"[mirpamo] ERROR: {msg}", file=sys.stderr)
sys.exit(1)

230
bpy/retarget.py Normal file
View File

@ -0,0 +1,230 @@
"""MIRPAMO stage 2: retarget motion clips onto a rigged character.
Replicates Mixamo's animation retargeter locally:
- Rotational data transfer: for every mapped bone, the source bone's
world-space rotation *delta from rest* is applied to the target bone's
rest orientation. Pure quaternion transfer no positional copying
so limb proportions never stretch.
- Scale compensation: root (Hips) translation is scaled by the ratio of
target hip height to source hip height, so a short character takes
short steps from the same clip.
Bone mapping: exact name match first, then normalized fuzzy match
("mixamorig:LeftArm" == "LeftArm" == "left_arm"), plus optional explicit
map JSON {"source_bone": "target_bone", "skip_me": null}.
Usage:
blender -b --python bpy/retarget.py -- --rig rigged.glb \
--clip walk.bvh [--clip run.fbx ...] --out out.glb \
[--bonemap bonemaps/cmu_mb_to_mixamo.json] [--inplace] [--fps 30]
"""
import argparse
import json
import os
import re
import sys
import bpy
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import (clean_scene, die, export_glb, find_armature, find_meshes,
import_any, stage_args)
def norm_name(n):
n = n.lower()
n = re.sub(r"mixamo[_:]?rig", "", n)
n = re.sub(r"[^a-z0-9]", "", n)
return n
def build_bone_map(src_arm, tgt_arm, explicit):
"""Map source bone name -> target bone name."""
tgt_names = {b.name for b in tgt_arm.data.bones}
tgt_by_norm = {norm_name(b.name): b.name for b in tgt_arm.data.bones}
mapping = {}
for sb in src_arm.data.bones:
if explicit and sb.name in explicit:
t = explicit[sb.name]
if t and t in tgt_names:
mapping[sb.name] = t
continue # explicit null = deliberately skipped
if sb.name in tgt_names:
mapping[sb.name] = sb.name
elif norm_name(sb.name) in tgt_by_norm:
mapping[sb.name] = tgt_by_norm[norm_name(sb.name)]
return mapping
def hip_bone(arm, mapping_values=None):
candidates = mapping_values or [b.name for b in arm.data.bones]
for b in candidates:
if norm_name(b) in ("hips", "hip", "pelvis", "root"):
return b
# fall back to a root bone (no parent) that has children
for b in arm.data.bones:
if b.parent is None and b.children:
return b.name
return None
def world_rest(arm_obj, bone_name):
return arm_obj.matrix_world @ arm_obj.data.bones[bone_name].matrix_local
def retarget_clip(src_arm, tgt_arm, mapping, clip_name, inplace, frame_range):
scene = bpy.context.scene
f0, f1 = frame_range
print(f"[mirpamo] retargeting '{clip_name}' frames {f0}..{f1} "
f"({len(mapping)} bones mapped)")
tgt_arm.animation_data_create()
action = bpy.data.actions.new(clip_name)
action.use_fake_user = True
tgt_arm.animation_data.action = action
# rest matrices (world space)
src_rest = {s: world_rest(src_arm, s) for s in mapping}
tgt_rest = {t: world_rest(tgt_arm, t) for t in mapping.values()}
# scale compensation: hip heights at rest
s_hip = hip_bone(src_arm, list(mapping.keys()))
t_hip = mapping.get(s_hip) if s_hip else None
scale = 1.0
if s_hip and t_hip:
sh = (src_arm.matrix_world @
src_arm.data.bones[s_hip].head_local).z
th = (tgt_arm.matrix_world @
tgt_arm.data.bones[t_hip].head_local).z
if sh > 1e-6:
scale = th / sh
print(f"[mirpamo] hip scale compensation: {scale:.4f}")
# target bones in parent-before-child order
ordered = []
def walk(bone):
if bone.name in tgt_rest:
ordered.append(bone.name)
for c in bone.children:
walk(c)
for b in tgt_arm.data.bones:
if b.parent is None:
walk(b)
tgt_to_src = {t: s for s, t in mapping.items()}
mw_tgt_inv = tgt_arm.matrix_world.inverted()
depsgraph = bpy.context.evaluated_depsgraph_get()
for f in range(f0, f1 + 1):
scene.frame_set(f)
src_ev = src_arm.evaluated_get(depsgraph)
src_world = {s: src_ev.matrix_world @ src_ev.pose.bones[s].matrix
for s in mapping}
# desired world matrix per target bone, computed top-down
desired = {}
for t in ordered:
s = tgt_to_src[t]
delta = (src_world[s] @ src_rest[s].inverted())
m = delta @ tgt_rest[t]
desired[t] = m
for t in ordered:
pb = tgt_arm.pose.bones[t]
bone = tgt_arm.data.bones[t]
m_des = mw_tgt_inv @ desired[t] # into armature space
if bone.parent:
m_parent_des = mw_tgt_inv @ desired.get(
bone.parent.name,
tgt_arm.matrix_world @ tgt_arm.pose.bones[bone.parent.name].matrix)
basis = (bone.matrix_local.inverted() @
bone.parent.matrix_local @
m_parent_des.inverted() @ m_des)
else:
basis = bone.matrix_local.inverted() @ m_des
loc, rot, _ = basis.decompose()
pb.rotation_mode = "QUATERNION"
pb.rotation_quaternion = rot
pb.keyframe_insert("rotation_quaternion", frame=f)
if t == t_hip:
# scale-compensated root translation
src_hip_world = src_world[tgt_to_src[t]].to_translation()
rest_hip_world = tgt_rest[t].to_translation()
target_world = src_hip_world * scale
if inplace:
target_world.x = rest_hip_world.x
target_world.y = rest_hip_world.y
m = desired[t].copy()
m.translation = target_world
m_des2 = mw_tgt_inv @ m
basis2 = bone.matrix_local.inverted() @ m_des2 \
if not bone.parent else basis
pb.location = basis2.to_translation()
pb.keyframe_insert("location", frame=f)
return action
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rig", required=True, help="rigged character (glb/fbx)")
ap.add_argument("--clip", action="append", required=True,
help="motion clip (bvh/fbx/glb); repeatable")
ap.add_argument("--out", required=True)
ap.add_argument("--bonemap", default=None)
ap.add_argument("--inplace", action="store_true",
help="strip horizontal root motion")
ap.add_argument("--fps", type=int, default=None)
args = ap.parse_args(stage_args())
explicit = None
if args.bonemap:
with open(args.bonemap) as f:
explicit = json.load(f)
clean_scene()
rig_objs = import_any(args.rig)
tgt_arm = find_armature(rig_objs)
if not tgt_arm:
die("rig file contains no armature — run autorig first")
tgt_meshes = find_meshes(rig_objs)
if args.fps:
bpy.context.scene.render.fps = args.fps
for clip_path in args.clip:
clip_objs = import_any(clip_path)
src_arm = find_armature(clip_objs)
if not src_arm:
die(f"{clip_path}: no armature/motion found")
mapping = build_bone_map(src_arm, tgt_arm, explicit)
if len(mapping) < 3:
die(f"{clip_path}: only {len(mapping)} bones mapped — "
"provide a --bonemap")
ad = src_arm.animation_data
if not ad or not ad.action:
die(f"{clip_path}: armature has no animation")
src_action = ad.action
fr = src_action.frame_range
frame_range = (int(fr[0]), int(fr[1]))
clip_name = os.path.splitext(os.path.basename(clip_path))[0]
action = retarget_clip(src_arm, tgt_arm, mapping, clip_name,
args.inplace, frame_range)
# drop the source skeleton + its action so only retargeted
# animations reach the export
for o in clip_objs:
bpy.data.objects.remove(o, do_unlink=True)
bpy.data.actions.remove(src_action)
action.name = clip_name # reclaim the name if it collided
export_glb(args.out, objects=[tgt_arm] + tgt_meshes)
print("[mirpamo] retarget OK")
main()

79
bpy/smoke_mesh.py Normal file
View File

@ -0,0 +1,79 @@
"""Generate a low-poly T-pose test humanoid (no rig) for the smoke test.
Usage: blender -b --python bpy/smoke_mesh.py -- --out out/test_human.glb
"""
import argparse
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import clean_scene, export_glb, select_only, stage_args
def cube(name, center, size):
bpy.ops.mesh.primitive_cube_add(location=center)
o = bpy.context.active_object
o.name = name
o.scale = (size[0] / 2, size[1] / 2, size[2] / 2)
return o
def sphere(name, center, r):
bpy.ops.mesh.primitive_uv_sphere_add(
location=center, radius=r, segments=12, ring_count=8)
o = bpy.context.active_object
o.name = name
return o
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
args = ap.parse_args(stage_args())
clean_scene()
H = 1.75 # meters, floor at z=0, facing -Y, T-pose along X
parts = []
# torso: groin (0.52H) to shoulders (~0.82H)
parts.append(cube("torso", (0, 0, 0.67 * H), (0.32 * H, 0.14 * H, 0.30 * H)))
# head: chin 0.87H to top 1.0H
parts.append(sphere("head", (0, 0, 0.93 * H), 0.075 * H))
parts.append(cube("neck", (0, 0, 0.845 * H), (0.06 * H, 0.06 * H, 0.06 * H)))
# arms straight out along +/-X at shoulder height 0.82H
for s in (1, -1):
parts.append(cube(f"upperarm{s}",
(s * 0.26 * H, 0, 0.82 * H),
(0.17 * H, 0.055 * H, 0.055 * H)))
parts.append(cube(f"forearm{s}",
(s * 0.40 * H, 0, 0.82 * H),
(0.14 * H, 0.045 * H, 0.045 * H)))
parts.append(cube(f"hand{s}",
(s * 0.50 * H, 0, 0.82 * H),
(0.09 * H, 0.04 * H, 0.03 * H)))
# legs down from groin, feet forward (-Y)
for s in (1, -1):
parts.append(cube(f"thigh{s}",
(s * 0.09 * H, 0, 0.40 * H),
(0.075 * H, 0.075 * H, 0.24 * H)))
parts.append(cube(f"shin{s}",
(s * 0.09 * H, 0, 0.165 * H),
(0.06 * H, 0.06 * H, 0.23 * H)))
parts.append(cube(f"foot{s}",
(s * 0.09 * H, -0.05 * H, 0.03 * H),
(0.06 * H, 0.16 * H, 0.05 * H)))
select_only(parts, active=parts[0])
bpy.ops.object.join()
body = bpy.context.active_object
body.name = "test_human"
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
export_glb(args.out, objects=[body])
print("[mirpamo] smoke mesh OK")
main()

74
bpy/verify_glb.py Normal file
View File

@ -0,0 +1,74 @@
"""Verify a MIRPAMO output GLB: armature present, meshes skinned,
animations present with real motion. Prints a JSON report; exit 1 on failure.
Usage: blender -b --python bpy/verify_glb.py -- --in out/final.glb \
[--expect-anim] [--min-frames 10]
"""
import argparse
import json
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import (clean_scene, count_fcurves, die, find_armature,
find_meshes, import_any, stage_args)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="inp", required=True)
ap.add_argument("--expect-anim", action="store_true")
ap.add_argument("--min-frames", type=int, default=10)
args = ap.parse_args(stage_args())
clean_scene()
objs = import_any(args.inp)
arm = find_armature(objs)
meshes = find_meshes(objs)
report = {
"file": args.inp,
"armature": arm.name if arm else None,
"bones": len(arm.data.bones) if arm else 0,
"meshes": [m.name for m in meshes],
"skinned": [],
"actions": {},
}
if not arm:
print(json.dumps(report, indent=2))
die("no armature in output")
if not meshes:
print(json.dumps(report, indent=2))
die("no meshes in output")
for m in meshes:
has_arm_mod = any(mod.type == "ARMATURE" for mod in m.modifiers)
has_groups = len(m.vertex_groups) > 0
report["skinned"].append(
{"mesh": m.name, "armature_mod": has_arm_mod,
"vertex_groups": len(m.vertex_groups)})
if not (has_arm_mod and has_groups):
print(json.dumps(report, indent=2))
die(f"mesh {m.name} is not skinned")
for act in bpy.data.actions:
fr = act.frame_range
n_frames = int(fr[1] - fr[0]) + 1
report["actions"][act.name] = {
"frames": n_frames, "fcurves": count_fcurves(act)}
if args.expect_anim:
good = [a for a, v in report["actions"].items()
if v["frames"] >= args.min_frames and v["fcurves"] > 0]
if not good:
print(json.dumps(report, indent=2))
die(f"no action with >= {args.min_frames} frames found")
print(json.dumps(report, indent=2))
print("[mirpamo] verify OK")
main()

83
library/fetch.py Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Fetch free motion-capture libraries into library/downloads/.
Sources live in library/manifest.json. Entries marked "verified": false
have not been confirmed reachable yet the script reports what it can and
skips what it can't, it never fails the whole run on one dead mirror.
Usage:
python3 library/fetch.py # fetch everything in the manifest
python3 library/fetch.py --only cmu_sample
python3 library/fetch.py --url https://... --name mypack # ad-hoc
"""
import argparse
import json
import os
import sys
import urllib.request
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
DOWNLOADS = os.path.join(HERE, "downloads")
def fetch_one(name, url, unpack):
os.makedirs(DOWNLOADS, exist_ok=True)
dest_dir = os.path.join(DOWNLOADS, name)
fname = os.path.join(DOWNLOADS, os.path.basename(url.split("?")[0]) or name)
if os.path.exists(dest_dir) and os.listdir(dest_dir):
print(f"[skip] {name}: already downloaded")
return True
print(f"[get ] {name}: {url}")
try:
req = urllib.request.Request(url, headers={"User-Agent": "mirpamo/0.1"})
with urllib.request.urlopen(req, timeout=60) as r, open(fname, "wb") as f:
while True:
chunk = r.read(1 << 20)
if not chunk:
break
f.write(chunk)
except Exception as e:
print(f"[fail] {name}: {e}")
return False
if unpack and fname.endswith(".zip"):
os.makedirs(dest_dir, exist_ok=True)
with zipfile.ZipFile(fname) as z:
z.extractall(dest_dir)
os.remove(fname)
print(f"[ok ] {name}: unpacked to {dest_dir}")
else:
os.makedirs(dest_dir, exist_ok=True)
os.rename(fname, os.path.join(dest_dir, os.path.basename(fname)))
print(f"[ok ] {name}")
return True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only")
ap.add_argument("--url")
ap.add_argument("--name", default="adhoc")
args = ap.parse_args()
if args.url:
ok = fetch_one(args.name, args.url, unpack=True)
sys.exit(0 if ok else 1)
with open(os.path.join(HERE, "manifest.json")) as f:
manifest = json.load(f)
results = []
for entry in manifest["sources"]:
if args.only and entry["name"] != args.only:
continue
if entry.get("url", "").startswith("TODO"):
print(f"[todo] {entry['name']}: no URL yet — {entry.get('notes','')}")
continue
results.append(fetch_one(entry["name"], entry["url"],
entry.get("unpack", True)))
print(f"\n{sum(results)}/{len(results)} sources fetched")
if __name__ == "__main__":
main()

36
library/manifest.json Normal file
View File

@ -0,0 +1,36 @@
{
"_comment": "Free motion libraries. 'license' matters: only CC0/permissive entries may ship in web games (see asset-pipeline licensing zones). Research-only sources are for local experiments.",
"sources": [
{
"name": "bandai_namco_1",
"url": "https://github.com/BandaiNamcoResearchInc/Bandai-Namco-Research-Motiondataset/archive/refs/heads/master.zip",
"format": "bvh",
"license": "CC-BY-NC-ND 4.0 (research/personal only — do NOT ship in games)",
"unpack": true,
"verified": false
},
{
"name": "lafan1_ubisoft",
"url": "https://github.com/ubisoft/ubisoft-laforge-animation-dataset/archive/refs/heads/master.zip",
"format": "bvh",
"license": "Ubisoft non-commercial (research only — do NOT ship in games)",
"unpack": true,
"verified": false
},
{
"name": "cmu_asfamc_official",
"url": "TODO — http://mocap.cs.cmu.edu hosts per-subject zips (asf/amc); the community BVH conversions (cgspeed / MotionBuilder-friendly) move mirrors often. Find a live mirror, then: python3 library/fetch.py --url <zip> --name cmu_bvh",
"format": "asf/amc or bvh",
"license": "free for all uses incl. commercial (CMU asks for credit)",
"notes": "the bundled bonemaps/cmu_mb_to_mixamo.json matches the MotionBuilder-friendly BVH naming",
"verified": false
},
{
"name": "existing_mixamo_bank",
"url": "TODO — not a download: machines that have ~/Documents/mixamo-fetch/out/ already hold anim-only Mixamo FBX clips. Point `mirpamo batch <rig> ~/Documents/mixamo-fetch/out -o out/` straight at it; mixamorig names map 1:1.",
"format": "fbx",
"license": "Mixamo (web-ok per asset-pipeline licensing zones)",
"verified": true
}
]
}

41
scripts/deploy.sh Executable file
View File

@ -0,0 +1,41 @@
#!/bin/bash
# Push current main to Gitea, then pull + smoke-test on the studio Macs.
# ./scripts/deploy.sh # push + pull + remote smoke on all hosts
# ./scripts/deploy.sh --no-smoke # skip the remote smoke tests
set -euo pipefail
REPO_URL="https://gitea.partly.party/monster/MIRPAMO.git"
REMOTE_PATH="~/Documents/MIRPAMO"
HOSTS=(
"m3ultra@100.89.131.57"
"johnking@100.91.239.7"
)
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
RUN_SMOKE=1
[[ "${1:-}" == "--no-smoke" ]] && RUN_SMOKE=0
echo "== pushing to $REPO_URL"
git push origin main
for HOST in "${HOSTS[@]}"; do
echo
echo "== $HOST: pull"
ssh -o ConnectTimeout=10 "$HOST" "
if [ -d $REMOTE_PATH/.git ]; then
git -C $REMOTE_PATH pull --ff-only
else
git clone $REPO_URL $REMOTE_PATH
fi"
if [[ $RUN_SMOKE == 1 ]]; then
echo "== $HOST: smoke test"
ssh "$HOST" "cd $REMOTE_PATH && ./bin/mirpamo smoke" \
&& echo "== $HOST: PASS" \
|| { echo "== $HOST: FAIL"; exit 1; }
fi
done
echo
echo "deploy complete"

106
tests/gen_test_bvh.py Executable file
View File

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Generate a small synthetic BVH clip (90 frames @ 30fps: hip bob +
arm swing + slight walk-forward) for the smoke test.
Pure Python no Blender needed. Bone names are Mixamo-style without the
namespace, which exercises the retargeter's fuzzy name matching.
Usage: python3 tests/gen_test_bvh.py out/test_clip.bvh
"""
import math
import sys
# hierarchy: name, parent, offset (BVH Y-up, meters)
JOINTS = [
("Hips", None, (0, 0.93, 0)),
("Spine", "Hips", (0, 0.10, 0)),
("Spine1", "Spine", (0, 0.10, 0)),
("Spine2", "Spine1", (0, 0.10, 0)),
("Neck", "Spine2", (0, 0.10, 0)),
("Head", "Neck", (0, 0.09, 0)),
("LeftShoulder", "Spine2", (0.06, 0.08, 0)),
("LeftArm", "LeftShoulder", (0.12, 0, 0)),
("LeftForeArm", "LeftArm", (0.26, 0, 0)),
("LeftHand", "LeftForeArm", (0.24, 0, 0)),
("RightShoulder", "Spine2", (-0.06, 0.08, 0)),
("RightArm", "RightShoulder", (-0.12, 0, 0)),
("RightForeArm", "RightArm", (-0.26, 0, 0)),
("RightHand", "RightForeArm", (-0.24, 0, 0)),
("LeftUpLeg", "Hips", (0.09, -0.05, 0)),
("LeftLeg", "LeftUpLeg", (0, -0.40, 0)),
("LeftFoot", "LeftLeg", (0, -0.40, 0)),
("LeftToeBase", "LeftFoot", (0, -0.05, 0.14)),
("RightUpLeg", "Hips", (-0.09, -0.05, 0)),
("RightLeg", "RightUpLeg", (0, -0.40, 0)),
("RightFoot", "RightLeg", (0, -0.40, 0)),
("RightToeBase", "RightFoot", (0, -0.05, 0.14)),
]
FRAMES = 90
FPS = 30.0
def children_of(name):
return [j for j in JOINTS if j[1] == name]
def write_joint(f, joint, depth):
name, parent, off = joint
ind = " " * depth
kw = "ROOT" if parent is None else "JOINT"
f.write(f"{ind}{kw} {name}\n{ind}{{\n")
f.write(f"{ind} OFFSET {off[0]:.4f} {off[1]:.4f} {off[2]:.4f}\n")
if parent is None:
f.write(f"{ind} CHANNELS 6 Xposition Yposition Zposition "
"Zrotation Xrotation Yrotation\n")
else:
f.write(f"{ind} CHANNELS 3 Zrotation Xrotation Yrotation\n")
kids = children_of(name)
if kids:
for k in kids:
write_joint(f, k, depth + 1)
else:
f.write(f"{ind} End Site\n{ind} {{\n")
f.write(f"{ind} OFFSET 0 0.05 0\n{ind} }}\n")
f.write(f"{ind}}}\n")
def motion_row(frame):
t = frame / FPS
phase = 2 * math.pi * t # 1 Hz cycle
swing = 35 * math.sin(phase) # degrees, arm/leg swing
bob = 0.03 * abs(math.sin(phase)) # hip bob
fwd = 0.6 * t # walk forward (BVH +Z)
rot = {name: (0.0, 0.0, 0.0) for name, _, _ in JOINTS} # (Z, X, Y)
rot["LeftArm"] = (0.0, swing, 0.0)
rot["RightArm"] = (0.0, -swing, 0.0)
rot["LeftForeArm"] = (0.0, -10 - 10 * abs(math.sin(phase)), 0.0)
rot["RightForeArm"] = (0.0, -10 - 10 * abs(math.sin(phase)), 0.0)
rot["LeftUpLeg"] = (0.0, -swing, 0.0)
rot["RightUpLeg"] = (0.0, swing, 0.0)
rot["LeftLeg"] = (0.0, 15 * max(0.0, math.sin(phase)), 0.0)
rot["RightLeg"] = (0.0, 15 * max(0.0, -math.sin(phase)), 0.0)
rot["Spine"] = (3 * math.sin(phase), 0.0, 0.0)
rot["Head"] = (0.0, 5 * math.sin(0.5 * phase), 0.0)
vals = [0.0, 0.93 + bob, fwd] # root Xpos Ypos Zpos
for name, parent, _ in JOINTS:
z, x, y = rot[name]
vals.extend([z, x, y])
return vals
def main(out_path):
with open(out_path, "w") as f:
f.write("HIERARCHY\n")
write_joint(f, JOINTS[0], 0)
f.write(f"MOTION\nFrames: {FRAMES}\n")
f.write(f"Frame Time: {1.0 / FPS:.6f}\n")
for i in range(FRAMES):
f.write(" ".join(f"{v:.4f}" for v in motion_row(i)) + "\n")
print(f"[mirpamo] wrote {out_path} ({FRAMES} frames)")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "out/test_clip.bvh")