自学内容网 自学内容网

使用人体关键点驱动FBX格式虚拟人原理【详解】

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_cam
        p_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 vectors
    const uA = A.clone().normalize();
    const uB = B.clone().normalize();

    // get products
    const 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 vectors
    const 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 matrix
    const C = new THREE.Matrix4().makeBasis(u, v, w).transpose();

    // get rotation matrix in new basis
    const R_uvw = new THREE.Matrix4().set(
        clampedIdot,
        -cdot,
        0,
        0,
        cdot,
        clampedIdot,
        0,
        0,
        0,
        0,
        1,
        0,
        0,
        0,
        0,
        1
    );

    // full rotation matrix
    const 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_HiptoLeft
 const 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/3

boneHips.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
) {
    // 计算子关节和主关节之间的向量, 将向量归一化,即将其长度调整为1
    const v = new THREE.Vector3()
        .subVectors(joint_mp_child, joint_mp)
        .normalize();

    // 计算两个向量之间的旋转矩阵 R
    const 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
);

原文地址:https://blog.csdn.net/qq_23022733/article/details/142632063

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!