ARTICLE DETAIL

资讯详情

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

MediaPipe手部面部识别驱动Unity虚拟人物完整链路

MediaPipe手部面部识别驱动Unity虚拟人物完整链路 简介这份源码资源面向计算机相关专业学生与项目实战学习者提供一套基于Python与MediaPipe实现手部、面部关键点识别并在Unity端驱动虚拟人物同步动作的完整方案可用于毕业设计、课程设计或大作业。压缩包共157个文件约85.91MB包含20个py脚本、12个cs脚本、66个pickle模型数据、22个mp4演示视频及pyc、xml、pdf、unitypackage等覆盖识别逻辑、Unity控制脚本与资源素材。已有72人学习下载。项目经导师指导并通过评审代码完整可运行读者可据此掌握MediaPipe手部与面部检测的调用方式、关键点数据向Unity的传输思路以及虚拟角色控制器、UI系统与存档管理模块的组织结构适合作为入门计算机视觉与虚拟形象驱动的实践参考。1. 从摄像头到虚拟人物MediaPipe 手部面部识别驱动 Unity 的完整链路你对着摄像头抬一下手屏幕里的虚拟人物同步抬手你张嘴它也张嘴——这套效果听起来像是动捕棚里的活实际上用 Python 加 MediaPipe 在普通笔记本上就能跑通。MediaPipe 是 Google 开源的跨平台感知管线它把手部 21 个关键点、面部 468 个关键点的检测模型封装成了开箱即用的 API你不需要训练任何模型装完库就能出坐标。真正需要动脑子的是后半段这些坐标怎么从 Python 进程传到 Unity怎么映射到虚拟人物的骨骼或 BlendShape 上怎么让延迟低到不穿帮。这套方案适合做虚拟主播、数字孪生交互、体感小游戏也适合想入门计算机视觉又不想啃论文的开发者。下面按「识别 → 传输 → 驱动」三段拆开讲每一步都给可复现的代码和参数。2. MediaPipe 手部与面部识别的 Python 端实现2.1 为什么选 MediaPipe 而不是自己训模型手部关键点检测这个任务自己从零训一个模型光是标注数据就得几千张还得处理遮挡、不同肤色、光照变化。MediaPipe 的 Hand Landmark 模型是在大量多样化数据上训好的输出 21 个 3D 关键点x、y、z 加归一化坐标单帧推理在 CPU 上就能跑到 30 FPS 以上。面部那边 Face Mesh 输出 468 个关键点覆盖眉毛、眼睛、嘴唇、脸颊轮廓做表情驱动绰绰有余。选型上还有一个容易被忽略的点MediaPipe 的坐标是归一化的0 到 1 之间这意味着你不需要关心摄像头分辨率是 640×480 还是 1280×720坐标直接就能映射到 Unity 的屏幕空间或世界空间。如果换成自己训的模型输出坐标的尺度每次都要重新对齐调试成本翻倍。安装上Python 3.8 到 3.11 都能跑 MediaPipe但 3.12 早期版本有过兼容问题我一般建议用 3.10 或 3.11。安装命令就一行pip install mediapipe opencv-python装完之后验证一下版本和可用性import mediapipe as mp print(mp.__version__) # 常见输出0.10.x低于 0.9 的版本 API 差异较大提示如果你用的是 Apple Silicon 的 MacMediaPipe 从 0.10 开始原生支持 arm64不需要走 Rosetta 转译帧率会明显好一截。2.2 手部 21 关键点检测的最小可跑代码先把手部检测跑通这是整条链路里最独立的一环。下面这段代码打开摄像头实时画出手部骨架import cv2 import mediapipe as mp mp_hands mp.solutions.hands mp_draw mp.solutions.drawing_utils # 初始化手部检测器 hands mp_hands.Hands( static_image_modeFalse, # 视频流模式False 会做帧间跟踪更快 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 # MediaPipe 要求 RGB 输入OpenCV 默认是 BGR rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) if results.multi_hand_landmarks: for hand_lms in results.multi_hand_landmarks: mp_draw.draw_landmarks(frame, hand_lms, mp_hands.HAND_CONNECTIONS) # 取手腕点索引 0的归一化坐标 wrist hand_lms.landmark[0] print(f手腕坐标: x{wrist.x:.3f}, y{wrist.y:.3f}, z{wrist.z:.3f}) cv2.imshow(Hand Tracking, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码的逻辑很直白每读一帧转成 RGB 喂给 MediaPipe拿回multi_hand_landmarks列表。每个hand_landmarks里有 21 个landmark每个 landmark 有x、y、z三个属性。x和y是归一化到 [0,1] 的图像坐标z是相对于手腕的深度值越小表示越靠近摄像头。参数上最需要调的是min_detection_confidence和min_tracking_confidence。如果你发现手一快速移动就丢跟踪把 tracking 降到 0.3 试试如果误检太多比如背景里有类似手的物体把 detection 提到 0.7。static_image_mode在视频流里一定设 False设 True 的话每帧都重新检测帧率直接砍半。2.3 面部 468 关键点与表情基提取面部检测的 API 结构和手部几乎一样只是输出从 21 个点变成 468 个点import cv2 import mediapipe as mp mp_face mp.solutions.face_mesh face_mesh mp_face.FaceMesh( static_image_modeFalse, 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 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results face_mesh.process(rgb) if results.multi_face_landmarks: face results.multi_face_landmarks[0] # 取嘴唇上下关键点估算张嘴程度 upper_lip face.landmark[13] # 上唇内侧中点 lower_lip face.landmark[14] # 下唇内侧中点 mouth_open abs(upper_lip.y - lower_lip.y) print(f张嘴程度: {mouth_open:.4f}) cv2.imshow(Face Mesh, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()refine_landmarksTrue这个参数值得单独说开启后会在眼睛区域额外输出虹膜关键点总共 478 个点。做虚拟人物的眼神追踪时没有虹膜点就只能靠眼球轮廓估算精度差很多。代价是推理时间增加约 15%如果只做嘴型和眉毛驱动可以关掉省性能。嘴唇开合的计算用的是索引 13 和 14 两个点这是 MediaPipe 官方文档里标注的上下唇内侧中点。实际用的时候不要直接用 y 差值因为人脸远近会影响绝对值最好除以人脸高度做归一化。人脸高度可以用额头点索引 10到下巴点索引 152的 y 差值来算。3. Python 与 Unity 之间的数据通道怎么搭3.1 三种通信方案的对比与选型Python 识别出的坐标要送到 Unity常见做法有三种UDP Socket、WebSocket、共享内存。我三种都用过说下实际感受。UDP 最简单Python 端socket.sendto一行Unity 端用UdpClient.Receive收。延迟最低实测局域网内单帧数据往返在 1ms 以内。缺点是丢包不补偶尔会跳一帧但对关键点驱动来说丢一帧下一帧就补上了肉眼基本看不出来。WebSocket 适合跨机器或者需要双向通信的场景比如 Unity 端要回传控制指令给 Python。但 WebSocket 有握手开销和帧头开销延迟比 UDP 高 2 到 5ms而且 Unity 端要用第三方库如 NativeWebSocket多一层依赖。共享内存是延迟最低的方案Python 用multiprocessing.shared_memoryUnity 用MemoryMappedFile读。但跨语言的内存布局对齐很容易翻车字节序、结构体 padding 都是坑调试起来没有后悔药。除非你的帧率要求到了 120 FPS 以上否则不建议上来就搞共享内存。我的建议先用 UDP 跑通延迟不够再换共享内存。下面给的也是 UDP 方案。3.2 Python 端 UDP 发送关键点数据在识别循环里加一个 UDP 发送把关键点打包成 JSON 发出去import cv2 import mediapipe as mp import socket import json mp_hands mp.solutions.hands mp_face mp.solutions.face_mesh hands mp_hands.Hands(max_num_hands2, min_detection_confidence0.5) face_mesh mp_face.FaceMesh(max_num_faces1, refine_landmarksTrue) # UDP 目标地址Unity 端监听的端口 UDP_IP 127.0.0.1 UDP_PORT 5052 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) hand_results hands.process(rgb) face_results face_mesh.process(rgb) payload {hands: [], face: None} if hand_results.multi_hand_landmarks: for hand_lms in hand_results.multi_hand_landmarks: points [{x: lm.x, y: lm.y, z: lm.z} for lm in hand_lms.landmark] payload[hands].append(points) if face_results.multi_face_landmarks: face face_results.multi_face_landmarks[0] # 只发关键子集468 个点全发 JSON 太大 key_indices [13, 14, 10, 152, 33, 133, 362, 263] payload[face] [{x: face.landmark[i].x, y: face.landmark[i].y, z: face.landmark[i].z} for i in key_indices] data json.dumps(payload).encode(utf-8) # 单包超过 65507 字节会发送失败468 点全发会超 if len(data) 60000: sock.sendto(data, (UDP_IP, UDP_PORT)) cv2.imshow(Tracking, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() sock.close() cv2.destroyAllWindows()这里有个关键决策面部 468 个点全发 JSON 会超过 UDP 单包上限65507 字节而且 JSON 序列化本身也吃 CPU。所以我只挑了 8 个关键点发过去——嘴唇上下、额头、下巴、左右眼内外眼角。这 8 个点足够驱动张嘴、眨眼、头部朝向。如果你要做完整的面部表情捕捉建议改用二进制打包struct.pack而不是 JSON体积能压到十分之一。手部 21 个点全发没问题两只手也就 42 个点JSON 体积在 3KB 左右UDP 完全扛得住。3.3 Unity 端接收与解析Unity 这边新建一个 C# 脚本挂到场景里的空物体上using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using UnityEngine; public class PoseReceiver : MonoBehaviour { private UdpClient udpClient; private Thread receiveThread; private string latestData ; // 解析后的数据供其他脚本读取 public Vector3[] leftHandPoints new Vector3[21]; public Vector3[] rightHandPoints new Vector3[21]; public float mouthOpen 0f; void Start() { udpClient new UdpClient(5052); receiveThread new Thread(new ThreadStart(ReceiveLoop)); receiveThread.IsBackground true; receiveThread.Start(); } private void ReceiveLoop() { IPEndPoint remoteEndPoint new IPEndPoint(IPAddress.Any, 0); while (true) { try { byte[] data udpClient.Receive(ref remoteEndPoint); latestData Encoding.UTF8.GetString(data); } catch (Exception e) { Debug.LogWarning(UDP 接收异常: e.Message); } } } void Update() { if (string.IsNullOrEmpty(latestData)) return; // 主线程解析避免 Unity API 在子线程调用 ParseData(latestData); latestData ; } private void ParseData(string json) { // 用 JsonUtility 解析需要定义对应的数据结构 PoseData data JsonUtility.FromJsonPoseData(json); if (data null) return; for (int h 0; h data.hands.Length h 2; h) { Vector3[] target (h 0) ? leftHandPoints : rightHandPoints; for (int i 0; i data.hands[h].points.Length i 21; i) { var p data.hands[h].points[i]; // 注意Unity 的 y 轴向上MediaPipe 的 y 轴向下需要翻转 target[i] new Vector3(p.x, 1f - p.y, p.z); } } if (data.face ! null data.face.points.Length 2) { // 索引 0 是上唇索引 1 是下唇 mouthOpen Mathf.Abs(data.face.points[0].y - data.face.points[1].y); } } void OnApplicationQuit() { if (receiveThread ! null) receiveThread.Abort(); if (udpClient ! null) udpClient.Close(); } } [Serializable] public class PoseData { public HandData[] hands; public FaceData face; } [Serializable] public class HandData { public PointData[] points; } [Serializable] public class FaceData { public PointData[] points; } [Serializable] public class PointData { public float x; public float y; public float z; }这段代码有几个容易翻车的点。第一UDP 接收必须在子线程做否则Receive会阻塞主线程导致 Unity 卡死。第二子线程里不能调 Unity 的 API比如Debug.Log在部分版本会报错所以收到的数据先存字符串回到Update主线程再解析。第三MediaPipe 的 y 轴是向下增长的图像坐标系Unity 的 y 轴向上所以要做1f - p.y的翻转这个不翻转的话虚拟人物的手会上下颠倒。OnApplicationQuit里一定要关线程和 socket否则在 Unity 编辑器里停止播放后端口还被占着下次运行直接报Address already in use。这个坑我踩过不止一次。4. 把关键点映射到虚拟人物骨骼与表情4.1 手部关键点到骨骼旋转的映射思路拿到 21 个手部关键点后驱动虚拟人物的手有两种做法直接位移骨骼或者计算旋转角度。直接位移最简单把虚拟人物手部骨骼的localPosition设成对应关键点的坐标。但这样手会像纸片一样平移没有关节弯曲的效果。适合做简单的指示手势比如虚拟人物的手跟着你的手在屏幕上移动。计算旋转更自然用相邻两个关键点算出一个方向向量然后把这个方向向量转成骨骼的旋转。比如手腕索引 0到食指根部索引 5的方向决定了手掌的朝向。具体做法// 在 PoseReceiver 解析完数据后驱动手部骨骼 public Transform wristBone; // 手腕骨骼 public Transform indexBaseBone; // 食指根部骨骼 void DriveHand() { if (leftHandPoints[0] Vector3.zero) return; // 手腕位置直接映射到世界坐标需要根据场景缩放调整 wristBone.position leftHandPoints[0] * 5f; // 计算手掌朝向 Vector3 palmDir leftHandPoints[5] - leftHandPoints[0]; if (palmDir.sqrMagnitude 0.0001f) { Quaternion targetRot Quaternion.LookRotation(palmDir); // 用 Slerp 平滑避免抖动 wristBone.rotation Quaternion.Slerp(wristBone.rotation, targetRot, 0.5f); } }Quaternion.Slerp的第三个参数是平滑系数0.5 表示每帧向目标旋转靠近一半。这个值不要设成 1设成 1 的话关键点抖动会直接传到骨骼上虚拟人物的手会抖得像帕金森。设成 0.2 到 0.3 会更稳但会有轻微延迟。这个平衡点需要根据你的帧率和场景需求调。4.2 面部 BlendShape 驱动的参数映射面部驱动用 BlendShape 比骨骼更合适因为虚拟人物的表情通常就是预设好的几个 BlendShape张嘴、眨眼、微笑等。把 MediaPipe 的关键点转成 BlendShape 权重核心是找到关键点距离和权重之间的映射关系。以张嘴为例MediaPipe 的上下唇距离归一化后大概在 0 到 0.05 之间变化。虚拟人物的张嘴 BlendShape 权重是 0 到 100。直接线性映射的话// mouthOpen 来自 PoseReceiver范围约 0 到 0.05 float blendShapeWeight Mathf.Clamp(mouthOpen / 0.05f, 0f, 1f) * 100f; skinnedMeshRenderer.SetBlendShapeWeight(mouthIndex, blendShapeWeight);但线性映射的问题是嘴唇微微张开时权重变化太快张到最大时又不够。实际调的时候我会加一个曲线// 用 AnimationCurve 在 Inspector 里调映射曲线 public AnimationCurve mouthCurve; float normalized Mathf.Clamp01(mouthOpen / 0.05f); float weight mouthCurve.Evaluate(normalized) * 100f; skinnedMeshRenderer.SetBlendShapeWeight(mouthIndex, weight);AnimationCurve可以在 Unity Inspector 里可视化拖拽把曲线调成先慢后快再慢的 S 形张嘴动作会自然很多。这个曲线没有标准答案取决于你的虚拟人物模型本身的表情 BlendShape 是怎么做的得对着镜子调。眨眼驱动类似用上下眼睑关键点的距离。MediaPipe 面部关键点里左眼上眼睑是索引 159下眼睑是索引 145。距离小于某个阈值就判定为闭眼BlendShape 权重拉满。4.3 延迟优化与帧率对齐整条链路的延迟来自四段摄像头采集约 30ms、MediaPipe 推理CPU 上约 15 到 25ms、UDP 传输小于 1ms、Unity 渲染约 16ms。加起来大概 60 到 70ms肉眼能感觉到轻微延迟但做虚拟主播够用了。想再压延迟有三个方向。第一把摄像头分辨率降到 480pMediaPipe 推理时间能省三分之一关键点精度损失很小。第二Python 端把model_complexity参数从默认的 1 降到 0手部检测支持这个参数推理快一倍但手指细节会糙一点。第三Unity 端把Application.targetFrameRate设成 60确保渲染不拖后腿。帧率对齐上Python 端的识别帧率和 Unity 的渲染帧率不需要一致。UDP 是异步的Python 发多少 Unity 收多少Unity 在Update里用最新收到的数据就行。但要注意如果 Python 端帧率远高于 UnitylatestData会被频繁覆盖浪费带宽。我一般会在 Python 端加一个time.sleep(0.01)把发送频率控制在 60 到 80 Hz 左右。5. 避坑与排查那些让我加班到凌晨的问题5.1 摄像头被占用导致 Python 端读不到帧现象cap.isOpened()返回 True但cap.read()一直返回 False画面黑的。原因摄像头被其他程序占用了Windows 上最常见的是 Unity 编辑器本身如果也开了 WebCamTexture或者浏览器里开着视频会议页面。解决关掉所有可能占用摄像头的程序。在 Windows 上可以在设备管理器里看摄像头是否被占用。另外cv2.VideoCapture(0)里的 0 是摄像头索引如果你有多个摄像头可能需要试 1 或 2。5.2 Unity 端收不到数据但 Python 显示已发送现象Python 端sendto没报错Unity 端Receive一直阻塞。原因防火墙拦了 UDP 包或者 IP 地址写错了。如果 Python 和 Unity 在同一台机器上用127.0.0.1没问题如果跨机器Python 端要写 Unity 所在机器的局域网 IP而且 Windows 防火墙默认会拦入站 UDP。解决先在本地用127.0.0.1跑通确认代码没问题再换 IP。跨机器的话在 Windows 防火墙里给 Unity 编辑器加一条入站规则允许 UDP 5052 端口。调试时可以用netstat -an | findstr 5052看端口有没有在监听。5.3 关键点抖动导致虚拟人物手部抽搐现象虚拟人物的手一直在小幅抖动静止时也抖。原因MediaPipe 的关键点本身就有帧间抖动尤其是手部快速移动或者光照变化时。直接把这些坐标赋给骨骼抖动就被放大了。解决加滤波。最简单的是滑动平均存最近 5 帧的坐标取平均。代码大概是这样private QueueVector3[] history new QueueVector3[21]; Vector3 Smooth(int index, Vector3 raw) { if (history[index] null) history[index] new QueueVector3(); history[index].Enqueue(raw); if (history[index].Count 5) history[index].Dequeue(); Vector3 sum Vector3.zero; foreach (var v in history[index]) sum v; return sum / history[index].Count; }滑动平均的窗口大小是 5窗口越大越平滑但延迟越高。如果抖动还是很明显可以上 One Euro Filter它是专门为交互式系统设计的低延迟滤波器网上有现成的 C# 实现。5.4 面部关键点索引对不上导致表情错乱现象虚拟人物该张嘴的时候眨眼该眨眼的时候张嘴。原因MediaPipe 面部 468 个关键点的索引是固定的但不同版本的 MediaPipe 可能有微调。另外如果你用了refine_landmarksTrue虹膜关键点会插在原有索引之间导致后面的索引偏移。解决不要硬编码索引在 Python 端把关键点的语义名称和索引的对应关系打印出来验证。比如嘴唇上下是 13 和 14这个在官方文档里有标注。如果开了refine_landmarks虹膜点是 468 到 477不影响前面的索引。但如果你从网上抄了一份索引表一定要确认它对应的是哪个 MediaPipe 版本。5.5 Unity 编辑器停止播放后端口未释放现象第二次运行时报SocketException: Address already in use。原因Unity 编辑器停止播放时OnApplicationQuit不一定被调用尤其是强制停止时UDP 端口没释放。解决在OnDisable和OnDestroy里也加上关闭 socket 的逻辑。另外UDP 的Close和Dispose都要调。如果还是不行在Start里给UdpClient加ExclusiveAddressUse false和ReuseAddress选项udpClient new UdpClient(); udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, 5052));这样即使端口还没完全释放也能重新绑定。6. 进阶用 One Euro Filter 把抖动压到肉眼不可见滑动平均的问题是延迟和抖动是一对矛盾窗口开大抖动小了但延迟上来了窗口开小延迟低了但抖动还在。One Euro Filter 的思路是根据信号的变化速度动态调整滤波强度。信号变化慢的时候手静止滤波强度拉高把抖动压死信号变化快的时候手快速移动滤波强度降低保证不引入延迟。它的核心公式不复杂但参数调起来有讲究。C# 实现大概是这样public class OneEuroFilter { private float minCutoff; // 最小截止频率越小越平滑 private float beta; // 速度系数越大对快速移动越敏感 private float dCutoff; // 速度信号的截止频率 private float xPrev; private float dxPrev; private bool initialized false; public OneEuroFilter(float minCutoff 1.0f, float beta 0.007f, float dCutoff 1.0f) { this.minCutoff minCutoff; this.beta beta; this.dCutoff dCutoff; } private float Alpha(float cutoff, float dt) { float tau 1.0f / (2 * Mathf.PI * cutoff); return 1.0f / (1.0f tau / dt); } public float Filter(float x, float dt) { if (!initialized) { xPrev x; dxPrev 0; 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 是常用起点设 0.5 会更平滑但轻微延迟。beta控制对快速移动的响应设 0.007 是论文推荐值如果你觉得快速挥手时虚拟人物的手跟不上把 beta 提到 0.01 到 0.02。dCutoff一般设 1.0 不用动。每个关键点的 x、y、z 各需要一个独立的滤波器实例21 个点就是 63 个实例。听起来多但每个实例的计算量就是几次乘加对性能的影响可以忽略。实际用的时候把Filter的调用放在ParseData之后、驱动骨骼之前。dt用Time.deltaTime。我实测下来One Euro Filter 比滑动平均的延迟低 30% 左右抖动抑制效果还更好。调参的时候先把minCutoff设成 0.5 看静止时稳不稳再调beta看快速移动跟不跟得上。这套方案我从头跑通大概花了两天其中一天半在调 Unity 端的映射和滤波。Python 端反而简单MediaPipe 的 API 设计得很干净。如果你刚开始做建议先把 UDP 通道跑通用固定的假数据驱动虚拟人物确认 Unity 端没问题了再接 MediaPipe 的真实数据。这样出问题的时候能快速定位是识别端还是驱动端。希望帮到你。本文还有配套的精品资源点击获取
返回列表