ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python+MediaPipe+Unity:手部面部识别驱动虚拟角色全链路

Python+MediaPipe+Unity:手部面部识别驱动虚拟角色全链路 简介这是一套面向计算机相关专业学生与项目实战学习者的PythonMediaPipe手部、面部识别源码通过识别结果驱动Unity端虚拟人物动作可用于毕业设计、课程设计或大作业场景。资源包共157个文件约85.91MB包含20个py脚本与22个pyc编译文件承载识别与逻辑处理12个cs脚本负责Unity端控制另有66个pickle模型数据、22个mp4演示素材、2个unitypackage及xml、pdf等配置与说明文档目录结构完整清晰。已有72人学习下载。项目经导师指导并获评审99分代码完整可运行读者可据此掌握MediaPipe关键点检测、Python与Unity数据通信、虚拟角色驱动等核心环节并参考现成的控制器与数据管理脚本快速搭建自己的交互演示系统。1. 从摄像头到虚拟角色MediaPipe 手部面部识别驱动 Unity 的完整链路你对着摄像头抬一下手屏幕里的虚拟人物同步抬手你张嘴它的口型跟着动。这套效果听起来像是动捕棚里的活但用 Python MediaPipe Unity 在普通笔记本上就能跑通。核心思路很直接Python 端用 MediaPipe 从摄像头画面里提取手部和面部的关键点坐标通过本地网络把坐标数据实时发给 UnityUnity 端拿到数据后驱动虚拟人物的骨骼或 BlendShape。整条链路里MediaPipe 负责看懂画面Unity 负责演出来中间的数据传输层负责把两者接上。适合想快速验证虚拟人驱动方案的开发者、做数字孪生原型的团队以及想把手势交互接进 Unity 项目的工程师。下面从环境搭建一路讲到参数调优和踩坑记录。2. 环境搭建与 MediaPipe 关键点提取Python 端怎么跑通2.1 Python 环境与 MediaPipe 安装的版本坑MediaPipe 对 Python 版本和依赖库版本比较敏感装错了就是一堆 ImportError。我一般用 Python 3.9 到 3.11 之间的版本太新或太旧都容易翻车。用 conda 建一个干净环境是最省事的做法conda create -n mediapipe_env python3.10 conda activate mediapipe_env pip install mediapipe opencv-python numpy装完之后验证一下import mediapipe as mp print(mp.__version__)如果这一步报错大概率是 protobuf 版本冲突。MediaPipe 依赖特定版本的 protobuf和 TensorFlow 等其他库共存时经常打架。解决办法是先卸载再指定版本安装pip uninstall protobuf -y pip install protobuf3.20.3注意如果你之前装过 opencv-contrib-python建议先卸掉只保留 opencv-python否则可能出现窗口显示异常。2.2 手部关键点提取21 个坐标怎么拿MediaPipe Hands 模块会从画面里检测手部并输出 21 个关键点每个点有 x、y、z 三个归一化坐标。x 和 y 是相对于画面宽高的比例值0 到 1z 是相对于手腕的深度单位大致和 x 方向的比例一致。下面是最小可运行代码import cv2 import mediapipe as mp mp_hands mp.solutions.hands mp_draw mp.solutions.drawing_utils hands mp_hands.Hands( static_image_modeFalse, # 视频流模式 max_num_hands2, # 最多检测两只手 min_detection_confidence0.5, # 检测置信度阈值 min_tracking_confidence0.5 # 跟踪置信度阈值 ) cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while cap.isOpened(): ret, frame cap.read() if not ret: break frame cv2.flip(frame, 1) # 镜像翻转符合直觉 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) if results.multi_hand_landmarks: for hand_landmarks in results.multi_hand_landmarks: mp_draw.draw_landmarks( frame, hand_landmarks, mp_hands.HAND_CONNECTIONS ) # 提取 21 个关键点坐标 for idx, lm in enumerate(hand_landmarks.landmark): h, w, _ frame.shape cx, cy int(lm.x * w), int(lm.y * h) print(fHand landmark {idx}: ({cx}, {cy}, {lm.z:.4f})) cv2.imshow(Hand Tracking, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码里几个参数值得说清楚。static_image_modeFalse表示按视频流处理MediaPipe 会在帧之间做跟踪而不是每帧重新检测速度更快但偶尔会跟丢。max_num_hands2控制检测手数设成 1 可以省一点算力。min_detection_confidence和min_tracking_confidence是两个关键阈值前者影响初始检测的灵敏度后者影响跟踪的稳定性。光照不好或者手部快速移动时把 tracking 阈值降到 0.3 能减少丢帧但误检会增多。2.3 面部关键点提取468 个点的 FaceMesh 怎么用面部用的是 MediaPipe FaceMesh输出 468 个关键点覆盖脸部轮廓、眉毛、眼睛、鼻子、嘴唇等区域。这些点可以用来驱动虚拟人物的 BlendShape表情混合形状比如嘴唇开合、眉毛上扬、眨眼等。import cv2 import mediapipe as mp mp_face_mesh mp.solutions.face_mesh mp_draw mp.solutions.drawing_utils face_mesh mp_face_mesh.FaceMesh( max_num_faces1, refine_landmarksTrue, # 包含虹膜关键点 min_detection_confidence0.5, min_tracking_confidence0.5 ) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break frame cv2.flip(frame, 1) rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results face_mesh.process(rgb) if results.multi_face_landmarks: for face_landmarks in results.multi_face_landmarks: # 提取嘴唇上下关键点示例上唇 13下唇 14 upper_lip face_landmarks.landmark[13] lower_lip face_landmarks.landmark[14] mouth_open abs(upper_lip.y - lower_lip.y) print(fMouth open ratio: {mouth_open:.4f}) # 左眼上下关键点示例上眼睑 159下眼睑 145 left_eye_top face_landmarks.landmark[159] left_eye_bottom face_landmarks.landmark[145] eye_open abs(left_eye_top.y - left_eye_bottom.y) print(fLeft eye open ratio: {eye_open:.4f}) cv2.imshow(Face Mesh, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()refine_landmarksTrue会额外输出虹膜关键点共 478 个点做眼球追踪时需要打开但会稍微增加计算量。嘴唇开合和眼睛开合的计算方式是用上下关键点的 y 坐标差值这个差值可以直接映射到 BlendShape 的权重值。实际用的时候需要做归一化处理因为不同人脸型不同绝对值会有差异。2.4 把两组数据打包成 JSON 并通过 UDP 发送Python 端拿到关键点之后需要把数据发给 Unity。最轻量的方案是 UDP socket延迟低不需要建立连接。数据格式用 JSON方便调试也方便 Unity 端解析。import socket import json import cv2 import mediapipe as mp UDP_IP 127.0.0.1 UDP_PORT 5052 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) mp_hands mp.solutions.hands mp_face_mesh mp.solutions.face_mesh hands mp_hands.Hands(max_num_hands2, min_detection_confidence0.5) face_mesh mp_face_mesh.FaceMesh(max_num_faces1, refine_landmarksTrue) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break frame cv2.flip(frame, 1) rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) hand_results hands.process(rgb) face_results face_mesh.process(rgb) data {hands: [], face: []} if hand_results.multi_hand_landmarks: for hand in hand_results.multi_hand_landmarks: landmarks [[lm.x, lm.y, lm.z] for lm in hand.landmark] data[hands].append(landmarks) if face_results.multi_face_landmarks: for face in face_results.multi_face_landmarks: landmarks [[lm.x, lm.y, lm.z] for lm in face.landmark] data[face].append(landmarks) payload json.dumps(data).encode(utf-8) if len(payload) 60000: # UDP 单包安全上限 sock.sendto(payload, (UDP_IP, UDP_PORT)) cv2.imshow(Tracking, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows() sock.close()这里有个容易忽略的问题468 个面部关键点加上 21×2 个手部关键点JSON 序列化之后数据量不小。UDP 单包理论上限是 65507 字节但实际网络中超过 60000 字节就容易分片丢包。如果发现 Unity 端收到的数据不完整可以把面部关键点做抽稀只发嘴唇、眉毛、眼睛周围的关键点或者改用 TCP。3. Unity 端接收数据并驱动虚拟人物从 UDP 解析到骨骼映射3.1 Unity 项目准备与 UDP 接收脚本Unity 端需要做三件事接收 UDP 数据、解析 JSON、把坐标映射到虚拟人物的骨骼或 BlendShape。先建一个 3D 项目URP 或 Built-in 都可以然后在场景里放一个带 Humanoid 骨骼的虚拟人物模型。如果手头没有模型用 Unity 自带的 Character 或者从 Mixamo 下载一个带骨骼绑定的 FBX 也行。接收脚本的核心逻辑是在Update里非阻塞地读取 UDP 数据using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using UnityEngine; public class UDPReceiver : MonoBehaviour { private UdpClient udpClient; private Thread receiveThread; private string latestData ; private readonly object dataLock new object(); public int port 5052; void Start() { udpClient new UdpClient(port); receiveThread new Thread(new ThreadStart(ReceiveData)); receiveThread.IsBackground true; receiveThread.Start(); } private void ReceiveData() { while (true) { try { IPEndPoint remoteEndPoint new IPEndPoint(IPAddress.Any, port); byte[] data udpClient.Receive(ref remoteEndPoint); string json Encoding.UTF8.GetString(data); lock (dataLock) { latestData json; } } catch (Exception e) { Debug.LogWarning(UDP receive error: e.Message); } } } public string GetLatestData() { lock (dataLock) { return latestData; } } void OnApplicationQuit() { if (receiveThread ! null receiveThread.IsAlive) receiveThread.Abort(); if (udpClient ! null) udpClient.Close(); } }这段代码用了一个后台线程来接收数据避免阻塞 Unity 主线程。lock保证主线程读取数据时不会和接收线程冲突。OnApplicationQuit里做清理否则 Unity 编辑器里反复运行会报端口占用的错误。3.2 JSON 解析与手部骨骼旋转映射Unity 自带的JsonUtility对嵌套数组支持不好建议用 Newtonsoft.Json通过 Package Manager 安装com.unity.nuget.newtonsoft-json。解析之后把手部关键点映射到虚拟人物的手部骨骼旋转上。映射的基本思路是取手腕关键点 0、食指根部关键点 5、小指根部关键点 17三个点构建一个局部坐标系然后计算每根手指的弯曲角度。下面是一个简化版的手部驱动脚本using Newtonsoft.Json; using System.Collections.Generic; using UnityEngine; public class HandDriver : MonoBehaviour { public UDPReceiver receiver; public Transform wristBone; public Transform[] fingerBones; // 按顺序排列的手指骨骼 [System.Serializable] public class HandData { public ListListfloat hands; public ListListListfloat face; } void Update() { string json receiver.GetLatestData(); if (string.IsNullOrEmpty(json)) return; HandData data JsonConvert.DeserializeObjectHandData(json); if (data.hands null || data.hands.Count 0) return; var hand data.hands[0]; // 取第一只手 if (hand.Count 21) return; // 手腕位置映射归一化坐标转世界坐标 Vector3 wristPos new Vector3( (hand[0][0] - 0.5f) * 2f, (0.5f - hand[0][1]) * 2f, hand[0][2] * 2f ); wristBone.localPosition Vector3.Lerp( wristBone.localPosition, wristPos, 0.5f ); // 手指弯曲角度计算以食指为例 Vector3 indexBase new Vector3(hand[5][0], hand[5][1], hand[5][2]); Vector3 indexTip new Vector3(hand[8][0], hand[8][1], hand[8][2]); float bendAngle Vector3.Angle( indexBase - new Vector3(hand[0][0], hand[0][1], hand[0][2]), indexTip - indexBase ); // 把 bendAngle 映射到骨骼旋转 fingerBones[0].localRotation Quaternion.Euler(bendAngle, 0, 0); } }这里的Vector3.Lerp是做平滑处理直接赋值会因为关键点抖动导致虚拟人物手部抽搐。0.5f是插值系数越小越平滑但延迟越大一般取 0.3 到 0.7 之间。手指弯曲角度用Vector3.Angle计算向量夹角然后映射到骨骼的 X 轴旋转上。实际项目中每根手指有 3 个关节需要分别计算近端指节、中端指节和远端指节的角度。3.3 面部 BlendShape 驱动嘴唇开合与眨眼面部驱动用 BlendShape 比骨骼更自然。Unity 的 SkinnedMeshRenderer 组件可以通过SetBlendShapeWeight方法设置表情权重。下面是根据嘴唇和眼睛开合比例驱动 BlendShape 的代码using Newtonsoft.Json; using System.Collections.Generic; using UnityEngine; public class FaceDriver : MonoBehaviour { public UDPReceiver receiver; public SkinnedMeshRenderer faceMeshRenderer; private int mouthOpenIndex; private int eyeBlinkLeftIndex; private int eyeBlinkRightIndex; void Start() { // 根据模型实际的 BlendShape 名称查找索引 mouthOpenIndex faceMeshRenderer.sharedMesh.GetBlendShapeIndex(mouthOpen); eyeBlinkLeftIndex faceMeshRenderer.sharedMesh.GetBlendShapeIndex(eyeBlinkLeft); eyeBlinkRightIndex faceMeshRenderer.sharedMesh.GetBlendShapeIndex(eyeBlinkRight); } void Update() { string json receiver.GetLatestData(); if (string.IsNullOrEmpty(json)) return; var data JsonConvert.DeserializeObjectHandDriver.HandData(json); if (data.face null || data.face.Count 0) return; var face data.face[0]; if (face.Count 468) return; // 嘴唇开合关键点 13上唇和 14下唇 float mouthOpen Mathf.Abs(face[13][1] - face[14][1]); float mouthWeight Mathf.Clamp(mouthOpen * 800f, 0f, 100f); faceMeshRenderer.SetBlendShapeWeight(mouthOpenIndex, mouthWeight); // 左眼眨眼关键点 159上眼睑和 145下眼睑 float leftEyeOpen Mathf.Abs(face[159][1] - face[145][1]); float leftBlinkWeight Mathf.Clamp((0.02f - leftEyeOpen) * 5000f, 0f, 100f); faceMeshRenderer.SetBlendShapeWeight(eyeBlinkLeftIndex, leftBlinkWeight); // 右眼眨眼关键点 386上眼睑和 374下眼睑 float rightEyeOpen Mathf.Abs(face[386][1] - face[374][1]); float rightBlinkWeight Mathf.Clamp((0.02f - rightEyeOpen) * 5000f, 0f, 100f); faceMeshRenderer.SetBlendShapeWeight(eyeBlinkRightIndex, rightBlinkWeight); } }mouthOpen * 800f这个系数是根据经验调的因为嘴唇开合比例通常在 0.01 到 0.1 之间乘以 800 之后能映射到 0 到 100 的 BlendShape 权重范围。眨眼逻辑反过来眼睛睁开时上下眼睑距离大权重应该小闭眼时距离小权重应该大。所以用(0.02f - leftEyeOpen)做反转再乘以系数放大。注意不同模型的 BlendShape 名称不一样GetBlendShapeIndex的参数需要根据你实际使用的模型来改。如果返回 -1 说明名称不匹配可以在 Unity 编辑器里查看 SkinnedMeshRenderer 的 BlendShape 列表。4. 避坑与排查延迟、抖动、丢包这些血泪经验4.1 虚拟人物手部抖动严重像在抽搐现象Unity 里虚拟人物的手一直在小幅高频抖动即使真人手保持静止。原因MediaPipe 输出的关键点坐标本身有噪声帧与帧之间的微小波动被直接映射到骨骼旋转上放大了抖动效果。解决在 Unity 端做平滑滤波。最简单的是线性插值Lerp把当前帧的旋转和目标旋转做插值。更稳一点可以用 One Euro Filter它对低速抖动的抑制效果比固定系数的 Lerp 好很多。我一般先用 Lerp 系数 0.3 试如果还抖就上 One Euro Filter。4.2 UDP 数据丢包导致虚拟人物动作卡顿现象虚拟人物动作一顿一顿的不是每帧都更新。原因UDP 不保证送达网络拥塞或数据包过大时会丢包。面部 468 个点的 JSON 数据序列化后可能超过 30KB加上手部数据接近 UDP 单包上限。解决两个方向。一是减小数据量面部只发嘴唇、眉毛、眼睛周围的约 50 个关键点手部只发 21 个点这样 JSON 能压到 5KB 以内。二是加一个帧序号Unity 端发现序号跳变时用上一帧数据做插值补上避免动作直接跳变。4.3 MediaPipe 检测不到手或面部现象摄像头画面正常但 MediaPipe 就是不输出关键点。原因常见的有三种。一是光照太暗或太亮MediaPipe 的检测模型对光照敏感。二是手离摄像头太远画面中手部区域太小。三是摄像头被其他程序占用OpenCV 读到的帧是黑的。解决先确认cap.read()返回的ret是 True排除摄像头占用问题。然后改善光照手离摄像头 30 到 60 厘米。如果还是不行把min_detection_confidence从 0.5 降到 0.3 试试。4.4 Unity 编辑器里运行正常打包后收不到数据现象在 Unity 编辑器里一切正常打包成 exe 之后虚拟人物不动了。原因Windows 防火墙对打包后的程序默认拦截 UDP 入站连接编辑器因为已经有规则放行所以没事。解决在 Windows 防火墙里给打包后的 exe 添加入站规则允许 UDP 端口 5052。或者换一个不常用的端口有时候端口冲突也会导致这个问题。4.5 面部 BlendShape 权重跳变导致表情鬼畜现象虚拟人物表情突然从正常变成夸张的咧嘴或瞪眼。原因某一帧 MediaPipe 的面部关键点检测出现异常值比如嘴唇上下关键点的 y 坐标差值突然变得很大乘以放大系数后直接顶到 BlendShape 权重上限。解决在 Unity 端加一个限幅逻辑每帧 BlendShape 权重的变化量不超过上一帧的 20%。同时用中值滤波对最近 3 帧的关键点坐标做处理去掉突变值。5. 进阶技巧用 One Euro Filter 把抖动压到最低前面提到 Lerp 平滑但它有个矛盾系数大了延迟低但抖动还在系数小了平滑但动作滞后。One Euro Filter 的好处是根据信号变化速度自适应调整滤波强度——手静止时强滤波压抖动手快速移动时弱滤波保延迟。下面是在 Unity 里实现的 C# 版本using UnityEngine; public class OneEuroFilter { private float minCutoff; private float beta; private float dCutoff; private float xPrev; private float dxPrev; private bool initialized; public OneEuroFilter(float minCutoff 1.0f, float beta 0.007f, float dCutoff 1.0f) { this.minCutoff minCutoff; this.beta beta; this.dCutoff dCutoff; initialized false; } private float Alpha(float cutoff, float dt) { float tau 1.0f / (2.0f * Mathf.PI * cutoff); return 1.0f / (1.0f tau / dt); } public float Filter(float x, float dt) { if (!initialized) { xPrev x; dxPrev 0f; initialized true; return x; } float dx (x - xPrev) / dt; float aD Alpha(dCutoff, dt); float dxHat aD * dx (1 - aD) * dxPrev; float cutoff minCutoff beta * Mathf.Abs(dxHat); float a Alpha(cutoff, dt); float xHat a * x (1 - a) * xPrev; xPrev xHat; dxPrev dxHat; return xHat; } }三个参数的含义minCutoff控制静止时的滤波强度越小越平滑但延迟越大一般从 1.0 开始调。beta控制速度自适应程度越大对快速动作的响应越好但抖动也会增加常用范围 0.005 到 0.01。dCutoff是速度信号的滤波截止频率一般保持 1.0 不用改。用法上对每个关键点的 x、y、z 坐标各建一个 One Euro Filter 实例在Update里传入当前值和Time.deltaTimeprivate OneEuroFilter filterX new OneEuroFilter(1.0f, 0.007f); private OneEuroFilter filterY new OneEuroFilter(1.0f, 0.007f); private OneEuroFilter filterZ new OneEuroFilter(1.0f, 0.007f); void Update() { float dt Time.deltaTime; float rawX /* 从 JSON 解析出的 x 坐标 */; float rawY /* 从 JSON 解析出的 y 坐标 */; float rawZ /* 从 JSON 解析出的 z 坐标 */; float smoothX filterX.Filter(rawX, dt); float smoothY filterY.Filter(rawY, dt); float smoothZ filterZ.Filter(rawZ, dt); // 用 smoothX/Y/Z 去驱动骨骼或 BlendShape }调参的时候有个实用技巧先把beta设成 0只调minCutoff找到抖动刚好消失的值。然后逐步增大beta观察快速动作时的延迟是否可接受。我一般最终会落在minCutoff0.8、beta0.006这组值附近但具体要看摄像头帧率和动作幅度。验证滤波效果有个简单方法在 Unity 里加一个 UI Text 显示原始坐标和平滑后坐标的差值差值越小说明滤波越强。同时录一段视频对比滤波前后虚拟人物的动作流畅度。如果滤波后动作明显滞后于真人说明minCutoff太小了往上调。这套方案我从头跑通大概花了两天大部分时间花在调滤波参数和 BlendShape 映射上。最大的教训是不要一上来就追求完美映射先把数据链路跑通看到虚拟人物能动起来再逐步优化平滑和精度。另外MediaPipe 的版本更新比较频繁建议锁定一个能跑通的版本不要随意升级。希望帮到你。本文还有配套的精品资源点击获取
返回列表