File size: 13,535 Bytes
5c331a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
"""Retarget generated SOMA directions to a learned Mixamo skin, preserving proportions."""

import argparse
import json
from pathlib import Path
import re
import sys

import bpy
import numpy as np
from mathutils import Matrix, Vector

p = argparse.ArgumentParser()
for name in ["input", "motion", "bvh", "output"]:
    p.add_argument("--" + name, required=True)
p.add_argument("--body-clearance", action="store_true")
p.add_argument("--smooth-joints", action="store_true")
p.add_argument("--arm-blend-band", type=float, default=0.55)
a = p.parse_args(sys.argv[sys.argv.index("--") + 1 :])
out = Path(a.output)
out.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(Path(a.input).resolve()))
rigs = [o for o in scene.objects if o.type == "ARMATURE"]
if len(rigs) != 1:
    raise ValueError("Learned rig must contain exactly one skeleton.")
rig = rigs[0]
meshes = [
    o
    for o in scene.objects
    if o.type == "MESH" and any(m.type == "ARMATURE" and m.object == rig for m in o.modifiers)
]
rig.animation_data_clear()
for bone in rig.pose.bones:
    bone.matrix_basis.identity()
bpy.context.view_layer.update()
for action in list(bpy.data.actions):
    bpy.data.actions.remove(action)
# Normalize the whole hierarchy together, retaining bind matrices and textured geometry.
vertices = np.array([tuple(o.matrix_world @ v.co) for o in meshes for v in o.data.vertices])
low, high = vertices.min(0), vertices.max(0)
height = high[2] - low[2]
if height < 1e-6:
    raise ValueError("Degenerate learned rig bounds.")
transform = Matrix.Scale(1 / height, 4) @ Matrix.Translation(
    Vector((-(low[0] + high[0]) / 2, -(low[1] + high[1]) / 2, -low[2]))
)
for obj in list(scene.objects):
    if obj.parent is None:
        obj.matrix_world = transform @ obj.matrix_world
bpy.context.view_layer.update()
# Standardize exported bone names so the geometric auditor can identify extremities.
rename = {"Spine": "Spine1", "Spine1": "Spine2", "Spine2": "Chest", "Neck": "Neck1"}
for side in ["Left", "Right"]:
    rename[side + "UpLeg"] = side + "Leg"
    rename[side + "Leg"] = side + "Shin"
original = [(b, b.name, b.name.split(":")[-1]) for b in rig.data.bones]
groups_by_bone = {old: [obj.vertex_groups.get(old) for obj in meshes] for bone, old, short in original}
for bone, old, short in original:
    staged = "__retarget_" + short
    bone.name = staged
    for group in groups_by_bone[old]:
        if group is not None and group.name != staged:
            group.name = staged
for bone, old, short in original:
    new = rename.get(short, short)
    bone.name = new
    for group in groups_by_bone[old]:
        if group is not None and group.name != new:
            group.name = new
for obj in meshes:
    if any(group.name not in rig.data.bones for group in obj.vertex_groups):
        raise ValueError("Skin groups do not match retargeted skeleton bone names.")
source_names = re.findall(r"JOINT (\S+)", Path(a.bvh).read_text())
idx = {name: i for i, name in enumerate(source_names)}
with np.load(a.motion, allow_pickle=False) as data:
    pos = data["posed_joints"].copy()
    source_rotations = data["global_rot_mats"].copy()
    contacts = data["foot_contacts"].reshape(len(pos), 2, -1).max(axis=2) > 0.5
C = np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]])
pos = pos @ C.T
source_rotations = C @ source_rotations @ C.T
if pos.shape[1] != len(source_names):
    raise ValueError("Motion NPZ and BVH skeleton disagree.")
tips = {
    "Hips": "Spine1",
    "Spine1": "Spine2",
    "Spine2": "Chest",
    "Chest": "Neck1",
    "Neck1": "Head",
    "Head": "HeadEnd",
}
for side in ["Left", "Right"]:
    for bone, tip in [
        ("Shoulder", "Arm"),
        ("Arm", "ForeArm"),
        ("ForeArm", "Hand"),
        ("Hand", "HandMiddleEnd"),
        ("Leg", "Shin"),
        ("Shin", "Foot"),
        ("Foot", "ToeBase"),
    ]:
        tips[side + bone] = side + tip
required = {"Hips", "Head", "LeftHand", "RightHand", "LeftFoot", "RightFoot"}
if not required.issubset(rig.data.bones.keys()):
    raise ValueError("Learned skeleton is not a supported Mixamo humanoid.")
bones = list(rig.data.bones)
heads = {b.name: np.array(b.head_local) for b in bones}
rest = {b.name: b.matrix_local.to_3x3() for b in bones}
torso_points = []
for obj in meshes:
    lookup = {group.index: group.name for group in obj.vertex_groups}
    matrix = rig.matrix_world.inverted() @ obj.matrix_world
    for vertex in obj.data.vertices:
        torso_weight = sum(
            group.weight
            for group in vertex.groups
            if lookup[group.group] in {"Hips", "Spine1", "Spine2", "Chest", "LeftLeg", "RightLeg"}
        )
        if torso_weight > 0.7:
            torso_points.append(tuple(matrix @ vertex.co))
if not torso_points:
    raise ValueError("Learned skin has no torso region.")
torso_points = np.array(torso_points)
body_low, body_high = np.quantile(torso_points, [0.01, 0.99], axis=0)
if a.smooth_joints:
    for side in ["Left", "Right"]:
        for upper, lower in [
            (side + "Arm", side + "ForeArm"),
            (side + "Leg", side + "Shin"),
            (side + "Shin", side + "Foot"),
        ]:
            pivot = heads[lower]
            direction = Vector(heads[lower] - heads[upper]).normalized()
            length = np.linalg.norm(heads[lower] - heads[upper])
            band = length * (
                a.arm_blend_band if upper.endswith("Arm") else 0.35 if upper.endswith("Shin") else 0.55
            )
            for obj in meshes:
                matrix = rig.matrix_world.inverted() @ obj.matrix_world
                groups = {group.index: group.name for group in obj.vertex_groups}
                for vertex in obj.data.vertices:
                    total = sum(
                        group.weight for group in vertex.groups if groups[group.group] in {upper, lower}
                    )
                    if total < 1e-6:
                        continue
                    point = matrix @ vertex.co
                    t = float(np.clip((np.dot(np.array(point) - pivot, direction) + band) / (2 * band), 0, 1))
                    t = t * t * (3 - 2 * t)
                    obj.vertex_groups[upper].add([vertex.index], total * (1 - t), "REPLACE")
                    obj.vertex_groups[lower].add([vertex.index], total * t, "REPLACE")
source_height = np.median(
    pos[:, idx["HeadEnd"], 2] - np.minimum(pos[:, idx["LeftFoot"], 2], pos[:, idx["RightFoot"], 2])
)
target_height = (rig.data.bones["Head"].head_local - rig.data.bones["LeftFoot"].head_local).length
scale = target_height / max(source_height, 1e-6)
clearance_radius = target_height * 0.10
clearance_changes = 0
scene.frame_start = 1
scene.frame_end = len(pos)
for f in range(len(pos)):
    scene.frame_set(f + 1)
    rotations, targets = {}, {}
    for bone in bones:
        name = bone.name
        parent = bone.parent.name if bone.parent else None
        if parent:
            targets[name] = targets[parent] + np.array(rotations[parent]) @ (heads[name] - heads[parent])
        else:
            delta = pos[f, idx["Hips"]] - pos[0, idx["Hips"]]
            delta[:2] = 0
            targets[name] = heads[name] + delta * scale
        if name == "Head" or name.endswith("Hand"):
            source_parent = "Neck1" if name == "Head" else name.replace("Hand", "ForeArm")
            relative = source_rotations[f, idx[source_parent]].T @ source_rotations[f, idx[name]]
            initial = source_rotations[0, idx[source_parent]].T @ source_rotations[0, idx[name]]
            delta_rotation = Matrix((relative @ initial.T).tolist())
            rotation = rotations[parent] @ delta_rotation
        elif name in tips and name in idx and tips[name] in idx:
            direction = rig.matrix_world.to_3x3().inverted() @ Vector(
                pos[f, idx[tips[name]]] - pos[f, idx[name]]
            )
            if a.body_clearance and name.endswith(("Arm", "ForeArm")):
                rest_direction = heads[tips[name]] - heads[name]
                length = np.linalg.norm(rest_direction)
                unit = np.array(direction.normalized())
                end = targets[name] + unit * length
                pad = clearance_radius
                if (
                    body_low[1] - pad < end[1] < body_high[1] + pad
                    and body_low[2] - pad < end[2] < body_high[2] + pad
                ):
                    sign = 1 if name.startswith("Left") else -1
                    bound = body_high[0] + pad if sign > 0 else body_low[0] - pad
                    desired_x = (bound - targets[name][0]) / max(length, 1e-8)
                    if unit[0] * sign < desired_x * sign:
                        desired_x = float(np.clip(desired_x, -0.8, 0.8))
                        yz = unit[1:] / max(np.linalg.norm(unit[1:]), 1e-8)
                        direction = Vector((desired_x, *(yz * np.sqrt(1 - desired_x**2))))
                        clearance_changes += 1
            if a.body_clearance and name.endswith(("Leg", "Shin")):
                length = np.linalg.norm(heads[tips[name]] - heads[name])
                unit = np.array(direction.normalized())
                endpoint = targets[name] + unit * length
                sign = 1 if name.startswith("Left") else -1
                hip_x = targets["Hips"][0]
                stance_x = hip_x + (heads[tips[name]][0] - heads["Hips"][0])
                if (endpoint[0] - stance_x) * sign < 0:
                    desired_x = float(np.clip((stance_x - targets[name][0]) / max(length, 1e-8), -0.8, 0.8))
                    yz = unit[1:] / max(np.linalg.norm(unit[1:]), 1e-8)
                    direction = Vector((desired_x, *(yz * np.sqrt(1 - desired_x**2))))
                    clearance_changes += 1
            rotation = (
                (
                    Vector(heads[tips[name]] - heads[name])
                    if tips[name] in heads
                    else bone.tail_local - bone.head_local
                )
                .rotation_difference(Vector(direction))
                .to_matrix()
            )
        else:
            rotation = rotations[parent] if parent else Matrix.Identity(3)
        rotations[name] = rotation
        matrix = (rotation @ rest[name]).to_4x4()
        matrix.translation = Vector(targets[name])
        pb = rig.pose.bones[name]
        pb.rotation_mode = "QUATERNION"
        pb.matrix = matrix
        bpy.context.view_layer.update()
        for channel in ["location", "rotation_quaternion", "scale"]:
            pb.keyframe_insert(channel, frame=f + 1)
# Preserve generated airborne phases while aligning source support contacts with the floor.
foot_ids = {}
for side, sign in [("Left", 1), ("Right", -1)]:
    foot_ids[side] = []
    for obj in meshes:
        points = np.array([tuple(obj.matrix_world @ vertex.co) for vertex in obj.data.vertices])
        foot_ids[side].append(np.where((points[:, 2] < 0.12) & (points[:, 0] * sign > 0))[0])
foot_minima = []
for frame in range(1, len(pos) + 1):
    scene.frame_set(frame)
    depsgraph = bpy.context.evaluated_depsgraph_get()
    pair = []
    for side in ["Left", "Right"]:
        values = []
        for obj, indices in zip(meshes, foot_ids[side]):
            evaluated = obj.evaluated_get(depsgraph)
            mesh = evaluated.to_mesh()
            values.extend((evaluated.matrix_world @ mesh.vertices[int(i)].co).z for i in indices)
            evaluated.to_mesh_clear()
        if not values:
            raise ValueError("Learned rig has no usable foot geometry for ground contact correction.")
        pair.append(min(values))
    foot_minima.append(pair)
foot_minima = np.array(foot_minima)
support = np.where(contacts, foot_minima, np.inf).min(axis=1)
support_frames = np.where(np.isfinite(support))[0]
if len(support_frames):
    correction = np.interp(np.arange(len(pos)), support_frames, -support[support_frames])
else:
    correction = np.zeros(len(pos))
correction = np.maximum(correction, -foot_minima.min(axis=1))
base_z = rig.location.z
for frame, offset in enumerate(correction, 1):
    rig.location.z = base_z + float(offset)
    rig.keyframe_insert("location", frame=frame)
rig.animation_data.action.name = "Generated_Motion"
scene.frame_set(1)
bpy.ops.object.select_all(action="DESELECT")
rig.select_set(True)
for obj in meshes:
    obj.select_set(True)
bpy.context.view_layer.objects.active = rig
bpy.ops.export_scene.gltf(
    filepath=str(out / "animation.glb"),
    export_format="GLB",
    use_selection=True,
    export_animations=True,
    export_skins=True,
    export_frame_range=True,
)
bpy.ops.wm.save_as_mainfile(filepath=str(out / "animation.blend"))
(out / "retargeting.json").write_text(
    json.dumps(
        {
            "frames": len(pos),
            "fps": 30,
            "method": "Generated SOMA joint directions on learned Mixamo skin; target proportions retained",
            "limitations": [
                "No synthesized finger or facial motion",
                "Self intersections require audit review",
            ],
            "root_translation": "vertical only; in-place preview",
            "ground_correction": correction.tolist(),
            "body_clearance_adjustments": clearance_changes,
            "joint_weights_smoothed": a.smooth_joints,
            "joint_blend_easing": "smoothstep",
            "arm_blend_band_fraction": a.arm_blend_band if a.smooth_joints else None,
        },
        indent=2,
    )
)