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:
2026-07-23 15:37:55 -07:00
parent 998e306804
commit b4d962167f
8 changed files with 204 additions and 16 deletions
+118 -1
View File
@@ -12,6 +12,20 @@
# Usage:
# blender --background --python tools/cc_retarget.py -- \
# --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
from mathutils import Matrix, Quaternion, Vector
@@ -190,12 +204,113 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, unit):
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():
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"]
target = args.get("--target", DEFAULT_TARGET_GLTF)
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 = []
for s in srcs:
@@ -241,6 +356,8 @@ def main():
print(f"[cc] SKIP (no armature take): {f}")
else:
baked = retarget_one(src_arm, tgt_arm, act, name, scn, unit)
if "despike" in flags:
despike(baked, tgt_arm, scn)
done.append(baked)
print(f"[cc] {i + 1}/{len(fbx_files)} {name} "
f"({int(baked.frame_range[1])}f @60, from take '{act.name}')")
+2 -1
View File
@@ -23,7 +23,8 @@
import bpy, sys, os, re
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).
BONE_MAP = {
+40 -9
View File
@@ -57,6 +57,22 @@ def detect_prefix(arm):
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):
"""Bake importer object-level rotation/scale into the armature data (meters, Z-up)."""
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()}
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()
# 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
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:
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):
scn.frame_set(f)
@@ -181,18 +198,23 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
def main():
global BONE_MAP, SRC_FRAME_BONES
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
args = dict(zip(argv[::2], argv[1::2]))
srcs, out = args["--src"].split(","), args["--out"]
limit = int(args.get("--limit", 0))
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 = []
for s in srcs:
s = s.strip()
if os.path.isdir(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:
fbx_files.append(s)
if limit:
@@ -218,22 +240,31 @@ def main():
name = clip_name(f)
pre = set(bpy.data.objects)
pre_actions = set(bpy.data.actions)
bpy.ops.import_scene.fbx(filepath=f)
if f.lower().endswith((".glb", ".gltf")):
bpy.ops.import_scene.gltf(filepath=f)
else:
bpy.ops.import_scene.fbx(filepath=f)
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)
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:
print(f"[mixamo] SKIP (no armature/action): {f}")
else:
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;
# the hips translation math needs it (fcurves stay in source units).
unit = src_arm.scale.x
apply_object_transform(src_arm)
act = retarget_one(src_arm, tgt_arm, new_acts[0], name, scn, prefix, unit)
done.append(act)
print(f"[mixamo] {i + 1}/{len(fbx_files)} {name} ({int(act.frame_range[1])}f, prefix='{prefix}')")
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)
print(f"[mixamo] {i + 1}/{len(fbx_files)} {out_name} ({int(act.frame_range[1])}f, prefix='{prefix}')")
for o in new_objs:
bpy.data.objects.remove(o, do_unlink=True)
for a in new_acts: