Squashed from work: - asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire - asset-ui: loop video chains — text→image→video that ends where it began - h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers - h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor - asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits - asset-ai realtime: a feedback loop survives a resize and travels by default - asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener - asset-ai realtime: the outbound encode leaves the loop's critical path - asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned - llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own - vision tower on CUDA: the encode leg gets its two missing kernels - llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error - vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away - live: a feed that moves box takes its trip with it — one seed image - vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go - llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs - asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels - Merge branch 'ocr-perf-integration' into work - asset-ai: the live anchor can follow the trip, and text leaves the 5090 - asset-ai: the camera moves the world, and the world starts still - asset-import: the EA strategy classics, in the one 2D contract - rtsmap: one seeded generator for tiled strategy maps - asset-ui: one card for the strategy classics, with a pack dropdown - asset-ai: music3 reference-audio path, ocr/h3 backends, registry - asset: mp4 sample index for range-streaming, chat tools, import profiles - cnc: tiberium is twelve growth frames, not twelve empty variants - platform: native file and save dialogs, in-house on all three desktops - chat: the scan holds out for a lane home - chat: a full home queues you — take the free lane - chat: the preload has a percentage, and the boundless cap stops showing - llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA - sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin - sqlite_query: anti-join regression test - td import: HARV's second frame block is its harvesting cycle, not a turret - asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling - ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin - asset-ui: test health fixtures grow the realtime field they were born without - ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re - ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo - ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic - strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move - ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore - ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4) - ai-hub: job leases — work lives only while it is renewed (aicore §8) - asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9) - ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3) - ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor - ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w - ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w - asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct - ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle - asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9) - ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a - route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p - asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6) - makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026 - ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a - ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open - ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4) - asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state ( - ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path - vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1) - asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore - chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5) - asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F - asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8) - asset-store: the shrink — the store stores (aicore P7) - importer + asset-server host: the coordination era ends (aicore P7) - store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7) - client + chat dispatcher: the dead wire comes out (aicore P7/P8) - ai-hub: 0.3.0 — the health version says which era a node runs - ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing - ai-hub: the preload note percents the prefill, not the job bar - ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7) - ai-hub: an open-think model is thinking from its first token - libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly - zero-warning sweep, round two — the first full-workspace pass - zero-warning sweep, round three — the model lanes and the deep examples - zero-warning sweep, round four — the last stragglers - zero-warning sweep, round five — vj and chat-ui - zero-warning sweep, round six — three cascades Co-authored-by: Claude <info@makepad.nl>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
# Retarget HY-Motion SMPL-H clips onto a rigged GLB character (UniRig or
|
|
# SkinTokens rigs — the chain classifier reads the armature, not bone names).
|
|
# VERBATIM the motion campaign's proven DIRECTION-BASED retarget
|
|
# (ai_stage/retarget.py on .123; global-delta transfer is WRONG — it
|
|
# double-applies the rest pose) + one addition: --in-place strips the
|
|
# HORIZONTAL pelvis travel so clips play in place and a game host drives
|
|
# movement from its own transform (vertical stays — the jump arc reads).
|
|
# Runs under venv_unirig python (bpy 4.2). Usage:
|
|
# python retarget_multi.py <rigged.glb> <out.glb> <clip1=name1.npz>
|
|
# [<clip2=...>] [--in-place]
|
|
import sys, os, json
|
|
import numpy as np
|
|
import bpy
|
|
from mathutils import Matrix, Vector, Quaternion
|
|
|
|
TMPL = os.environ.get(
|
|
"HYMOTION_WOODEN_DIR",
|
|
r"C:\ai\HY-Motion-1.0\scripts\gradio\static\assets\dump_wooden",
|
|
)
|
|
SMPL_NAMES = json.load(open(os.path.join(TMPL, "joint_names.json")))
|
|
KIN = np.fromfile(os.path.join(TMPL, "kintree.bin"), dtype=np.int32)
|
|
JT = np.fromfile(os.path.join(TMPL, "j_template.bin"), dtype=np.float32).reshape(-1, 3).astype(np.float64)
|
|
NAME2IDX = {n: i for i, n in enumerate(SMPL_NAMES)}
|
|
|
|
def aa2R(v):
|
|
th = np.linalg.norm(v)
|
|
if th < 1e-9: return np.eye(3)
|
|
a = v / th
|
|
K = np.array([[0,-a[2],a[1]],[a[2],0,-a[0]],[-a[1],a[0],0]])
|
|
return np.eye(3) + np.sin(th)*K + (1-np.cos(th))*(K@K)
|
|
|
|
def smpl_fk(poses_t, Rh_t, trans_t):
|
|
J = len(KIN)
|
|
R = [None]*J
|
|
P = [None]*J
|
|
# HY-Motion's official `construct_smpl_data_dict` stores the same root
|
|
# rotation twice: once at poses[0:3] and again in Rh. Rh is a legacy
|
|
# EasyMocap alias, not a second transform. Multiplying both here used to
|
|
# square the pelvis rotation and poison every descendant direction before
|
|
# retargeting (most visibly, both knees crossed the centre line). Match
|
|
# the official WoodenMesh/simple_lbs contract: apply joint 0 exactly once.
|
|
R[0] = aa2R(poses_t[0:3])
|
|
P[0] = trans_t.astype(np.float64)
|
|
for i in range(1, J):
|
|
p = KIN[i]
|
|
R[i] = R[p] @ aa2R(poses_t[3*i:3*i+3])
|
|
P[i] = P[p] + R[p] @ (JT[i]-JT[p])
|
|
return R, np.array(P)
|
|
|
|
def rot_between(a, b):
|
|
a = a/ (np.linalg.norm(a)+1e-12); b = b/(np.linalg.norm(b)+1e-12)
|
|
c = np.cross(a, b); d = float(np.dot(a, b))
|
|
if d > 0.999999: return np.eye(3)
|
|
if d < -0.999999:
|
|
# pick any orthogonal axis
|
|
ax = np.cross(a, [1.0,0,0])
|
|
if np.linalg.norm(ax) < 1e-6: ax = np.cross(a, [0,1.0,0])
|
|
ax /= np.linalg.norm(ax)
|
|
K = np.array([[0,-ax[2],ax[1]],[ax[2],0,-ax[0]],[-ax[1],ax[0],0]])
|
|
return np.eye(3) + 2*(K@K)
|
|
K = np.array([[0,-c[2],c[1]],[c[2],0,-c[0]],[-c[1],c[0],0]])
|
|
return np.eye(3) + K + K@K*(1.0/(1.0+d))
|
|
|
|
# ---------- import rig ----------
|
|
rig_path, out_path = sys.argv[1], sys.argv[2]
|
|
clips = []
|
|
IN_PLACE = False
|
|
for a in sys.argv[3:]:
|
|
if a == "--in-place":
|
|
IN_PLACE = True
|
|
continue
|
|
nm, path = a.split("=", 1)
|
|
clips.append((nm, path))
|
|
print("in_place:", IN_PLACE)
|
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_scene.gltf(filepath=rig_path)
|
|
arm = [o for o in bpy.data.objects if o.type == 'ARMATURE'][0]
|
|
bpy.context.view_layer.objects.active = arm
|
|
print("armature:", arm.name, "bones:", len(arm.data.bones))
|
|
|
|
bones = arm.data.bones
|
|
bone_names = [b.name for b in bones]
|
|
parent = {b.name: (b.parent.name if b.parent else None) for b in bones}
|
|
children = {}
|
|
for b in bones:
|
|
children.setdefault(parent[b.name], []).append(b.name)
|
|
head = {b.name: np.array((arm.matrix_world @ b.matrix_local).to_translation()) for b in bones}
|
|
tailp = {b.name: np.array((arm.matrix_world @ Matrix.Translation(b.tail_local)).to_translation()) for b in bones}
|
|
roots = children.get(None, [])
|
|
root = roots[0]
|
|
print("root:", root, "root children:", children.get(root))
|
|
|
|
# ---------- chain classification (blender z-up) ----------
|
|
def chain_down(start):
|
|
ch = [start]
|
|
while True:
|
|
cs = children.get(ch[-1], [])
|
|
if len(cs) != 1: break
|
|
ch.append(cs[0])
|
|
return ch
|
|
|
|
root_kids = children.get(root, [])
|
|
legs, spine_start = [], None
|
|
for k in root_kids:
|
|
if head[k][2] < head[root][2] - 1e-6: legs.append(k)
|
|
else: spine_start = k
|
|
assert len(legs) == 2 and spine_start, f"unexpected root children {root_kids}"
|
|
leg_chains = [chain_down(l) for l in legs]
|
|
|
|
spine = [spine_start]
|
|
while True:
|
|
cs = children.get(spine[-1], [])
|
|
if len(cs) == 1: spine.append(cs[0])
|
|
else: break
|
|
chest = spine[-1]
|
|
chest_kids = children.get(chest, [])
|
|
# classify by chain ENDPOINT: arms end far out in |x|, neck ends high in z
|
|
kid_chains = [chain_down(k) for k in chest_kids]
|
|
ends = [tailp[ch[-1]] for ch in kid_chains]
|
|
xdev = [abs(e[0] - head[chest][0]) for e in ends]
|
|
arm_idx = sorted(range(len(kid_chains)), key=lambda i: -xdev[i])[:2]
|
|
rest_idx = [i for i in range(len(kid_chains)) if i not in arm_idx]
|
|
arm_chains = [kid_chains[i] for i in arm_idx]
|
|
neck_chain = []
|
|
if rest_idx:
|
|
ni = max(rest_idx, key=lambda i: ends[i][2])
|
|
neck_chain = kid_chains[ni]
|
|
assert len(arm_chains) == 2, f"chest kids {chest_kids}"
|
|
print("spine:", spine, "\nchest:", chest, "\nlegs:", leg_chains, "\narms:", arm_chains, "\nneck:", neck_chain)
|
|
|
|
# left = +x in blender after gltf import (gltf +x preserved)
|
|
def lr(chains):
|
|
a, b = chains
|
|
return (a, b) if head[a[0]][0] > head[b[0]][0] else (b, a)
|
|
Lleg, Rleg = lr(leg_chains)
|
|
Larm, Rarm = lr(arm_chains)
|
|
|
|
# mapping: bone -> (smpl_from, smpl_to, rig_child) direction contract;
|
|
# pelvis is handled separately. Auto-riggers are allowed to create
|
|
# unconnected Blender bones: a bone's local +Y/tail direction is then NOT the
|
|
# direction from that joint node to its child node. Retargeting the +Y axis
|
|
# made SkinTokens hips/shoulders fold inward even for a clean SMPL pose. The
|
|
# hierarchy edge is the segment whose endpoint we must place, while applying
|
|
# its alignment to the complete rest frame preserves the rigger's bone roll.
|
|
mapping = {}
|
|
def assign_dirs(chain, pairs):
|
|
for i, pr in enumerate(pairs):
|
|
if i < len(chain):
|
|
rig_child = chain[i + 1] if i + 1 < len(chain) else None
|
|
mapping[chain[i]] = (NAME2IDX[pr[0]], NAME2IDX[pr[1]], rig_child)
|
|
assign_dirs(Lleg, [("L_Hip","L_Knee"),("L_Knee","L_Ankle"),("L_Ankle","L_Foot"),("L_Ankle","L_Foot")])
|
|
assign_dirs(Rleg, [("R_Hip","R_Knee"),("R_Knee","R_Ankle"),("R_Ankle","R_Foot"),("R_Ankle","R_Foot")])
|
|
sp = spine
|
|
sp_pairs = [("Spine1","Spine2"),("Spine2","Spine3"),("Spine3","Neck")]
|
|
if len(sp) >= 3:
|
|
idxs = [round(i*(len(sp)-1)/2) for i in range(3)]
|
|
for pr, ci in zip(sp_pairs, idxs):
|
|
rig_child = sp[ci + 1] if ci + 1 < len(sp) else (neck_chain[0] if neck_chain else None)
|
|
mapping[sp[ci]] = (NAME2IDX[pr[0]], NAME2IDX[pr[1]], rig_child)
|
|
else:
|
|
for i in range(len(sp)):
|
|
rig_child = sp[i + 1] if i + 1 < len(sp) else (neck_chain[0] if neck_chain else None)
|
|
mapping[sp[i]] = (NAME2IDX[sp_pairs[i][0]], NAME2IDX[sp_pairs[i][1]], rig_child)
|
|
assign_dirs(Larm, [("L_Collar","L_Shoulder"),("L_Shoulder","L_Elbow"),("L_Elbow","L_Wrist"),("L_Wrist","L_Middle1")])
|
|
assign_dirs(Rarm, [("R_Collar","R_Shoulder"),("R_Shoulder","R_Elbow"),("R_Elbow","R_Wrist"),("R_Wrist","R_Middle1")])
|
|
if neck_chain:
|
|
for i, bn in enumerate(neck_chain):
|
|
rig_child = neck_chain[i + 1] if i + 1 < len(neck_chain) else None
|
|
mapping[bn] = (NAME2IDX["Neck"], NAME2IDX["Head"], rig_child)
|
|
print("mapping:")
|
|
for bn, (a, b, rig_child) in sorted(mapping.items()):
|
|
print(" ", bn, "->", SMPL_NAMES[a], "->", SMPL_NAMES[b], "via", rig_child or "tail")
|
|
|
|
# smpl gltf(y-up) -> blender(z-up)
|
|
M = np.array([[1,0,0],[0,0,-1],[0,1,0]], dtype=np.float64)
|
|
|
|
# rig facing from foot direction (blender space, ground plane XY)
|
|
fdir = np.zeros(3)
|
|
for ch in (Lleg, Rleg):
|
|
ank = ch[2] if len(ch) > 2 else ch[-1]
|
|
ft = ch[3] if len(ch) > 3 else ch[-1]
|
|
fdir += (tailp[ft] - head[ank])
|
|
fdir[2] = 0.0
|
|
fdir /= (np.linalg.norm(fdir) + 1e-9)
|
|
smpl_fwd_b = M @ np.array([0,0,1.0]) # smpl canonical forward in blender space
|
|
smpl_fwd_b[2] = 0; smpl_fwd_b /= np.linalg.norm(smpl_fwd_b)
|
|
ang = np.arctan2(fdir[1], fdir[0]) - np.arctan2(smpl_fwd_b[1], smpl_fwd_b[0])
|
|
Yaw = np.array([[np.cos(ang),-np.sin(ang),0],[np.sin(ang),np.cos(ang),0],[0,0,1]])
|
|
C = Yaw @ M
|
|
print("rig fwd:", fdir, "yaw deg:", np.degrees(ang))
|
|
|
|
# left/right sanity: smpl L_Hip offset mapped into blender vs rig left hip
|
|
def flip(ji):
|
|
n = SMPL_NAMES[ji]
|
|
if n.startswith("L_"): return NAME2IDX["R_" + n[2:]]
|
|
if n.startswith("R_"): return NAME2IDX["L_" + n[2:]]
|
|
return ji
|
|
lhip_b = C @ (JT[NAME2IDX["L_Hip"]] - JT[NAME2IDX["Pelvis"]])
|
|
rig_lhip = head[Lleg[0]] - head[root]
|
|
if lhip_b[0] * rig_lhip[0] < 0:
|
|
print("MIRROR DETECTED -> swapping L/R smpl assignment")
|
|
mapping = {bn: (flip(a), flip(b), rig_child) for bn, (a, b, rig_child) in mapping.items()}
|
|
|
|
# scale: rig height vs smpl height
|
|
zs = [head[b][2] for b in bone_names] + [tailp[b][2] for b in bone_names]
|
|
rig_h = max(zs) - min(zs)
|
|
smpl_h = JT[:,1].max() - JT[:,1].min() + 0.3 # head top fudge
|
|
scale = rig_h / 1.75
|
|
print("rig_h:", rig_h, "scale:", scale)
|
|
|
|
# rest matrices (armature space)
|
|
ML = {b.name: b.matrix_local.copy() for b in bones}
|
|
order = []
|
|
def topo(bn):
|
|
order.append(bn)
|
|
for c in children.get(bn, []): topo(c)
|
|
for r in roots: topo(r)
|
|
|
|
scene = bpy.context.scene
|
|
scene.render.fps = 30
|
|
pose = arm.pose
|
|
|
|
def np2mat(R, t):
|
|
m = Matrix.Identity(4)
|
|
for i in range(3):
|
|
for jj in range(3): m[i][jj] = R[i][jj]
|
|
m[i][3] = t[i]
|
|
return m
|
|
|
|
actions = []
|
|
for clip_name, npz_path in clips:
|
|
d = np.load(npz_path)
|
|
poses_all, trans_all, Rh_all = d["poses"], d["trans"], d["Rh"]
|
|
T = poses_all.shape[0]
|
|
act = bpy.data.actions.new(clip_name)
|
|
arm.animation_data_create()
|
|
arm.animation_data.action = act
|
|
p0 = trans_all[0].copy()
|
|
root_rest_t = np.array(ML[root].to_translation())
|
|
# Rest direction of the actual hierarchy edge. For terminal bones there
|
|
# is no child joint to place, so retain the Blender +Y/tail direction.
|
|
rest_dir = {}
|
|
for bn in bone_names:
|
|
mapped = mapping.get(bn)
|
|
rig_child = mapped[2] if mapped else None
|
|
if rig_child:
|
|
rest_dir[bn] = np.array(ML[rig_child].to_translation() - ML[bn].to_translation())
|
|
else:
|
|
rest_dir[bn] = np.array(ML[bn].to_3x3() @ Vector((0,1,0)))
|
|
pelvis_i = NAME2IDX["Pelvis"]
|
|
for t in range(T):
|
|
G, P = smpl_fk(poses_all[t], Rh_all[t], trans_all[t])
|
|
Mpose = {}
|
|
for bn in order:
|
|
pb = pose.bones[bn]
|
|
par = parent[bn]
|
|
Mpar = Mpose[par] if par else Matrix.Identity(4)
|
|
offset = (ML[par].inverted() @ ML[bn]) if par else ML[bn]
|
|
Mhier = Mpar @ offset
|
|
rest_rot = np.array(ML[bn].to_3x3())
|
|
if bn == root:
|
|
# pelvis: orientation delta (starts ~identity) + translation
|
|
delta = C @ np.array(G[pelvis_i]) @ C.T
|
|
want_rot = delta @ rest_rot
|
|
d_tr = (trans_all[t] - p0).copy()
|
|
if IN_PLACE:
|
|
# SMPL is y-up: x/z are the ground plane. Zero the
|
|
# horizontal travel, keep the vertical (crouch, jump).
|
|
d_tr[0] = 0.0
|
|
d_tr[2] = 0.0
|
|
tvec = C @ (d_tr * scale) + root_rest_t
|
|
Mdes = np2mat(want_rot, tvec)
|
|
elif bn in mapping:
|
|
a, b, _rig_child = mapping[bn]
|
|
v = P[b] - P[a]
|
|
u = C @ v
|
|
R_align = rot_between(rest_dir[bn], u)
|
|
want_rot = R_align @ rest_rot
|
|
tvec = np.array(Mhier.to_translation())
|
|
Mdes = np2mat(want_rot, tvec)
|
|
else:
|
|
Mdes = Mhier
|
|
basis = Mhier.inverted() @ Mdes
|
|
Mpose[bn] = Mdes
|
|
pb.matrix_basis = basis
|
|
q = basis.to_quaternion()
|
|
pb.rotation_mode = 'QUATERNION'
|
|
pb.rotation_quaternion = q
|
|
pb.keyframe_insert("rotation_quaternion", frame=t)
|
|
if bn == root:
|
|
pb.location = basis.to_translation()
|
|
pb.keyframe_insert("location", frame=t)
|
|
# stash to NLA
|
|
tr = arm.animation_data.nla_tracks.new()
|
|
tr.name = clip_name
|
|
tr.strips.new(clip_name, 1, act)
|
|
actions.append(act)
|
|
print("clip done:", clip_name, "frames:", T)
|
|
|
|
arm.animation_data.action = None
|
|
scene.frame_start = 0
|
|
scene.frame_end = 1
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.export_scene.gltf(filepath=out_path, export_format='GLB',
|
|
export_animations=True, export_animation_mode='NLA_TRACKS',
|
|
export_skins=True, export_yup=True, export_apply=False)
|
|
print("EXPORTED", out_path, os.path.getsize(out_path))
|
|
print("RETARGET-DONE")
|