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)