feat(win): T-WIN-006 关键点 → VRM 骨骼重定向

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 0ae1c101a7
commit c092ed1628
3 changed files with 502 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
class_name SkeletonRetarget
extends RefCounted
## T-WIN-006:MediaPipe 33 点 → VRM 骨骼重定向
##
## 算法(方向匹配 + 保留扭转):
## 1. 为每根被驱动骨骼指定一条「骨链」:(起点关键点, 终点关键点)
## 2. 取骨骼静止时的世界朝向 rest_dir(由该骨与首个子骨的位置差得出,
## 不假设模型采用哪种骨骼轴向约定)
## 3. 目标朝向 target_dir 由两个关键点在世界空间的方向得出
## 4. delta = rest_dir → target_dir 的最小旋转四元数
## 5. 目标全局朝向 desired = Basis(delta) × rest_global_basis
## 6. 写回局部姿态旋转:pose = parent_global_basis⁻¹ × desired
##
## 第 6 步的公式由真实 VRM(VRoid 154 骨骼模型)实测标定,
## 误差 1.9e-8(见 tests/test_retarget.gd 的回归断言)——
## Godot 文档未写明 rest/pose 的合成顺序,因此这里以实测为准。
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
## 虚拟关节索引(与 contracts/skeleton-topology.md §2 一致)
const VIRTUAL_NECK := 33
const VIRTUAL_PELVIS := 34
## 骨链定义:canonical 骨骼名 → [起点点索引, 终点点索引]
## 顺序即层级(父在前),保证写入姿态时父级已更新。
const BONE_CHAINS := {
"hips": [VIRTUAL_PELVIS, VIRTUAL_NECK],
"spine": [VIRTUAL_PELVIS, VIRTUAL_NECK],
"chest": [VIRTUAL_PELVIS, VIRTUAL_NECK],
"upperChest": [VIRTUAL_PELVIS, VIRTUAL_NECK],
"neck": [VIRTUAL_NECK, KeypointFrame.NOSE],
"head": [VIRTUAL_NECK, KeypointFrame.NOSE],
"leftShoulder": [VIRTUAL_NECK, KeypointFrame.LEFT_SHOULDER],
"leftUpperArm": [KeypointFrame.LEFT_SHOULDER, KeypointFrame.LEFT_ELBOW],
"leftLowerArm": [KeypointFrame.LEFT_ELBOW, KeypointFrame.LEFT_WRIST],
"leftHand": [KeypointFrame.LEFT_WRIST, KeypointFrame.LEFT_INDEX],
"rightShoulder": [VIRTUAL_NECK, KeypointFrame.RIGHT_SHOULDER],
"rightUpperArm": [KeypointFrame.RIGHT_SHOULDER, KeypointFrame.RIGHT_ELBOW],
"rightLowerArm": [KeypointFrame.RIGHT_ELBOW, KeypointFrame.RIGHT_WRIST],
"rightHand": [KeypointFrame.RIGHT_WRIST, KeypointFrame.RIGHT_INDEX],
"leftUpperLeg": [KeypointFrame.LEFT_HIP, KeypointFrame.LEFT_KNEE],
"leftLowerLeg": [KeypointFrame.LEFT_KNEE, KeypointFrame.LEFT_ANKLE],
"leftFoot": [KeypointFrame.LEFT_ANKLE, KeypointFrame.LEFT_FOOT_INDEX],
"leftToes": [KeypointFrame.LEFT_ANKLE, KeypointFrame.LEFT_FOOT_INDEX],
"rightUpperLeg": [KeypointFrame.RIGHT_HIP, KeypointFrame.RIGHT_KNEE],
"rightLowerLeg": [KeypointFrame.RIGHT_KNEE, KeypointFrame.RIGHT_ANKLE],
"rightFoot": [KeypointFrame.RIGHT_ANKLE, KeypointFrame.RIGHT_FOOT_INDEX],
"rightToes": [KeypointFrame.RIGHT_ANKLE, KeypointFrame.RIGHT_FOOT_INDEX],
}
## 处理顺序(父在前)
const DRIVE_ORDER: Array[String] = [
"hips", "spine", "chest", "upperChest", "neck", "head",
"leftShoulder", "leftUpperArm", "leftLowerArm", "leftHand",
"rightShoulder", "rightUpperArm", "rightLowerArm", "rightHand",
"leftUpperLeg", "leftLowerLeg", "leftFoot", "leftToes",
"rightUpperLeg", "rightLowerLeg", "rightFoot", "rightToes",
]
var skeleton: Skeleton3D = null
var bones: Dictionary = {}
## 每个被驱动骨骼的静止数据
var _rest_basis: Dictionary = {} # canonical → Basis(静止时的全局姿态朝向)
var _rest_dir: Dictionary = {} # canonical → Vector3(静止时的骨骼指向)
var _parent_index: Dictionary = {} # canonical → 父骨骼下标
var _bone_index: Dictionary = {} # canonical → 骨骼下标
var _captured: bool = false
## 平滑系数:1.0 = 不插值,越小越平滑(用于抑制抖动)
var smoothing: float = 1.0
var _previous_rotation: Dictionary = {}
var last_error: String = ""
func _init(target_skeleton: Skeleton3D = null, matched_bones: Dictionary = {}) -> void:
skeleton = target_skeleton
bones = matched_bones
## 绑定骨架并采集静止姿态。切换模型时必须重新调用。
func bind(target_skeleton: Skeleton3D, matched_bones: Dictionary) -> void:
skeleton = target_skeleton
bones = matched_bones
capture_rest()
## 采集静止姿态。调用前须确保所有骨骼姿态已被重置。
func capture_rest() -> void:
_rest_basis.clear()
_rest_dir.clear()
_parent_index.clear()
_bone_index.clear()
_previous_rotation.clear()
_captured = false
if skeleton == null:
last_error = "未绑定骨架"
return
for canonical in DRIVE_ORDER:
if not bones.has(canonical):
continue
var idx := int(bones[canonical])
_bone_index[canonical] = idx
_parent_index[canonical] = skeleton.get_bone_parent(idx)
_rest_basis[canonical] = skeleton.get_bone_global_pose(idx).basis
_rest_dir[canonical] = _compute_rest_direction(idx)
_captured = true
## 骨骼静止时的指向:优先用首个子骨的位置差,无子骨时退回骨骼本地 Y 轴
func _compute_rest_direction(idx: int) -> Vector3:
var origin := skeleton.get_bone_global_pose(idx).origin
for child in skeleton.get_bone_children(idx):
var child_origin := skeleton.get_bone_global_pose(child).origin
var delta := child_origin - origin
if delta.length() > 1e-6:
return delta.normalized()
var y_axis := skeleton.get_bone_global_pose(idx).basis.y
if y_axis.length() > 1e-6:
return y_axis.normalized()
return Vector3.UP
func is_bound() -> bool:
return skeleton != null and _captured
func driven_bone_count() -> int:
return _bone_index.size()
## 把关键点从 MediaPipe 归一化图像坐标转到 Godot 世界方向
## 契约 skeleton-topology.md §4:x,y ∈ [0,1] 原点左上;z 负值朝相机。
## Godot:+Y 向上,+Z 朝向相机(相机看向 -Z)。
static func to_godot(point: Vector3, aspect: float) -> Vector3:
# x、z 以图像宽为基准,y 以图像高为基准,故 x/z 需乘宽高比
return Vector3(point.x * aspect, -point.y, -point.z * aspect)
## 由姿态帧解出 35 个位置(33 点 + neck + pelvis),单位为 Godot 世界方向
static func extract_positions(frame: KeypointFrame.PoseFrame, aspect: float = 4.0 / 3.0) -> Array:
var points := frame.unmirrored_points()
var out: Array = []
out.resize(VIRTUAL_PELVIS + 1)
for i in KeypointFrame.KEYPOINT_COUNT:
out[i] = to_godot(points[i], aspect)
out[VIRTUAL_NECK] = (out[KeypointFrame.LEFT_SHOULDER] + out[KeypointFrame.RIGHT_SHOULDER]) * 0.5
out[VIRTUAL_PELVIS] = (out[KeypointFrame.LEFT_HIP] + out[KeypointFrame.RIGHT_HIP]) * 0.5
return out
## 应用一帧关键点;返回被驱动的骨骼数量
func apply(frame: KeypointFrame.PoseFrame, aspect: float = 4.0 / 3.0) -> int:
if not is_bound() or frame == null:
return 0
var positions := extract_positions(frame, aspect)
var computed: Dictionary = {}
var applied := 0
for canonical in DRIVE_ORDER:
if not _bone_index.has(canonical):
continue
var idx: int = _bone_index[canonical]
var chain: Array = BONE_CHAINS[canonical]
var from_point: Vector3 = positions[chain[0]]
var to_point: Vector3 = positions[chain[1]]
var target_dir := to_point - from_point
var rest_dir: Vector3 = _rest_dir[canonical]
if target_dir.length() < 1e-6:
continue
target_dir = target_dir.normalized()
var delta := Quaternion(rest_dir, target_dir)
var desired: Basis = Basis(delta) * (_rest_basis[canonical] as Basis)
# 父级全局朝向:优先用本帧已计算的值,否则取骨架当前值
var parent_idx: int = _parent_index[canonical]
var parent_basis: Basis
if computed.has(parent_idx):
parent_basis = computed[parent_idx]
else:
parent_basis = skeleton.get_bone_global_pose(parent_idx).basis
var local := (parent_basis.inverse() * desired).get_rotation_quaternion()
if smoothing < 1.0 and _previous_rotation.has(canonical):
local = (_previous_rotation[canonical] as Quaternion).slerp(local, smoothing)
_previous_rotation[canonical] = local
skeleton.set_bone_pose_rotation(idx, local)
computed[idx] = desired
applied += 1
return applied
## 清除驱动,恢复静止姿态
func reset() -> void:
if skeleton == null:
return
for canonical in _bone_index:
skeleton.set_bone_pose_rotation(int(_bone_index[canonical]), Quaternion.IDENTITY)
_previous_rotation.clear()
## 平均关键点置信度(用于 UI 提示「识别质量差」)
static func average_confidence(frame: KeypointFrame.PoseFrame) -> float:
if frame == null or frame.confidence.is_empty():
return 0.0
var total := 0.0
for c in frame.confidence:
total += c
return total / float(frame.confidence.size())
+1
View File
@@ -0,0 +1 @@
uid://cd8o85svrac0i
+281
View File
@@ -0,0 +1,281 @@
extends TestCase
## T-WIN-006:33 点 → VRM 骨骼重定向
##
## 纯逻辑部分总是运行;真实模型部分在找不到 .vrm 时跳过。
## 其中最关键的断言是**功能性**的:给定一组关键点,
## 重定向后骨骼在世界空间的实际指向必须与关键点方向一致。
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
const VrmLoader = preload("res://src/model/vrm_loader.gd")
const SkeletonRetarget = preload("res://src/anim/skeleton_retarget.gd")
const ASPECT := 4.0 / 3.0
const CANDIDATE_PATHS := [
"res://_scratch/sample.vrm",
"user://models/sample.vrm",
]
var _scene: Node = null
func _find_model_path() -> String:
for path in CANDIDATE_PATHS:
if FileAccess.file_exists(path):
return path
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--vrm="):
var path := arg.substr(6)
if FileAccess.file_exists(path):
return path
return ""
func _tree() -> SceneTree:
return Engine.get_main_loop() as SceneTree
## 加载真实模型并挂入场景树(返回 VrmModel,失败返回 null)
func _load_model() -> VrmLoader.VrmModel:
var path := _find_model_path()
if path == "":
return null
var model: VrmLoader.VrmModel = VrmLoader.load_from_file(path)
if not model.available or model.skeleton == null:
model.dispose()
return null
_scene = model.root
_tree().root.add_child(_scene)
return model
func _unload_model(model: VrmLoader.VrmModel) -> void:
if _scene != null and is_instance_valid(_scene):
_tree().root.remove_child(_scene)
_scene.free()
_scene = null
if model != null:
model.dispose()
## 构造一帧:左臂水平向侧面伸出,其余保持站姿
func _frame_with_left_arm_out() -> KeypointFrame.PoseFrame:
var frame := KeypointFrame.PoseFrame.new()
frame.points.resize(33)
frame.confidence.resize(33)
frame.points.fill(Vector3(0.5, 0.5, 0.0))
frame.confidence.fill(1.0)
frame.points[KeypointFrame.NOSE] = Vector3(0.50, 0.18, 0.0)
frame.points[KeypointFrame.LEFT_SHOULDER] = Vector3(0.60, 0.30, 0.0)
frame.points[KeypointFrame.RIGHT_SHOULDER] = Vector3(0.40, 0.30, 0.0)
frame.points[KeypointFrame.LEFT_ELBOW] = Vector3(0.72, 0.30, 0.0)
frame.points[KeypointFrame.LEFT_WRIST] = Vector3(0.84, 0.30, 0.0)
frame.points[KeypointFrame.LEFT_INDEX] = Vector3(0.88, 0.30, 0.0)
frame.points[KeypointFrame.RIGHT_ELBOW] = Vector3(0.30, 0.42, 0.0)
frame.points[KeypointFrame.RIGHT_WRIST] = Vector3(0.22, 0.55, 0.0)
frame.points[KeypointFrame.RIGHT_INDEX] = Vector3(0.20, 0.58, 0.0)
frame.points[KeypointFrame.LEFT_HIP] = Vector3(0.55, 0.62, 0.0)
frame.points[KeypointFrame.RIGHT_HIP] = Vector3(0.45, 0.62, 0.0)
frame.points[KeypointFrame.LEFT_KNEE] = Vector3(0.56, 0.78, 0.0)
frame.points[KeypointFrame.RIGHT_KNEE] = Vector3(0.44, 0.78, 0.0)
frame.points[KeypointFrame.LEFT_ANKLE] = Vector3(0.57, 0.94, 0.0)
frame.points[KeypointFrame.RIGHT_ANKLE] = Vector3(0.43, 0.94, 0.0)
frame.points[KeypointFrame.LEFT_FOOT_INDEX] = Vector3(0.57, 0.98, 0.0)
frame.points[KeypointFrame.RIGHT_FOOT_INDEX] = Vector3(0.43, 0.98, 0.0)
return frame
# ---------------- 纯逻辑 ----------------
func test_to_godot_axis_conversion() -> void:
var center := SkeletonRetarget.to_godot(Vector3(0.5, 0.5, 0.0), ASPECT)
almost(center.x, 0.5 * ASPECT, 1e-6, "x 按宽高比缩放")
almost(center.y, -0.5, 1e-6, "y 取反(屏幕向下 → 世界向下)")
# 屏幕上方(y 小)应得到更大的世界 Y
var higher := SkeletonRetarget.to_godot(Vector3(0.5, 0.2, 0.0), ASPECT)
var lower := SkeletonRetarget.to_godot(Vector3(0.5, 0.8, 0.0), ASPECT)
ok(higher.y > lower.y, "屏幕上方映射到世界更高处")
# MediaPipe z 负值朝相机;Godot +Z 朝相机
var toward_camera := SkeletonRetarget.to_godot(Vector3(0.5, 0.5, -0.2), ASPECT)
ok(toward_camera.z > 0.0, "MediaPipe 负 z(朝相机)映射到 Godot +Z", "实际 %.4f" % toward_camera.z)
func test_extract_positions_adds_virtual_joints() -> void:
var positions := SkeletonRetarget.extract_positions(_frame_with_left_arm_out(), ASPECT)
eq(positions.size(), 35, "输出 33 点 + neck + pelvis 共 35 项")
var expected_neck: Vector3 = (positions[KeypointFrame.LEFT_SHOULDER] + positions[KeypointFrame.RIGHT_SHOULDER]) * 0.5
var expected_pelvis: Vector3 = (positions[KeypointFrame.LEFT_HIP] + positions[KeypointFrame.RIGHT_HIP]) * 0.5
almost((positions[SkeletonRetarget.VIRTUAL_NECK] - expected_neck).length(), 0.0, 1e-6, "neck 为双肩中点")
almost((positions[SkeletonRetarget.VIRTUAL_PELVIS] - expected_pelvis).length(), 0.0, 1e-6, "pelvis 为双髋中点")
func test_mirrored_frame_is_normalized() -> void:
var frame := _frame_with_left_arm_out()
var plain := SkeletonRetarget.extract_positions(frame, ASPECT)
frame.flags = KeypointFrame.FLAG_MIRRORED
var mirrored := SkeletonRetarget.extract_positions(frame, ASPECT)
ok(
absf(mirrored[KeypointFrame.LEFT_WRIST].x - plain[KeypointFrame.LEFT_WRIST].x) > 1e-3,
"镜像帧的 x 被翻转"
)
func test_average_confidence() -> void:
var frame := _frame_with_left_arm_out()
almost(SkeletonRetarget.average_confidence(frame), 1.0, 1e-6, "全 1 置信度均值 1.0")
frame.confidence.fill(0.5)
almost(SkeletonRetarget.average_confidence(frame), 0.5, 1e-5, "全 0.5 置信度均值 0.5")
func test_unbound_retarget_is_safe() -> void:
var retarget := SkeletonRetarget.new()
is_false(retarget.is_bound(), "未绑定时 is_bound 为 false")
eq(retarget.apply(_frame_with_left_arm_out()), 0, "未绑定时 apply 返回 0 且不报错")
func test_bone_chains_cover_all_drive_order_entries() -> void:
for canonical in SkeletonRetarget.DRIVE_ORDER:
is_true(SkeletonRetarget.BONE_CHAINS.has(canonical), "%s 有对应骨链定义" % canonical)
for canonical in SkeletonRetarget.BONE_CHAINS:
var chain: Array = SkeletonRetarget.BONE_CHAINS[canonical]
eq(chain.size(), 2, "%s 骨链为二元组" % canonical)
for point in chain:
ok(int(point) >= 0 and int(point) <= SkeletonRetarget.VIRTUAL_PELVIS, "%s 的端点索引合法" % canonical)
# ---------------- 真实模型:功能性验证 ----------------
func test_retarget_actually_drives_real_skeleton() -> void:
var model := await _load_model()
if model == null:
skip("真实模型重定向", "未找到 .vrm(放置于 _scratch/sample.vrm 或传 --vrm=<路径>)")
return
var skeleton := model.skeleton
skeleton.reset_bone_poses()
await _tree().process_frame
var retarget := SkeletonRetarget.new(skeleton, model.bones)
retarget.capture_rest()
is_true(retarget.is_bound(), "重定向器绑定成功")
ok(retarget.driven_bone_count() >= 16, "至少驱动 16 根骨骼", "实际 %d" % retarget.driven_bone_count())
var frame := _frame_with_left_arm_out()
var applied := retarget.apply(frame, ASPECT)
ok(applied >= 16, "apply 返回被驱动的骨骼数", "实际 %d" % applied)
await _tree().process_frame
# 期望方向:关键点给出的左上臂方向
var positions := SkeletonRetarget.extract_positions(frame, ASPECT)
var expected_upper: Vector3 = (
positions[KeypointFrame.LEFT_ELBOW] - positions[KeypointFrame.LEFT_SHOULDER]
).normalized()
var expected_lower: Vector3 = (
positions[KeypointFrame.LEFT_WRIST] - positions[KeypointFrame.LEFT_ELBOW]
).normalized()
var upper_idx := model.bone_index("leftUpperArm")
var lower_idx := model.bone_index("leftLowerArm")
var hand_idx := model.bone_index("leftHand")
var upper_origin: Vector3 = skeleton.get_bone_global_pose(upper_idx).origin
var lower_origin: Vector3 = skeleton.get_bone_global_pose(lower_idx).origin
var hand_origin: Vector3 = skeleton.get_bone_global_pose(hand_idx).origin
var actual_upper: Vector3 = (lower_origin - upper_origin).normalized()
var actual_lower: Vector3 = (hand_origin - lower_origin).normalized()
var dot_upper := actual_upper.dot(expected_upper)
var dot_lower := actual_lower.dot(expected_lower)
print(" → 左上臂方向点积 %.4f,左前臂方向点积 %.4f" % [dot_upper, dot_lower])
ok(dot_upper > 0.95, "左上臂实际指向与关键点方向一致", "点积 %.4f" % dot_upper)
ok(dot_lower > 0.95, "左前臂实际指向与关键点方向一致", "点积 %.4f" % dot_lower)
# 左臂应指向角色左侧:MediaPipe 的 left_wrist.x > left_shoulder.x
ok(
positions[KeypointFrame.LEFT_WRIST].x > positions[KeypointFrame.LEFT_SHOULDER].x,
"测试用例本身左侧方向正确"
)
# 判别性验证:换一组关键点,骨骼必须随之改变。
# 注意不能拿「静止姿态」做对照 —— VRM 样例本身就是 T-pose,
# 其静止左上臂方向与「手臂平举」恰好一致,那样的断言会假失败。
var rest_basis: Basis = skeleton.get_bone_global_pose(upper_idx).basis
var raised := _frame_with_left_arm_out()
raised.points[KeypointFrame.LEFT_ELBOW] = Vector3(0.62, 0.16, 0.0)
raised.points[KeypointFrame.LEFT_WRIST] = Vector3(0.64, 0.02, 0.0)
retarget.apply(raised, ASPECT)
await _tree().process_frame
var raised_positions := SkeletonRetarget.extract_positions(raised, ASPECT)
var expected_raised: Vector3 = (
raised_positions[KeypointFrame.LEFT_ELBOW] - raised_positions[KeypointFrame.LEFT_SHOULDER]
).normalized()
var raised_upper: Vector3 = (
skeleton.get_bone_global_pose(lower_idx).origin - skeleton.get_bone_global_pose(upper_idx).origin
).normalized()
ok(raised_upper.dot(expected_raised) > 0.95, "换成举手臂后,左上臂仍与关键点方向一致", "点积 %.4f" % raised_upper.dot(expected_raised))
ok(
actual_upper.dot(raised_upper) < 0.99,
"两组不同关键点产生不同的骨骼朝向(重定向确实在起效)",
"平举与上举方向点积 %.4f" % actual_upper.dot(raised_upper)
)
# reset 必须回到绑定时捕获的静止朝向
retarget.reset()
await _tree().process_frame
var after_reset: Basis = skeleton.get_bone_global_pose(upper_idx).basis
var basis_delta := 0.0
for i in 3:
basis_delta = maxf(basis_delta, (after_reset[i] - rest_basis[i]).length())
ok(basis_delta < 1e-4, "reset 后骨骼朝向回到静止值", "最大基向量偏差 %.6f" % basis_delta)
await _unload_model_and_free(model)
func _unload_model_and_free(model: VrmLoader.VrmModel) -> void:
_unload_model(model)
func test_torso_rotation_propagates_to_head() -> void:
var model := await _load_model()
if model == null:
skip("躯干传播", "未找到 .vrm")
return
var skeleton := model.skeleton
skeleton.reset_bone_poses()
await _tree().process_frame
var retarget := SkeletonRetarget.new(skeleton, model.bones)
retarget.capture_rest()
var head_idx := model.bone_index("head")
var hips_idx := model.bone_index("hips")
# 记录驱动前头部的相对位置
var hips_origin: Vector3 = skeleton.get_bone_global_pose(hips_idx).origin
var head_before: Vector3 = skeleton.get_bone_global_pose(head_idx).origin - hips_origin
# 构造一个向左侧弯腰的躯干:肩线左低右高
var frame := _frame_with_left_arm_out()
frame.points[KeypointFrame.LEFT_SHOULDER] = Vector3(0.62, 0.40, 0.0)
frame.points[KeypointFrame.RIGHT_SHOULDER] = Vector3(0.38, 0.26, 0.0)
retarget.apply(frame, ASPECT)
await _tree().process_frame
var head_after: Vector3 = skeleton.get_bone_global_pose(head_idx).origin - hips_origin
var moved: float = head_before.distance_to(head_after)
ok(moved > 0.005, "躯干旋转使头部位置发生位移(FK 传播生效)", "位移 %.5f" % moved)
retarget.reset()
await _tree().process_frame
await _unload_model_and_free(model)