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:
commit
a148b4a543
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
out/
|
||||||
|
library/downloads/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.blend
|
||||||
|
*.blend1
|
||||||
|
.DS_Store
|
||||||
35
CLAUDE.md
Normal file
35
CLAUDE.md
Normal 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
15
Makefile
Normal 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
95
README.md
Normal 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
166
bin/mirpamo
Executable 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()
|
||||||
34
bonemaps/cmu_mb_to_mixamo.json
Normal file
34
bonemaps/cmu_mb_to_mixamo.json
Normal 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
291
bpy/autorig.py
Normal 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
115
bpy/common.py
Normal 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
230
bpy/retarget.py
Normal 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
79
bpy/smoke_mesh.py
Normal 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
74
bpy/verify_glb.py
Normal 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
83
library/fetch.py
Executable 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
36
library/manifest.json
Normal 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
41
scripts/deploy.sh
Executable 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
106
tests/gen_test_bvh.py
Executable 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")
|
||||||
Loading…
Reference in New Issue
Block a user