fix(audit): converge on the 2026-07-23 animation standard — tool sync, QuatSkin INBOX, registry corrections
From the conformance audit (ariki-game .agents/audits/animation-standard-assessment-2026-07-23.md): - tools/: cc_retarget + mixamo_retarget re-synced from ariki-game (were stale forks missing --despike/--fingers/--map ual); kevin_retarget path now repo-relative; mirror rule documented in exchange/GUIDE.md - INBOX/lena: add canonical Ariki_Female_QuatSkin.glb; README marks MixamoSkin superseded - REGISTRY: tide_dance_01 flagged (no DanceType enum entry); rain_dance note corrected (RainDance IS in the enum); boat table notes live canoedismount1/run_to_dive; QC split (loop_qc pre-commit vs anim_qc post-ingest) documented - docs/iclone-bridge.md: stub created (GUIDE referenced it but it never existed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+13
-2
@@ -39,7 +39,7 @@ renamed once Jeremy says which ceremony it corresponds to. Rename with:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `water_dance_01` | 1 | awaiting take | — |
|
| `water_dance_01` | 1 | awaiting take | — |
|
||||||
| `fire_dance_01` | 1 | awaiting take | — (the old "firedance1" was mislabeled dance1 choreography) |
|
| `fire_dance_01` | 1 | awaiting take | — (the old "firedance1" was mislabeled dance1 choreography) |
|
||||||
| `tide_dance_01` | 1 | awaiting take | — |
|
| `tide_dance_01` | 1 | ⚠ **no enum entry** | — (no `TideDance` in the sim `DanceType` enum as of 2026-07-23 — confirm with Ozan whether the slot is real before filming a take) |
|
||||||
| `fertility_dance_01` | 1 | **adopted** 2026-07-16 | aloha1 (hula) — shipped with `_pp` loop |
|
| `fertility_dance_01` | 1 | **adopted** 2026-07-16 | aloha1 (hula) — shipped with `_pp` loop |
|
||||||
| `food_dance_01` | 1 | awaiting take | — |
|
| `food_dance_01` | 1 | awaiting take | — |
|
||||||
| `taming_dance_01` | 1 | **adopted** 2026-07-16 | dance1 — shipped with `_pp` loop |
|
| `taming_dance_01` | 1 | **adopted** 2026-07-16 | dance1 — shipped with `_pp` loop |
|
||||||
@@ -49,7 +49,7 @@ renamed once Jeremy says which ceremony it corresponds to. Rename with:
|
|||||||
| `colony_shield_dance_01` | 2 | awaiting take | — |
|
| `colony_shield_dance_01` | 2 | awaiting take | — |
|
||||||
| `age_progression_dance_01` | 3 | awaiting take | — (gates Age advancement) |
|
| `age_progression_dance_01` | 3 | awaiting take | — (gates Age advancement) |
|
||||||
| `star_watching_dance_01` | 3 | awaiting take | — |
|
| `star_watching_dance_01` | 3 | awaiting take | — |
|
||||||
| `rain_dance_01` | tech | awaiting take | — (tech-tree "Rain Maker"; not in DanceType enum yet) |
|
| `rain_dance_01` | 1 | awaiting take | — (tech-tree "Rain Maker"; `RainDance` **is** in the DanceType enum, Age 1 — earlier "not in enum yet" note was wrong, corrected 2026-07-23) |
|
||||||
|
|
||||||
## Register
|
## Register
|
||||||
|
|
||||||
@@ -82,3 +82,14 @@ Cyclic clips (`*_loop`, `*_idle`) must pass `loop_qc.py` before shipping; one-sh
|
|||||||
| `boat_row_loop` | `BoatRowLoop` | awaiting take | helm, stern-oar sweep ±18° |
|
| `boat_row_loop` | `BoatRowLoop` | awaiting take | helm, stern-oar sweep ±18° |
|
||||||
| `boat_fish_loop` | `BoatFishLoop` | awaiting take | Seat_Fisher; coexists with kevin Fishing01_Loop |
|
| `boat_fish_loop` | `BoatFishLoop` | awaiting take | Seat_Fisher; coexists with kevin Fishing01_Loop |
|
||||||
| `boat_sail_idle` | `BoatSailIdle` | awaiting take | sway/line-hauling loop |
|
| `boat_sail_idle` | `BoatSailIdle` | awaiting take | sway/line-hauling loop |
|
||||||
|
| *(unregistered)* | `CanoeDismount`, `CanoeSitIdle` | **live in-game** since 2026-07-21 | shipped as `ariki-game/assets/quaternius/dancegen/canoedismount1.glb` (content-guessed name, predates this table) — register under a `boat_*` code at next adoption pass |
|
||||||
|
| *(unregistered)* | `run_to_dive` | **live in-game** | Mixamo one-off at `ariki-game/assets/quaternius/mixamo/run_to_dive.glb` — outside this pipeline, listed for completeness |
|
||||||
|
|
||||||
|
## QC tool split (audit 2026-07-23)
|
||||||
|
|
||||||
|
Two QC tools exist and gate different stages — both stay:
|
||||||
|
- **`tools/loop_qc.py` (this repo)** — the **pre-commit loop gate**: local-rotation pose gap,
|
||||||
|
velocity gap, root drift; exit 0/1. Every `_pp` (and `*_loop`/`*_idle` boat clip) must pass
|
||||||
|
it before adoption — this is the "must pass loop_qc" rule above.
|
||||||
|
- **`ariki-game/tools/anim_qc.py`** — **post-ingest QC** on the game side: world-space seam
|
||||||
|
position/velocity, mid-clip stutter scan, seam-cross video render for eye review.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# iClone bridge — PC-lane overview
|
||||||
|
|
||||||
|
(Stub created 2026-07-23 — this path was referenced by `exchange/GUIDE.md` but never
|
||||||
|
written. The full operational detail lives elsewhere; this page just routes you.)
|
||||||
|
|
||||||
|
The Mac↔PC animation bridge: iClone 8 + Video Mocap on Jeremy's Windows machine authors
|
||||||
|
motion; this repo carries files both directions; ariki-game consumes 65-bone Quaternius
|
||||||
|
GLBs.
|
||||||
|
|
||||||
|
| Need | Go to |
|
||||||
|
|---|---|
|
||||||
|
| Which exchange/ folder to use, when files move/archive | `exchange/GUIDE.md` |
|
||||||
|
| Naming (nd_## → ceremony codes), loop-pair rule, registry | `docs/dances/REGISTRY.md` |
|
||||||
|
| Filming rules, iClone/AccuFACE settings, mocap cleanup, FBX export | `iclone-video-mocap` skill (`.claude/skills/` here and in ariki-game) |
|
||||||
|
| CC FBX → Quaternius GLB conversion | `tools/cc_retarget.py` (mirror of ariki-game — game copy is authoritative) |
|
||||||
|
| Looping (`_pp`) + QC gates | `tools/pingpong_bake.py` + `tools/loop_qc.py`; game-side `anim_qc.py` (split documented in REGISTRY.md) |
|
||||||
|
| Game-side landing zone | `ariki-game/assets/quaternius/dancegen/` + `assets/dances/*.json` |
|
||||||
|
| The skeleton/pack standard itself | `ariki-game/.humans/animation-bone-sources.html` + `.agents/wiki/quaternius-ual-packs.md` |
|
||||||
+7
-1
@@ -7,7 +7,13 @@ this file tells you which folder to use, when a file moves to the next stage, an
|
|||||||
when the previous version gets archived.
|
when the previous version gets archived.
|
||||||
|
|
||||||
Related docs: `docs/dances/REGISTRY.md` (naming — the source of truth),
|
Related docs: `docs/dances/REGISTRY.md` (naming — the source of truth),
|
||||||
`docs/iclone-bridge.md`, and the `animation` skill (batch workflow details).
|
`docs/iclone-bridge.md` (PC-lane overview), and the `animation` skill (batch workflow details).
|
||||||
|
|
||||||
|
**Tool mirroring rule (audit 2026-07-23):** the retargeters in `tools/`
|
||||||
|
(`cc_retarget.py`, `mixamo_retarget.py`, `kevin_retarget.py`, `mocap_retarget.py`) are
|
||||||
|
**mirrors of `ariki-game/tools/`** — the game copies are authoritative. Never edit them
|
||||||
|
here; re-copy from ariki-game when they change (they silently diverged once already:
|
||||||
|
2026-07-15 → 2026-07-23, losing `--despike`/`--fingers`/`--map ual`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,11 +1,12 @@
|
|||||||
# Lena character (from ariki-game)
|
# Lena character (from ariki-game)
|
||||||
|
|
||||||
The new Lena character, exported from `ariki-game/assets/quaternius/derived-bodies/`
|
The new Lena character, exported from `ariki-game/assets/quaternius/derived-bodies/`
|
||||||
(Ozan's standard-rig work, committed 2026-07-21).
|
(Ozan's standard-rig work, 2026-07-21 → standardized 2026-07-23).
|
||||||
|
|
||||||
| File | What it is |
|
| File | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Ariki_Female_MixamoSkin.glb` | **The game-ready Lena** — her mesh with Mixamo-derived weights transferred onto the Quaternius v1 skeleton, textures embedded. This is the exact body the game loads (dance bed model dropdown, combat stations, body bed). |
|
| `Ariki_Female_QuatSkin.glb` | **⭐ THE canonical game body (standard 2026-07-23)** — Mixamo weights on Quaternius rest orientations, exact 65-bone UE-named contract, plays the stock clip libraries natively. Import THIS one. |
|
||||||
|
| `Ariki_Female_MixamoSkin.glb` | **SUPERSEDED** by QuatSkin (kept for reference) — Mixamo-convention rig (no `root` bone, Mixamo leaf names); needed the now-stale msrig clip reroute. |
|
||||||
| `lena_tpose_legs5_mixamo_rigged.fbx` | Mixamo-rigged T-pose FBX (legs5 variant) — the rigged source; best starting point for importing into iClone/CC or Mixamo on the PC. |
|
| `lena_tpose_legs5_mixamo_rigged.fbx` | Mixamo-rigged T-pose FBX (legs5 variant) — the rigged source; best starting point for importing into iClone/CC or Mixamo on the PC. |
|
||||||
| `lena_tpose_for_mixamo.fbx` | Unrigged T-pose mesh, the file that was uploaded to Mixamo's auto-rigger. |
|
| `lena_tpose_for_mixamo.fbx` | Unrigged T-pose mesh, the file that was uploaded to Mixamo's auto-rigger. |
|
||||||
| `Lena_Body_Toned.png` / `Lena_Head_Toned.png` | Toned base-color textures (FBX files don't embed them). |
|
| `Lena_Body_Toned.png` / `Lena_Head_Toned.png` | Toned base-color textures (FBX files don't embed them). |
|
||||||
|
|||||||
+118
-1
@@ -12,6 +12,20 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# blender --background --python tools/cc_retarget.py -- \
|
# blender --background --python tools/cc_retarget.py -- \
|
||||||
# --src "<dir-or-fbx>[,<more>…]" --out <out.glb> [--name <ClipName>] [--target <gltf>]
|
# --src "<dir-or-fbx>[,<more>…]" --out <out.glb> [--name <ClipName>] [--target <gltf>]
|
||||||
|
# [--fingers track|static] static = ignore captured finger articulation; fingers
|
||||||
|
# hold the target rig's rest pose (open hand) and the
|
||||||
|
# hand moves as one unit. Use for noisy AI-mocap fingers.
|
||||||
|
# [--despike] heal short AI-mocap glitches: frames whose world-space
|
||||||
|
# acceleration spikes far above the clip's median get
|
||||||
|
# re-interpolated from the good frames around them.
|
||||||
|
# Only short windows are touched, so intentional sharp
|
||||||
|
# moves survive. Extra important for ping-pong clips —
|
||||||
|
# a glitch plays twice (forward + reversed).
|
||||||
|
#
|
||||||
|
# Looping note: seamless looping is owned by the ping-pong bake (animation repo
|
||||||
|
# pingpong_bake.py → <pack>_pp), which loops and returns to start by construction.
|
||||||
|
# The former --inplace / --loop-blend passes were removed 2026-07-17; resurrect from
|
||||||
|
# git history (commit 535719601) if a forward-loop clip ever needs them again.
|
||||||
|
|
||||||
import bpy, sys, os, re
|
import bpy, sys, os, re
|
||||||
from mathutils import Matrix, Quaternion, Vector
|
from mathutils import Matrix, Quaternion, Vector
|
||||||
@@ -190,12 +204,113 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, unit):
|
|||||||
return new_act
|
return new_act
|
||||||
|
|
||||||
|
|
||||||
|
DESPIKE_FACTOR = 200.0 # world accel must exceed FACTOR x the clip's median accel
|
||||||
|
DESPIKE_MAX_WIN = 12 # frames; longer regions are real motion, leave them alone
|
||||||
|
DESPIKE_PAD = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _hermite_patch(kps, a, b):
|
||||||
|
"""Replace kps[a+1..b-1] with a cubic that matches value AND slope at both edges."""
|
||||||
|
v = [k.co.y for k in kps]
|
||||||
|
n = b - a
|
||||||
|
m0 = (v[a] - v[a - 1]) * n
|
||||||
|
m1 = (v[b + 1] - v[b]) * n
|
||||||
|
for i in range(a + 1, b):
|
||||||
|
t = (i - a) / n
|
||||||
|
h00 = 2 * t ** 3 - 3 * t ** 2 + 1
|
||||||
|
h10 = t ** 3 - 2 * t ** 2 + t
|
||||||
|
h01 = -2 * t ** 3 + 3 * t ** 2
|
||||||
|
h11 = t ** 3 - t ** 2
|
||||||
|
kps[i].co.y = h00 * v[a] + h10 * m0 + h01 * v[b] + h11 * m1
|
||||||
|
|
||||||
|
|
||||||
|
def despike(act, tgt_arm, scn):
|
||||||
|
"""Heal short AI-mocap glitches. Detection runs in WORLD space (same metric as
|
||||||
|
tools/anim_qc.py): a bone whose world acceleration towers over the clip's median
|
||||||
|
for a few frames is a tracking error, not choreography. The flagged bone's local
|
||||||
|
curves — and its parent's, where the glitch usually originates — are re-interpolated
|
||||||
|
across the window with slope-matched cubics."""
|
||||||
|
bones = sorted(set(BONE_MAP.values()))
|
||||||
|
prev = tgt_arm.animation_data.action
|
||||||
|
tgt_arm.animation_data.action = act
|
||||||
|
if getattr(act, "slots", None) and tgt_arm.animation_data.action_slot is None:
|
||||||
|
tgt_arm.animation_data.action_slot = act.slots[0]
|
||||||
|
f0, f1 = (int(x) for x in act.frame_range)
|
||||||
|
|
||||||
|
pos = {n: [] for n in bones}
|
||||||
|
for f in range(f0, f1 + 1):
|
||||||
|
scn.frame_set(f)
|
||||||
|
dg = bpy.context.evaluated_depsgraph_get()
|
||||||
|
te = tgt_arm.evaluated_get(dg)
|
||||||
|
for n in bones:
|
||||||
|
pos[n].append((tgt_arm.matrix_world @ te.pose.bones[n].matrix).translation.copy())
|
||||||
|
tgt_arm.animation_data.action = prev
|
||||||
|
|
||||||
|
acc = {n: [(p[i + 1] - 2 * p[i] + p[i - 1]).length for i in range(1, len(p) - 1)]
|
||||||
|
for n, p in pos.items()}
|
||||||
|
flat = sorted(x for arr in acc.values() for x in arr)
|
||||||
|
thr = DESPIKE_FACTOR * (flat[len(flat) // 2] or 1e-9)
|
||||||
|
|
||||||
|
by_bone = {}
|
||||||
|
for fc in action_fcurves(act):
|
||||||
|
if fc.data_path.startswith('pose.bones["'):
|
||||||
|
by_bone.setdefault(fc.data_path.split('"')[1], []).append(fc)
|
||||||
|
|
||||||
|
repaired = 0
|
||||||
|
for n, arr in acc.items():
|
||||||
|
bad = [i + 1 for i, a in enumerate(arr) if a > thr] # -> pos/key index
|
||||||
|
if not bad:
|
||||||
|
continue
|
||||||
|
spans, s, e = [], bad[0], bad[0]
|
||||||
|
for i in bad[1:]:
|
||||||
|
if i - e <= DESPIKE_PAD * 2:
|
||||||
|
e = i
|
||||||
|
else:
|
||||||
|
spans.append((s, e))
|
||||||
|
s = e = i
|
||||||
|
spans.append((s, e))
|
||||||
|
parent = tgt_arm.data.bones[n].parent
|
||||||
|
chain = [n] + ([parent.name] if parent else [])
|
||||||
|
for s, e in spans:
|
||||||
|
a = max(1, s - DESPIKE_PAD)
|
||||||
|
b = min(len(pos[n]) - 2, e + DESPIKE_PAD)
|
||||||
|
if b - a > DESPIKE_MAX_WIN or b - a < 2:
|
||||||
|
continue
|
||||||
|
for bone in chain:
|
||||||
|
for fc in by_bone.get(bone, []):
|
||||||
|
kps = sorted(fc.keyframe_points, key=lambda k: k.co.x)
|
||||||
|
_hermite_patch(kps, a, b)
|
||||||
|
fc.update()
|
||||||
|
repaired += 1
|
||||||
|
print(f"[cc] --despike: {n} @ frame {f0 + s}"
|
||||||
|
f" ({(s) / 60:.2f}s) window {b - a}f via {'+'.join(chain)}")
|
||||||
|
print(f"[cc] --despike: repaired {repaired} glitch window(s), threshold {thr:.4f}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||||
args = dict(zip(argv[::2], argv[1::2]))
|
args, flags, i = {}, set(), 0
|
||||||
|
while i < len(argv):
|
||||||
|
if argv[i] == "--despike":
|
||||||
|
flags.add("despike")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
args[argv[i]] = argv[i + 1]
|
||||||
|
i += 2
|
||||||
srcs, out = args["--src"].split(","), args["--out"]
|
srcs, out = args["--src"].split(","), args["--out"]
|
||||||
target = args.get("--target", DEFAULT_TARGET_GLTF)
|
target = args.get("--target", DEFAULT_TARGET_GLTF)
|
||||||
forced_name = args.get("--name")
|
forced_name = args.get("--name")
|
||||||
|
fingers = args.get("--fingers", "track")
|
||||||
|
if fingers not in ("track", "static"):
|
||||||
|
raise SystemExit(f"--fingers must be 'track' or 'static', got '{fingers}'")
|
||||||
|
if fingers == "static":
|
||||||
|
# Unmapped bones ride their parent at rest offset, so dropping the finger
|
||||||
|
# entries makes each hand move as one rigid unit in the rig's rest hand pose.
|
||||||
|
dropped = [k for k, v in BONE_MAP.items()
|
||||||
|
if re.match(r"(thumb|index|middle|ring|pinky)_", v)]
|
||||||
|
for k in dropped:
|
||||||
|
del BONE_MAP[k]
|
||||||
|
print(f"[cc] --fingers static: ignoring {len(dropped)} finger bone mappings")
|
||||||
|
|
||||||
fbx_files = []
|
fbx_files = []
|
||||||
for s in srcs:
|
for s in srcs:
|
||||||
@@ -241,6 +356,8 @@ def main():
|
|||||||
print(f"[cc] SKIP (no armature take): {f}")
|
print(f"[cc] SKIP (no armature take): {f}")
|
||||||
else:
|
else:
|
||||||
baked = retarget_one(src_arm, tgt_arm, act, name, scn, unit)
|
baked = retarget_one(src_arm, tgt_arm, act, name, scn, unit)
|
||||||
|
if "despike" in flags:
|
||||||
|
despike(baked, tgt_arm, scn)
|
||||||
done.append(baked)
|
done.append(baked)
|
||||||
print(f"[cc] {i + 1}/{len(fbx_files)} {name} "
|
print(f"[cc] {i + 1}/{len(fbx_files)} {name} "
|
||||||
f"({int(baked.frame_range[1])}f @60, from take '{act.name}')")
|
f"({int(baked.frame_range[1])}f @60, from take '{act.name}')")
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
import bpy, sys, os, re
|
import bpy, sys, os, re
|
||||||
from mathutils import Matrix, Quaternion, Vector
|
from mathutils import Matrix, Quaternion, Vector
|
||||||
|
|
||||||
TARGET_GLTF = "/Users/behcetozanbozkurt/Documents/tinqs-ltd/ariki-game/assets/quaternius/base-characters/Universal Base Characters[Standard]/Base Characters/Godot - UE/Superhero_Male_FullBody.gltf"
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
TARGET_GLTF = os.path.join(REPO_ROOT, "assets/quaternius/base-characters/Universal Base Characters[Standard]/Base Characters/Godot - UE/Superhero_Male_FullBody.gltf")
|
||||||
|
|
||||||
# Kevin "B-*" → Quaternius UE names. Fingers map 1:1 (both rigs have 3-segment fingers).
|
# Kevin "B-*" → Quaternius UE names. Fingers map 1:1 (both rigs have 3-segment fingers).
|
||||||
BONE_MAP = {
|
BONE_MAP = {
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ def detect_prefix(arm):
|
|||||||
raise RuntimeError("no *Hips bone found — not a Mixamo rig?")
|
raise RuntimeError("no *Hips bone found — not a Mixamo rig?")
|
||||||
|
|
||||||
|
|
||||||
|
# ── UAL/Quaternius-source mode (2026-07-21): retarget stock-skeleton clips (UAL,
|
||||||
|
# Kevin) onto a DIFFERENT-convention skeleton (e.g. the renamed Mixamo rig — its
|
||||||
|
# bone local frames differ, so raw clip playback tips her face-down). Identity
|
||||||
|
# bone map (names already match), source frame bones are the Quaternius names.
|
||||||
|
QUAT_BONES = ["pelvis", "spine_01", "spine_02", "spine_03", "neck_01", "Head"]
|
||||||
|
for _T in ("l", "r"):
|
||||||
|
QUAT_BONES += [f"clavicle_{_T}", f"upperarm_{_T}", f"lowerarm_{_T}", f"hand_{_T}",
|
||||||
|
f"thigh_{_T}", f"calf_{_T}", f"foot_{_T}", f"ball_{_T}"]
|
||||||
|
for _i in (1, 2, 3):
|
||||||
|
QUAT_BONES += [f"index_0{_i}_{_T}", f"middle_0{_i}_{_T}", f"ring_0{_i}_{_T}",
|
||||||
|
f"pinky_0{_i}_{_T}", f"thumb_0{_i}_{_T}"]
|
||||||
|
UAL_MAP = {n: n for n in QUAT_BONES}
|
||||||
|
# Per-mode source-frame bones (see retarget_one): mixamo | ual
|
||||||
|
SRC_FRAME_BONES = ("Hips", "Head", "LeftArm")
|
||||||
|
|
||||||
|
|
||||||
def apply_object_transform(obj):
|
def apply_object_transform(obj):
|
||||||
"""Bake importer object-level rotation/scale into the armature data (meters, Z-up)."""
|
"""Bake importer object-level rotation/scale into the armature data (meters, Z-up)."""
|
||||||
bpy.ops.object.select_all(action="DESELECT")
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
@@ -109,11 +125,12 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
|
|||||||
tgt_rest_q = {n: m.to_quaternion().normalized() for n, m in tgt_rest.items()}
|
tgt_rest_q = {n: m.to_quaternion().normalized() for n, m in tgt_rest.items()}
|
||||||
|
|
||||||
C = (char_frame(tgt_rest, "pelvis", "Head", "upperarm_l")
|
C = (char_frame(tgt_rest, "pelvis", "Head", "upperarm_l")
|
||||||
@ char_frame(src_rest, prefix + "Hips", prefix + "Head", prefix + "LeftArm").inverted()).normalized()
|
@ char_frame(src_rest, prefix + SRC_FRAME_BONES[0], prefix + SRC_FRAME_BONES[1],
|
||||||
|
prefix + SRC_FRAME_BONES[2]).inverted()).normalized()
|
||||||
Cinv = C.inverted()
|
Cinv = C.inverted()
|
||||||
|
|
||||||
# Uniform size ratio from hips→head world distance (unit/scale agnostic).
|
# Uniform size ratio from hips→head world distance (unit/scale agnostic).
|
||||||
h_src = (src_rest[prefix + "Head"].translation - src_rest[prefix + "Hips"].translation).length
|
h_src = (src_rest[prefix + SRC_FRAME_BONES[1]].translation - src_rest[prefix + SRC_FRAME_BONES[0]].translation).length
|
||||||
h_tgt = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).length
|
h_tgt = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).length
|
||||||
tscale = h_tgt / h_src if h_src > 1e-5 else 1.0
|
tscale = h_tgt / h_src if h_src > 1e-5 else 1.0
|
||||||
|
|
||||||
@@ -127,7 +144,7 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
|
|||||||
for pb in tgt_arm.pose.bones:
|
for pb in tgt_arm.pose.bones:
|
||||||
pb.rotation_mode = "QUATERNION"
|
pb.rotation_mode = "QUATERNION"
|
||||||
|
|
||||||
src_hips_rest_t = src_rest[prefix + "Hips"].translation
|
src_hips_rest_t = src_rest[prefix + SRC_FRAME_BONES[0]].translation
|
||||||
|
|
||||||
for f in range(f0, f1 + 1):
|
for f in range(f0, f1 + 1):
|
||||||
scn.frame_set(f)
|
scn.frame_set(f)
|
||||||
@@ -181,18 +198,23 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
global BONE_MAP, SRC_FRAME_BONES
|
||||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||||
args = dict(zip(argv[::2], argv[1::2]))
|
args = dict(zip(argv[::2], argv[1::2]))
|
||||||
srcs, out = args["--src"].split(","), args["--out"]
|
srcs, out = args["--src"].split(","), args["--out"]
|
||||||
limit = int(args.get("--limit", 0))
|
limit = int(args.get("--limit", 0))
|
||||||
target = args.get("--target", DEFAULT_TARGET_GLTF)
|
target = args.get("--target", DEFAULT_TARGET_GLTF)
|
||||||
|
clips_filter = [c.strip().lower() for c in args.get("--clips", "").split(",") if c.strip()]
|
||||||
|
if args.get("--map") == "ual":
|
||||||
|
BONE_MAP = UAL_MAP # identity: source bones already quat-named
|
||||||
|
SRC_FRAME_BONES = ("pelvis", "Head", "upperarm_l")
|
||||||
|
|
||||||
fbx_files = []
|
fbx_files = []
|
||||||
for s in srcs:
|
for s in srcs:
|
||||||
s = s.strip()
|
s = s.strip()
|
||||||
if os.path.isdir(s):
|
if os.path.isdir(s):
|
||||||
for root, _, files in os.walk(s):
|
for root, _, files in os.walk(s):
|
||||||
fbx_files += [os.path.join(root, x) for x in sorted(files) if x.lower().endswith(".fbx")]
|
fbx_files += [os.path.join(root, x) for x in sorted(files) if x.lower().endswith((".fbx", ".glb", ".gltf"))]
|
||||||
else:
|
else:
|
||||||
fbx_files.append(s)
|
fbx_files.append(s)
|
||||||
if limit:
|
if limit:
|
||||||
@@ -218,22 +240,31 @@ def main():
|
|||||||
name = clip_name(f)
|
name = clip_name(f)
|
||||||
pre = set(bpy.data.objects)
|
pre = set(bpy.data.objects)
|
||||||
pre_actions = set(bpy.data.actions)
|
pre_actions = set(bpy.data.actions)
|
||||||
|
if f.lower().endswith((".glb", ".gltf")):
|
||||||
|
bpy.ops.import_scene.gltf(filepath=f)
|
||||||
|
else:
|
||||||
bpy.ops.import_scene.fbx(filepath=f)
|
bpy.ops.import_scene.fbx(filepath=f)
|
||||||
new_objs = [o for o in bpy.data.objects if o not in pre]
|
new_objs = [o for o in bpy.data.objects if o not in pre]
|
||||||
src_arm = next((o for o in new_objs if o.type == "ARMATURE"), None)
|
src_arm = next((o for o in new_objs if o.type == "ARMATURE"), None)
|
||||||
new_acts = [a for a in bpy.data.actions if a not in pre_actions]
|
new_acts = [a for a in bpy.data.actions if a not in pre_actions]
|
||||||
|
if clips_filter:
|
||||||
|
new_acts = [a for a in new_acts if any(c in a.name.lower() for c in clips_filter)]
|
||||||
if src_arm is None or not new_acts:
|
if src_arm is None or not new_acts:
|
||||||
print(f"[mixamo] SKIP (no armature/action): {f}")
|
print(f"[mixamo] SKIP (no armature/action): {f}")
|
||||||
else:
|
else:
|
||||||
bpy.context.view_layer.update()
|
bpy.context.view_layer.update()
|
||||||
prefix = detect_prefix(src_arm)
|
prefix = "" if args.get("--map") == "ual" else detect_prefix(src_arm)
|
||||||
# Importer unit scale (cm→m 0.01) — capture BEFORE transform_apply wipes it;
|
# Importer unit scale (cm→m 0.01) — capture BEFORE transform_apply wipes it;
|
||||||
# the hips translation math needs it (fcurves stay in source units).
|
# the hips translation math needs it (fcurves stay in source units).
|
||||||
unit = src_arm.scale.x
|
unit = src_arm.scale.x
|
||||||
apply_object_transform(src_arm)
|
apply_object_transform(src_arm)
|
||||||
act = retarget_one(src_arm, tgt_arm, new_acts[0], name, scn, prefix, unit)
|
for act_src in new_acts:
|
||||||
|
out_name = act_src.name if args.get("--map") == "ual" else name
|
||||||
|
if args.get("--map") == "ual":
|
||||||
|
act_src.name += "__src" # free the clean name — Blender dedupes the output to .001 otherwise
|
||||||
|
act = retarget_one(src_arm, tgt_arm, act_src, out_name, scn, prefix, unit)
|
||||||
done.append(act)
|
done.append(act)
|
||||||
print(f"[mixamo] {i + 1}/{len(fbx_files)} {name} ({int(act.frame_range[1])}f, prefix='{prefix}')")
|
print(f"[mixamo] {i + 1}/{len(fbx_files)} {out_name} ({int(act.frame_range[1])}f, prefix='{prefix}')")
|
||||||
for o in new_objs:
|
for o in new_objs:
|
||||||
bpy.data.objects.remove(o, do_unlink=True)
|
bpy.data.objects.remove(o, do_unlink=True)
|
||||||
for a in new_acts:
|
for a in new_acts:
|
||||||
|
|||||||
Reference in New Issue
Block a user