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:
+118
-1
@@ -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}')")
|
||||
|
||||
Reference in New Issue
Block a user