文章目录
- 1、使用人体关键点数据驱动FBX格式虚拟人的总流程
- 2、使用mediapipe检测人体关键点和插值平滑
- 2.1 mediapipe检测人体关键点
- 2.2 人体关键点的插值平滑
- 3、将2d关键点转为3d关键点
- 4、旋转矩阵
- 4.1 旋转矩阵
- 4.2 旋转矩阵转为四元数
- 5、将旋转矩阵用于虚拟人的驱动
- 5.1 基础旋转
- 5.2 头部和颈部的旋转
1、使用人体关键点数据驱动FBX格式虚拟人的总流程
2、使用mediapipe检测人体关键点和插值平滑
2.1 mediapipe检测人体关键点
使用mediapipe人体关键点模型,可以检测人体姿势的33个关键点,33个关键点分别是,
0 - nose
1 - left eye (inner)
2 - left eye
3 - left eye (outer)
4 - right eye (inner)
5 - right eye
6 - right eye (outer)
7 - left ear
8 - right ear
9 - mouth (left)
10 - mouth (right)
11 - left shoulder
12 - right shoulder
13 - left elbow
14 - right elbow
15 - left wrist
16 - right wrist
17 - left pinky
18 - right pinky
19 - left index
20 - right index
21 - left thumb
22 - right thumb
23 - left hip
24 - right hip
25 - left knee
26 - right knee
27 - left ankle
28 - right ankle
29 - left heel
30 - right heel
31 - left foot index
32 - right foot index
对应的示意图如下,
使用mediapipe的HTML接口后,在浏览器控制台显示的33个关键点具体如下图,每一个关键点中的 x 和 y 表示关键点的 2 维坐标,z 表示地标深度,臀部中点的深度作为来源。值越小,地标就越靠近镜头。通过 z 的量级使用的比例与 x 大致相同。具体可参考:https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker/web_js?hl=zh-cn
2.2 人体关键点的插值平滑
插值平滑的步骤:
- 将当前帧的关键点和前面4帧的关键点数据添加到空列表中,其中4帧可以是超参数,
- 遍历5帧中的每一个关键点,后面帧的关键点权重较高,前面帧的关键点权重较低,将赋予权重的每一帧的关键点相加再除以总权重,即为插值平滑后的权重,
参考代码如下:
function updateLandmarks(currentLandmarks) {// console.log("Current Landmarks:", currentLandmarks);if (previousLandmarksWindow.length === 0) {// 初始化默认关键点数据for (let j = 0; j < windowSize; j++) {previousLandmarksWindow.push(currentLandmarks.map(initializeLandmark));}}// 将当前帧添加到滑动窗口previousLandmarksWindow.push(currentLandmarks.map(initializeLandmark));if (previousLandmarksWindow.length > windowSize) {previousLandmarksWindow.shift(); // 移除最早的一帧}//console.log("Updated previousLandmarksWindow:", previousLandmarksWindow);// 插值平滑当前帧和滑动窗口中的关键点数据for (let i = 0; i < currentLandmarks.length; i++) {if (!currentLandmarks[i] && isNaN(currentLandmarks[i].x) && isNaN(currentLandmarks[i].y) && isNaN(currentLandmarks[i].z) && currentLandmarks[i].score < visibilityThreshold) {// 如果当前帧的关键点不可用或可见性低,使用前一帧的关键点数据if (previousLandmarksWindow[windowSize - 2][i]) {//console.log(`Using previous landmark for index ${i}`);currentLandmarks[i] = {...previousLandmarksWindow[windowSize - 2][i]};} else {currentLandmarks[i] = {...previousLandmarksWindow[0][i]};}} else {// 对滑动窗口中的关键点数据进行加权平均let smoothedLandmark = { x: 0, y: 0, z: 0 };let totalWeight = 0;for (let j = 0; j < previousLandmarksWindow.length; j++) {const weight = (j + 1); // 可以根据需要调整权重策略smoothedLandmark.x += previousLandmarksWindow[j][i].x * weight;smoothedLandmark.y += previousLandmarksWindow[j][i].y * weight;smoothedLandmark.z += previousLandmarksWindow[j][i].z * weight;totalWeight += weight;}currentLandmarks[i].x = smoothedLandmark.x / totalWeight;currentLandmarks[i].y = smoothedLandmark.y / totalWeight;currentLandmarks[i].z = smoothedLandmark.z / totalWeight;}}
}
3、将2d关键点转为3d关键点
具体步骤如下:
- 需要先将2d关键点中的 x 和 y 坐标值转换为世界坐标值,
- 计算视口的宽度 x_scale,
- 根据 x_scale、相机的近剪裁面和相机的位置得到 z 的坐标值,
- 将上面计算得到的 x、y、z 的值输入到ProjScale函数,得到最终的 x、y、z 的值,
参考代码如下:
// 用于将2D姿势关键点转换为3D空间坐标
function update3dpose(camera, dist_from_cam, offset, poseLandmarks) {// if the camera is orthogonal, set scale to 1// ip_lt 和 ip_rb 分别表示视口左上角的归一化设备坐标(NDC)和以及NDC右下角的坐标,然后通过 unproject(camera) 将其转换为世界坐标const ip_lt = new THREE.Vector3(-1, 1, -1).unproject(camera);const ip_rb = new THREE.Vector3(1, -1, -1).unproject(camera);const ip_diff = new THREE.Vector3().subVectors(ip_rb, ip_lt);const x_scale = Math.abs(ip_diff.x); // x_scale 是 ip_diff 的 x 方向的绝对值,表示视口的宽度function ProjScale(p_ms, cam_pos, src_d, dst_d) {let vec_cam2p = new THREE.Vector3().subVectors(p_ms, cam_pos);// 返回从相机位置开始,按比例缩放后的点return new THREE.Vector3().addVectors(cam_pos,vec_cam2p.multiplyScalar(dst_d / src_d));}let pose3dDict = {}; // 用于存储3D姿势数据for (const [key, value] of Object.entries(poseLandmarks)) {// value.x 和 value.y 被缩放到 [-1, 1] 范围, 使用 unproject 方法将相机坐标转换为世界坐标let p_3d = new THREE.Vector3((value.x - 0.5) * 2.0,-(value.y - 0.5) * 2.0,0).unproject(camera); p_3d.z = -value.z * x_scale - camera.near + camera.position.z;// ProjScale 函数将 p_3d 缩放到目标距离 dist_from_camp_3d = ProjScale(p_3d, camera.position, camera.near, dist_from_cam);pose3dDict[key] = p_3d.add(offset);}return pose3dDict;
}
4、旋转矩阵
4.1 旋转矩阵
本项目中使用关键点驱动虚拟人模型的格式是FBX,首先获取FBX模型的骨骼,比如mixamorigLeftUpLeg,获取FBX模型的骨骼代码如下,翻译为中文就是左上大腿,
const boneLeftUpLeg = model.getObjectByName("mixamorigLeftUpLeg");
然后计算人体关键点中的人体髋部中心点到左髋的方向,代码是:
const v_HiptoLeft = new THREE.Vector3().subVectors(jointLeftUpLeg, jointHips).normalize();
通过boneLeftUpLeg 和 v_HiptoLeft 计算旋转矩阵,步骤如下:
1、根据boneLeftUpLeg 和 v_HiptoLeft 构建3维坐标系u,v,w,代码如下,
const u = uA.clone();const v = new THREE.Vector3().subVectors(uB, uA.clone().multiplyScalar(clampedIdot)).normalize();const w = cross_AB.clone().normalize();
2、 创建一个从新基坐标系(u, v, w)到世界坐标系的变换矩阵 C,代码如下,
const C = new THREE.Matrix4().makeBasis(u, v, w).transpose();
3、构建一个旋转矩阵 R_uvw,该矩阵表示在新基坐标系下的旋转,即2个向量之间绕 w 轴(新基坐标系中的第三个基向量)的旋转,代码如下,
const R_uvw = new THREE.Matrix4().set(clampedIdot,-cdot,0,0,cdot,clampedIdot,0,0,0,0,1,0,0,0,0,1
);
4、组合了从世界坐标系到新基坐标系、应用旋转、再回到世界坐标系的变换,构建一个完整的旋转矩阵 R,它将向量 A 旋转到向量 B 在世界坐标系中的位置,代码如下,
const R = new THREE.Matrix4().multiplyMatrices(C.clone().transpose(),new THREE.Matrix4().multiplyMatrices(R_uvw, C));
5、整体参考代码如下:
function computeR(A, B) {// get unit vectorsconst uA = A.clone().normalize();const uB = B.clone().normalize();// get productsconst idot = uA.dot(uB); // 两个单位向量的点积,表示它们之间的夹角的余弦值const cross_AB = new THREE.Vector3().crossVectors(uA, uB); // 两个单位向量的叉积,表示垂直于这两个向量的向量 const cdot = cross_AB.length(); // cross_AB 的长度,表示两个向量之间的正弦值// 处理数值稳定性问题,确保 idot 在 [-1, 1] 范围内const clampedIdot = Math.min(Math.max(idot, -1), 1);// get new unit vectorsconst u = uA.clone();const v = new THREE.Vector3().subVectors(uB, uA.clone().multiplyScalar(clampedIdot)).normalize();const w = cross_AB.clone().normalize();// get change of basis matrixconst C = new THREE.Matrix4().makeBasis(u, v, w).transpose();// get rotation matrix in new basisconst R_uvw = new THREE.Matrix4().set(clampedIdot,-cdot,0,0,cdot,clampedIdot,0,0,0,0,1,0,0,0,0,1);// full rotation matrixconst R = new THREE.Matrix4().multiplyMatrices(C.clone().transpose(),new THREE.Matrix4().multiplyMatrices(R_uvw, C));return R;
}
下面是ChatGPT总结的步骤:
1、标准化向量: 将向量 A 和 B 标准化为单位向量 uA 和 uB。
2、计算点积和叉积: 获取向量 uA 和 uB 之间的夹角信息(余弦值和正弦值)。
3、处理数值稳定性: 限制点积值在 [-1, 1] 范围内,确保计算的准确性。
4、构建新的正交基:
- u: 与 uA 相同的单位向量。
- v: 与 uA 垂直的单位向量,位于 uA 和 uB 之间。
- w: 垂直于 uA 和 uB 的单位向量。
5、构建基变换矩阵: 创建一个从新基坐标系到世界坐标系的变换矩阵 C。
6、构建新基坐标系下的旋转矩阵: R_uvw 表示在新基坐标系下绕 w 轴旋转 θ 角度的旋转矩阵。
7、组合旋转矩阵回到世界坐标系: 通过基变换矩阵 C 和旋转矩阵 R_uvw 组合,得到最终的旋转矩阵 R。
8、返回旋转矩阵: 将计算得到的旋转矩阵 R 返回。
4.2 旋转矩阵转为四元数
const Q_HiptoLeft = new THREE.Quaternion().setFromRotationMatrix(R_HiptoLeft);
5、将旋转矩阵用于虚拟人的驱动
5.1 基础旋转
在利用人体关键点驱动虚拟人模型时,需要确定基础旋转,虚拟人的其他骨骼都会根据这个基础旋转进行驱动。基础旋转的步骤如下:
1、根据检测出的左右髋部确定左右髋部的中心点 center_hips,根据左右肩膀确定左右肩膀的中心点center_shoulders,center_hips 和 center_shoulders 之间的连线就是脊柱的长度 length_spine ,相关代码如下,
const dir_spine = new THREE.Vector3().subVectors(center_shoulders,center_hips);
const length_spine = dir_spine.length();
2、左右髋部的中心点 center_hips 加上脊柱的长度 length_spine 的1/9 得到 hips,左右髋部的中心点 center_hips 加上脊柱的长度 length_spine 的3/9 得到 spine0,左右髋部的中心点 center_hips 加上脊柱的长度 length_spine 的5/9 得到 spine1,左右髋部的中心点 center_hips 加上脊柱的长度 length_spine 的7/9 得到 spine3,相关代码如下,
newJoints3D["hips"] = new THREE.Vector3().addVectors(center_hips,dir_spine.clone().multiplyScalar(length_spine / 9.0)
);
newJoints3D["spine0"] = new THREE.Vector3().addVectors(center_hips,dir_spine.clone().multiplyScalar((length_spine / 9.0) * 3)
);
newJoints3D["spine1"] = new THREE.Vector3().addVectors(center_hips,dir_spine.clone().multiplyScalar((length_spine / 9.0) * 5)
);
newJoints3D["spine2"] = new THREE.Vector3().addVectors(center_hips,dir_spine.clone().multiplyScalar((length_spine / 9.0) * 7)
);
3、计算 hips 到 左髋部 left_hip 的方向向量 v_HiptoLeft,计算 hips 到 左髋部 right_hip 的方向向量 v_HiptoRight,计算 hips 到 spine0 的方向向量 v_HiptoSpine0,通过 model.getObjectByName 获取虚拟人模型骨骼的对象,然后分别计算虚拟人模型中的 boneLeftUpLeg 和 v_HiptoLeft 的之间的旋转矩阵、虚拟人模型中的 boneRightUpLeg 和 v_HiptoRight 的之间的旋转矩阵、虚拟人模型中的 boneSpine0 和 v_HiptoSpine0 的之间的旋转矩阵,并将旋转转换为四元数,相关代码如下,
const jointHips = newJoints3D["hips"];
const jointLeftUpLeg = pos_3d_landmarks["left_hip"];
const jointRightUpLeg = pos_3d_landmarks["right_hip"];
const jointSpine0 = newJoints3D["spine0"];// 获取3D模型中相应名称的骨骼
const boneHips = model.getObjectByName("mixamorigHips");
// console.log("boneLeftUpLeg---->", boneLeftUpLeg)
const boneRightUpLeg = model.getObjectByName("mixamorigRightUpLeg");
const boneLeftUpLeg = model.getObjectByName("mixamorigLeftUpLeg");
const boneSpine0 = model.getObjectByName("mixamorigSpine");const v_HiptoLeft = new THREE.Vector3().subVectors(jointLeftUpLeg, jointHips).normalize();
const v_HiptoRight = new THREE.Vector3().subVectors(jointRightUpLeg, jointHips).normalize();
const v_HiptoSpine0 = new THREE.Vector3().subVectors(jointSpine0, jointHips).normalize();
// 计算 boneLeftUpLeg.position 和 v_HiptoLeft 之间的旋转矩阵
const R_HiptoLeft = computeR(boneLeftUpLeg.position.clone().normalize(),v_HiptoLeft);// 将旋转矩阵转换为四元数 Q_HiptoLeftconst Q_HiptoLeft = new THREE.Quaternion().setFromRotationMatrix(R_HiptoLeft);const R_HiptoRight = computeR(boneRightUpLeg.position.clone().normalize(),v_HiptoRight);const Q_HiptoRight = new THREE.Quaternion().setFromRotationMatrix(R_HiptoRight);const R_HiptoSpine0 = computeR(boneSpine0.position.clone().normalize(),v_HiptoSpine0);const Q_HiptoSpine0 = new THREE.Quaternion().setFromRotationMatrix(R_HiptoSpine0);
4、计算 Q_HiptoLeft 和 Q_HiptoRight 的球面线性插值,代码是 Q_HiptoLeft.clone().slerp(Q_HiptoRight, 0.5),将 Q_HiptoSpine0 与 Q_HiptoLeft 和 Q_HiptoRight 的球面线性插值后的结果又进行计算球面线性插值,记为变量 Q_Hips,将计算好的四元数 Q_Hips 应用到虚拟人模型的髋部骨骼 boneHips,从 boneHips 的变换矩阵中提取旋转矩阵 R_Hips 。相关代码如下,
// Q_HiptoLeft.clone().slerp(Q_HiptoRight, 0.5): 计算左髋关节和右髋关节旋转的中间插值。
// Q_Hips.slerp(..., 1 / 3): 将脊柱旋转与左右髋关节的插值进一步混合,最终得到髋关节的旋转 Q_Hips。
const Q_Hips = new THREE.Quaternion().copy(Q_HiptoSpine0).slerp(Q_HiptoLeft.clone().slerp(Q_HiptoRight, 0.5), 4 / 3); // 0.5, 1/3boneHips.quaternion.copy(Q_Hips); // 将计算好的四元数应用到3D模型的髋部骨骼const R_Hips = new THREE.Matrix4().extractRotation(boneHips.matrix); // 从骨骼的变换矩阵中提取旋转矩阵
5、后面旋转的比如颈部和头部之间的旋转都会根据 R_Hips 为基础。
5.2 头部和颈部的旋转
具体步骤如下,
1、获取关键点中的头部 jointNeck 和颈部 jointHead ,虚拟人模型的头部 boneNeck 和颈部boneHead,代码如下,
const jointNeck = newJoints3D["neck"];
const jointHead = newJoints3D["head"];const boneNeck = model.getObjectByName("mixamorigNeck");
const boneHead = model.getObjectByName("mixamorigHead");
2、获取关键点的主关节到子关节的向量 v。获取子关节在其局部模型中的位置向量,并进行归一化,这通常表示子关节相对于主关节的标准方向。创建 R_chain 的克隆并转置(逆矩阵),用于将向量 v 从世界坐标系转换到当前关节链的局部坐标。将归一化的向量 v 应用上述逆矩阵,得到在当前局部坐标系中的方向。将模型中的子关节与 v 之间计算旋转矩阵。
排除特定关节(例如头部关节),可能是因为头部需要特殊处理或不需要旋转更新。
new THREE.Quaternion().setFromRotationMatrix®:将旋转矩阵 R 转换为四元数 targetQuat,这是因为四元数在3D旋转中更适合进行插值和平滑操作。
const currentQuat = joint_model.quaternion.clone(): 克隆当前关节的四元数 currentQuat,用于后续的旋转约束处理。
joint_model.quaternion.slerp(targetQuat, 0.5):slerp(球面线性插值)方法用于在当前四元数和目标四元数之间进行平滑插值。0.5: 插值因子,表示旋转到目标四元数的一半。这使得旋转过程更为平滑,避免突兀的旋转。
applyRotationConstraints(joint_model, currentQuat, targetQuat):应用旋转约束,限制关节旋转的范围或方向,防止出现不自然的旋转。这是一个自定义函数,具体实现未在代码片段中展示。
将当前的旋转矩阵 R 乘到 R_chain 上,更新 R_chain 以包含新的旋转
整体代码如下,
function SetRbyCalculatingJoints(joint_mp,joint_mp_child,joint_model,joint_model_child,R_chain
) {// 计算子关节和主关节之间的向量, 将向量归一化,即将其长度调整为1const v = new THREE.Vector3().subVectors(joint_mp_child, joint_mp).normalize();// 计算两个向量之间的旋转矩阵 Rconst R = computeR(joint_model_child.position.clone().normalize(), // 获取子关节在模型中的位置并归一化v.applyMatrix4(R_chain.clone().transpose()) // 将之前计算的向量 v 应用到当前的旋转矩阵 R_chain);// 阻尼或插值平滑更新旋转(可选)if (joint_model.name != "mixamorigHead") {const targetQuat = new THREE.Quaternion().setFromRotationMatrix(R);// 获取当前关节的旋转四元数const currentQuat = joint_model.quaternion.clone();// 插值更新旋转,使用阻尼因子0.5(平滑效果)joint_model.quaternion.slerp(targetQuat, 0.5);// 应用旋转角度限制applyRotationConstraints(joint_model, currentQuat, targetQuat);}R_chain.multiply(R); // 将当前的旋转矩阵 R 乘到 R_chain 上,更新 R_chain 以包含新的旋转
}let R_chain_neck = new THREE.Matrix4().identity();R_chain_neck.multiply(R_Hips);const jointNeck = newJoints3D["neck"];
const jointHead = newJoints3D["head"];const boneNeck = model.getObjectByName("mixamorigNeck");
const boneHead = model.getObjectByName("mixamorigHead");
SetRbyCalculatingJoints(jointNeck,jointHead,boneNeck,boneHead,R_chain_neck
);