commit a148b4a543004aef2067e6142b5a9e039fd36d63 Author: jing Date: Fri Jul 17 01:26:13 2026 +1000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22b3eaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +out/ +library/downloads/ +__pycache__/ +*.pyc +*.blend +*.blend1 +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a34f79c --- /dev/null +++ b/CLAUDE.md @@ -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/.py -- `. +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e0c5a3 --- /dev/null +++ b/Makefile @@ -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/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..74447b3 --- /dev/null +++ b/README.md @@ -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) diff --git a/bin/mirpamo b/bin/mirpamo new file mode 100755 index 0000000..151a112 --- /dev/null +++ b/bin/mirpamo @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""MIRPAMO — local Mixamo-style character pipeline. + +Orchestrates headless Blender. Subcommands: + + mirpamo rig -o rigged.glb [--markers m.json] [--name N] + mirpamo anim [ ...] -o out.glb + [--bonemap map.json] [--inplace] [--fps 30] + mirpamo batch -o out_dir [--bonemap ...] [--inplace] + mirpamo verify [--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() diff --git a/bonemaps/cmu_mb_to_mixamo.json b/bonemaps/cmu_mb_to_mixamo.json new file mode 100644 index 0000000..99a705c --- /dev/null +++ b/bonemaps/cmu_mb_to_mixamo.json @@ -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" +} diff --git a/bpy/autorig.py b/bpy/autorig.py new file mode 100644 index 0000000..ac5b9f0 --- /dev/null +++ b/bpy/autorig.py @@ -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() diff --git a/bpy/common.py b/bpy/common.py new file mode 100644 index 0000000..8c66f3f --- /dev/null +++ b/bpy/common.py @@ -0,0 +1,115 @@ +"""Shared helpers for MIRPAMO's headless-Blender stages. + +Every stage script is run as: + blender -b --python bpy/.py -- +""" +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) diff --git a/bpy/retarget.py b/bpy/retarget.py new file mode 100644 index 0000000..d494508 --- /dev/null +++ b/bpy/retarget.py @@ -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() diff --git a/bpy/smoke_mesh.py b/bpy/smoke_mesh.py new file mode 100644 index 0000000..9cf9473 --- /dev/null +++ b/bpy/smoke_mesh.py @@ -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() diff --git a/bpy/verify_glb.py b/bpy/verify_glb.py new file mode 100644 index 0000000..008f555 --- /dev/null +++ b/bpy/verify_glb.py @@ -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() diff --git a/library/fetch.py b/library/fetch.py new file mode 100755 index 0000000..527c6e4 --- /dev/null +++ b/library/fetch.py @@ -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() diff --git a/library/manifest.json b/library/manifest.json new file mode 100644 index 0000000..ed89218 --- /dev/null +++ b/library/manifest.json @@ -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 --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 ~/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 + } + ] +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..9a3dce9 --- /dev/null +++ b/scripts/deploy.sh @@ -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" diff --git a/tests/gen_test_bvh.py b/tests/gen_test_bvh.py new file mode 100755 index 0000000..adcfce2 --- /dev/null +++ b/tests/gen_test_bvh.py @@ -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")