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}')")