From b4d962167f624e7fb622ee8a90ec0cf2c7a618f2 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Thu, 23 Jul 2026 15:37:55 -0700 Subject: [PATCH] =?UTF-8?q?fix(audit):=20converge=20on=20the=202026-07-23?= =?UTF-8?q?=20animation=20standard=20=E2=80=94=20tool=20sync,=20QuatSkin?= =?UTF-8?q?=20INBOX,=20registry=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/dances/REGISTRY.md | 15 ++- docs/iclone-bridge.md | 18 +++ exchange/GUIDE.md | 8 +- exchange/INBOX/lena/Ariki_Female_QuatSkin.glb | 3 + exchange/INBOX/lena/README.md | 5 +- tools/cc_retarget.py | 119 +++++++++++++++++- tools/kevin_retarget.py | 3 +- tools/mixamo_retarget.py | 49 ++++++-- 8 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 docs/iclone-bridge.md create mode 100644 exchange/INBOX/lena/Ariki_Female_QuatSkin.glb diff --git a/docs/dances/REGISTRY.md b/docs/dances/REGISTRY.md index 9eee84e..72d76cf 100644 --- a/docs/dances/REGISTRY.md +++ b/docs/dances/REGISTRY.md @@ -39,7 +39,7 @@ renamed once Jeremy says which ceremony it corresponds to. Rename with: |---|---|---|---| | `water_dance_01` | 1 | awaiting take | — | | `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 | | `food_dance_01` | 1 | awaiting take | — | | `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 | — | | `age_progression_dance_01` | 3 | awaiting take | — (gates Age advancement) | | `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 @@ -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_fish_loop` | `BoatFishLoop` | awaiting take | Seat_Fisher; coexists with kevin Fishing01_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. diff --git a/docs/iclone-bridge.md b/docs/iclone-bridge.md new file mode 100644 index 0000000..995e170 --- /dev/null +++ b/docs/iclone-bridge.md @@ -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` | diff --git a/exchange/GUIDE.md b/exchange/GUIDE.md index 05192d7..be2304c 100644 --- a/exchange/GUIDE.md +++ b/exchange/GUIDE.md @@ -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. 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`). --- diff --git a/exchange/INBOX/lena/Ariki_Female_QuatSkin.glb b/exchange/INBOX/lena/Ariki_Female_QuatSkin.glb new file mode 100644 index 0000000..ffdf381 --- /dev/null +++ b/exchange/INBOX/lena/Ariki_Female_QuatSkin.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa53b03f063eac85999b0952de7e579e807fb5c107f183b204d5aa457183770a +size 11744728 diff --git a/exchange/INBOX/lena/README.md b/exchange/INBOX/lena/README.md index 1ab590f..23f58ce 100644 --- a/exchange/INBOX/lena/README.md +++ b/exchange/INBOX/lena/README.md @@ -1,11 +1,12 @@ # Lena character (from ariki-game) 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 | |---|---| -| `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_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). | diff --git a/tools/cc_retarget.py b/tools/cc_retarget.py index fcd354d..c531e4b 100644 --- a/tools/cc_retarget.py +++ b/tools/cc_retarget.py @@ -12,6 +12,20 @@ # Usage: # blender --background --python tools/cc_retarget.py -- \ # --src "[,…]" --out [--name ] [--target ] +# [--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 → _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}')") diff --git a/tools/kevin_retarget.py b/tools/kevin_retarget.py index 3a22b1b..96c078c 100644 --- a/tools/kevin_retarget.py +++ b/tools/kevin_retarget.py @@ -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 = { diff --git a/tools/mixamo_retarget.py b/tools/mixamo_retarget.py index a7fb7cf..6643417 100644 --- a/tools/mixamo_retarget.py +++ b/tools/mixamo_retarget.py @@ -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: