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