makepad/libs/ai/hub/python/retarget_multi.py
Admin e37c263b9c ai backbone: the hub era — makepad_ai deleted, every backend is a hub pipe; machine residency elections, job leases, ETA placement; the store only stores; creator pipelines run in the app (aicore)
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>
2026-09-01 16:46:31 +02:00

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