MediaPipe+Unity:手部面部关键点实时驱动虚拟角色 📅 发布时间:2026/9/14 15:08:20 👁 浏览次数: 简介基于Python与MediaPipe实现手部、面部实时识别并驱动Unity端虚拟人物运动的完整项目源码面向计算机相关专业正在筹备毕业设计、课程设计或期末大作业的学生也适合希望从零上手视觉驱动Unity项目的实战学习者。项目经导师指导与评审获得99分高分代码结构完整、运行配置清晰普通学习者按说明操作也能顺利跑通。资源包共157个文件核心包含20个Python识别脚本、12个Unity C#控制脚本以及22个MP4演示视频另有pickle中间数据、unitypackage角色场景资源包、PDF说明文档等压缩包约85.91MB目录按功能模块划分便于按需查阅与二次开发。已有71人学习下载。通过该项目可完整掌握MediaPipe人脸与手部关键点提取、Python与Unity联动等关键技术并能将控制脚本替换到自有角色上直接迁移到毕业设计或实际演示场景中。1. 用 Python MediaPipe 把手部面部识别变成 Unity 虚拟人物的实时输入源想必你见过这样的场景人对着摄像头摆手、做表情屏幕里的虚拟角色跟着同步抬手、挑眉、张嘴说话。这套链路的核心其实不复杂——摄像头画面交给 Python 端的 MediaPipe识别出 21 个手部关键点和 468 个面部关键点再把关键点换算成“驱动量”也就是手指弯曲程度、嘴巴开合度这类参数通过网络实时推给 UnityUnity 拿到这些量去旋转虚拟角色的骨骼和 BlendShape。下文按关键点选型、坐标换算、网络传输、Unity 端骨骼驱动这个顺序展开给出的源码是不依赖商业动捕设备的最小可运行闭环。适合已经在用 Unity 做虚拟角色、想给角色加一个“摄像头控制”能力的开发者也适合数字人方向的技术人员。2. MediaPipe 手部面部识别关键点坐标系与驱动量换算在写 Unity 驱动代码之前要先把两个基础问题弄清楚MediaPipe 输出的关键点坐标代表什么以及这些坐标怎么变成能够驱动骨骼的“量”。直接用坐标去推 Unity 的 Transform 位置十有八九会出问题——手在画面里移动时坐标整体漂移而骨骼驱动需要的是与位置无关的相对量。2.1 手部 21 点和面部关键点的坐标含义MediaPipe 输出的是归一化坐标x、y 的取值范围是 0 到 1分别对应图像宽和高z 是深度值手部模型的 z 基准是手腕关键点面部模型的 z 基准是鼻尖。landmark[8].x 0.52表示食指指尖出现在画面水平方向的 52% 处。归一化的好处是坐标不随摄像头分辨率变化640×480 和 1920×1080 下同一位置的关键点数值一致这是它能跨分辨率驱动角色的基础。手部 21 个关键点的索引和驱动用途如下索引部位驱动用途0手腕手部整体位置与朝向基准1-4拇指CMC/MCP/IP/TIP拇指弯曲、对掌角度5-8食指MCP/PIP/DIP/TIP食指弯曲、手势指向9-12中指手掌朝向计算13-16无名指无名指弯曲17-20小指小指弯曲、手势判定有一个需要重点强调的细节MediaPipe 的坐标跟随图像坐标系y 轴向下而 Unity 是左手坐标系y 轴向上。直接把landmark.y赋给角色位置角色会上下颠倒。常见做法是在 Python 端把 y 翻转成1.0 - y或者在 Unity 端取反。我更偏向在 Unity 端统一处理因为后续的骨骼旋转映射都在 Unity 里坐标变换集中在一处排查镜像问题时思路更清晰。面部关键点有 468 个但驱动虚拟角色不需要全用。实际项目只关心一批能计算开合度的点对嘴部开合上嘴唇中心点 0 与下嘴唇中心点 17 的距离除以嘴角点 61、291 的距离得到一个不受脸大小影响的相对开合度。右眼闭合角色视角上眼睑点 159 与下眼睑点 145 的距离除以眼睛宽度即外眼角 33 与内眼角 133 的距离。左眼闭合上眼睑点 386 与下眼睑点 374 的距离除以对应眼宽。眉毛动作左眉点 65、右眉点 300 的 y 坐标变化量同样要除以脸宽度做归一化。这组点对覆盖了张嘴、眨眼、挑眉三类高频表情数字人直播和虚拟客服场景里用到的就是这几个量。2.2 手指弯曲程度计算三点向量法与四个关键点的归一化直接把手部关键点坐标发给 Unity 还有一个问题同一只手在画面不同位置、不同距离下坐标数值差异很大Unity 端没办法用一组固定阈值判断手势。弯曲程度是相对量手平移、缩放都不会改变它所以要先做坐标到角度的换算。计算角度用同一根手指上相邻的三个关键点构造两个向量再求夹角。为了得到整根手指的弯曲程度一般取连续四个点把两段夹角合并import math def calc_angle(a, b, c): 计算向量 ba 与 bc 的夹角b 为顶点返回角度degree v1 (a[0] - b[0], a[1] - b[1]) v2 (c[0] - b[0], c[1] - b[1]) dot v1[0] * v2[0] v1[1] * v2[1] len1 math.hypot(v1[0], v1[1]) len2 math.hypot(v2[0], v2[1]) if len1 0 or len2 0: return 0.0 cos_val max(-1.0, min(1.0, dot / (len1 * len2))) return math.degrees(math.acos(cos_val)) def finger_curl(a, b, c, d): 计算连续四个关键点代表的整根手指弯曲程度0 为伸直1 为完全弯曲 angle1 calc_angle(a, b, c) angle2 calc_angle(b, c, d) return max(0.0, min(1.0, 1.0 - (angle1 angle2) / 360.0))逻辑说明先以顶点b为公共端点构造向量ba和bc用点积公式cosθ v1·v2 / (|v1||v2|)求夹角余弦再用acos还原成角度。max/min钳制是防御性写法避免浮点舍入导致余弦值超出 [-1, 1] 时acos返回 NaN。finger_curl把两段夹角相加伸手指时两个夹角都接近 180 度总和接近 360 度换算后弯曲程度接近 0握拳时总和明显下降弯曲程度逼近 1。参数说明finger_curl的四个参数是同一根手指上连续的关键点坐标比如食指用点 5、6、7、8。驱动角色时把返回值乘以合适的角度上限比如curl * 90就能得到食指近端指节的旋转角度。这个归一化值在不同手型、不同距离下都稳定适合作为网络传输的原始量。2.3 MediaPipe 手部和面部推理的最小实现下面的代码是最常见的一种写法采用mp.solutions风格 API。mediapipe 0.10 之后官方主推 tasks API但 solutions 写法在 0.10.x 里仍然可用且代码更短本文用它保证可抄性import cv2 import mediapipe as mp mp_hands mp.solutions.hands mp_face_mesh mp.solutions.face_mesh hands mp_hands.Hands( static_image_modeFalse, max_num_hands2, min_detection_confidence0.5, min_tracking_confidence0.5, ) face_mesh mp_face_mesh.FaceMesh( static_image_modeFalse, max_num_faces1, refine_landmarksTrue, min_detection_confidence0.5, min_tracking_confidence0.5, ) cap cv2.VideoCapture(0) while cap.isOpened(): ok, frame cap.read() if not ok: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results_hands hands.process(rgb) results_face face_mesh.process(rgb) if results_hands.multi_hand_landmarks: hand_lm results_hands.multi_hand_landmarks[0] # finger_curl(点5, 点6, 点7, 点8) 得到食指弯曲度 if results_face.multi_face_landmarks: face_lm results_face.multi_face_landmarks[0] # 用 face_lm.landmark[0].y 和 face_lm.landmark[17].y 计算嘴部开合参数说明static_image_modeFalse让 MediaPipe 进入视频跟踪模式复用上一帧关键点作为先验推理速度明显提升代价是画面突然切换或手快速进出摄像头时重新检测会慢几帧。max_num_hands2限制输出手数。refine_landmarksTrue必须打开否则眼部周围只有基础的关键点上眼睑 159、386 的定位误差会很大眨眼数据不可用。还有一类容易被忽略的信息存在multi_handedness里它给每只手返回一个标签0 代表左手、1 代表右手。驱动 Unity 左右手骨骼时必须先按标签把数据分发到正确的骨骼上否则左右手动作会互换。双手交叉时标签准确率会明显下降实际项目里需要对历史标签做多数投票来稳定。如果multi_hand_landmarks为 None说明这一帧没检测到手。驱动层要么保留上一帧输出要么让角色回到默认姿势。保留上一帧会有延迟感直接回默认会闪跳建议“手消失超过 1 秒”后再回退。3. 实时链路打通用 UDP 把关键点帧从 Python 推到 Unity识别和弯曲度换算完成后剩下的问题是怎么把数据送进 Unity。可选项有 TCP、UDP 和 WebSocket选择依据如下方案可靠性延迟适用场景TCP有重传可靠弱网下延迟放大对丢包敏感的跨机传输UDP无重传可能丢包最低本地动捕驱动默认方案WebSocket可靠中等Unity WebGL 或浏览器端做本地动捕驱动我一般直接选 UDP。Unity 和 Python 跑在同一台机器走回环地址不经过物理网卡基本不丢包延迟也最低。3.1 定义帧协议一份可直接复用的 JSON 结构网络传输第一步是定义协议。我习惯把每帧数据组织成 JSON 对象包含帧号、时间戳、手部和面部两部分{ frame_id: 123, timestamp: 1712345678.125, hands: [ { id: 0, fingers: [0.12, 0.34, 0.55, 0.2, 0.1], palm_rotation: [0.0, 0.0, 0.0, 1.0] } ], face: { mouth: 0.35, eye_left: 0.08, eye_right: 0.12, brow_left: -0.05, brow_right: 0.02 } }字段说明hands[].fingers按固定顺序放五个手指的弯曲程度顺序是拇指、食指、中指、无名指、小指取值范围 0 到 1palm_rotation是手掌整体姿态的四元数用[w, x, y, z]四个浮点数表示Unity 端可以通过new Quaternion(x, y, z, w)还原。face里是各表情开合度0~1 区间眉毛这类有抬压两个方向的量用 -1~1。做过一次包体估算五个手指值、一个四元数、五个表情值JSON 序列化后约 300 到 500 字节按 30fps 发送频率算带宽很小。单角色场景完全没必要上二进制协议。只有驱动多个角色或者带宽受限的远端渲染场景才需要考虑把 JSON 换成紧凑的字节数组。3.2 Python 端 UDP 发送最小实现import json import socket import time sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) UNITY_IP 127.0.0.1 UNITY_PORT 9050 def build_frame(frame_id, fingers, face_values): 组装符合 3.1 协议的 JSON 帧 return { frame_id: frame_id, timestamp: time.time(), hands: [{id: 0, fingers: fingers, palm_rotation: [0.0, 0.0, 0.0, 1.0]}], face: face_values, } def send_frame(payload: dict): 序列化并发送到 Unity 监听端口 data json.dumps(payload).encode(utf-8) sock.sendto(data, (UNITY_IP, UNITY_PORT))逻辑说明send_frame先做 JSON 序列化把字典转成 UTF-8 字节串再通过sendto直接发出。UDP 无连接不需要建立连接发送后也不用等确认所以延迟非常小。配合主循环每处理完一帧就调用一次send_frame。参数说明UNITY_IP和UNITY_PORT是收发两端必须对齐的参数。同机调试用127.0.0.1Unity 跑在另一台设备上就填那台设备的局域网 IP。端口选高位端口比如 9050避开系统常用端口。要注意 Windows 防火墙有时会拦截 UDP 入站收发不通时先检查防火墙规则。3.3 Unity 端 UDP 接收与 JSON 反序列化Unity 端用System.Net.Sockets.UdpClient接收。ReceiveAsync是异步方法不会阻塞主线程主线程还有余力做动画更新和渲染using System.Net; using System.Net.Sockets; using System.Text; using UnityEngine; public class UDPReceiver : MonoBehaviour { private UdpClient client; [SerializeField] private int listenPort 9050; async void Start() { client new UdpClient(listenPort); while (client ! null) { UdpReceiveResult result await client.ReceiveAsync(); string json Encoding.UTF8.GetString(result.Buffer); Dispatch(json); } } private void Dispatch(string json) { FrameData frame JsonUtility.FromJsonFrameData(json); // 把 frame 分发给手部驱动和面部驱动组件 } private void OnDestroy() { client?.Close(); client null; } }逻辑说明new UdpClient(listenPort)让 Unity 绑定 9050 端口ReceiveAsync在有数据到达时返回UdpReceiveResultBuffer是收到的字节数组。Dispatch里用JsonUtility.FromJsonFrameData转成强类型对象再分发给后续的驱动脚本。OnDestroy里关闭client并置空让while循环退出避免组件销毁后端口一直被占用。这段代码是教学级最小实现没有处理ReceiveAsync在 socket 关闭瞬间抛出的异常。正式项目里可以在循环外包一层 try-catch或者用链式调用替代while。配套的强类型类如下[System.Serializable] public class FrameData { public int frame_id; public double timestamp; public HandData[] hands; public FaceData face; } [System.Serializable] public class HandData { public int id; public float[] fingers; // [拇指, 食指, 中指, 无名指, 小指] public float[] palm_rotation; // [w, x, y, z] } [System.Serializable] public class FaceData { public float mouth; public float eye_left; public float eye_right; public float brow_left; public float brow_right; }参数说明JsonUtility对大小写敏感字段名必须和 JSON 的 key 完全一致。如果 JSON 里没有face字段反序列化后frame.face为 null使用前要做空判断。判断有无手部数据时要写hands null || hands.Length 0因为空数组与无字段在JsonUtility里的表现不一样。4. Unity 端驱动虚拟人物骨骼旋转与 BlendShape 映射实战数据到达 Unity 之后决定驱动效果的是映射环节。虚拟角色有两类驱动通道骨骼旋转负责手部姿态BlendShape 负责面部表情。两条通道的技术路线完全不同——骨骼是四元数旋转BlendShape 是线性插值权重混在一起写会让组件职责不清晰建议拆成HandRigDriver和FaceBlendShapeDriver两个脚本。4.1 手部骨骼驱动从弯曲程度到关节旋转先认清角色骨骼结构。以 Unity Humanoid 角色为例左右手腕下各有拇指、食指、中指、无名指、小指五根手指每根手指由两到三节骨骼组成。食指通常是近端、中间、远端三节拇指是近端和远端两节。如果角色配置了 Humanoid 骨骼可以在 Awake 阶段用Animator.GetBoneTransform按标准枚举拿骨骼引用模型换装后也不需要手动重挂using UnityEngine; public class HandRigDriver : MonoBehaviour { public Transform wrist; public Transform[] thumb new Transform[2]; public Transform[] index new Transform[3]; public Transform[] middle new Transform[3]; public Transform[] ring new Transform[3]; public Transform[] little new Transform[3]; public void Apply(float[] fingers, Quaternion palmRotation) { wrist.rotation palmRotation; // 数组下标 0 为近端1 为远端食指和中指多一节中间指骨 thumb[0].localRotation Quaternion.Euler(0, 0, fingers[0] * 45f); thumb[1].localRotation Quaternion.Euler(0, 0, fingers[0] * 60f); index[0].localRotation Quaternion.Euler(0, 0, fingers[1] * 80f); index[1].localRotation Quaternion.Euler(0, 0, fingers[1] * 90f); index[2].localRotation Quaternion.Euler(0, 0, fingers[1] * 70f); middle[0].localRotation Quaternion.Euler(0, 0, fingers[2] * 80f); middle[1].localRotation Quaternion.Euler(0, 0, fingers[2] * 90f); // 无名指和小指按同一规律继续 } }逻辑说明fingers数组收到的值在 0 到 1 之间乘以不同角度上限后赋给各节指骨的localRotation。把一整根手指的弯曲程度拆分到多个关节时近端和中间承担的角度多一些远端少一些视觉上才接近真实手指。这里的角度上限是经验值不同模型需要微调。有一个前提模型导入时手指骨骼是伸直状态角度为 0 时手指保持伸展。如果模型自带轻微弯曲就要在赋值时减去初始偏移量否则手指永远闭不拢或者伸不直。手掌整体旋转也可以从 MediaPipe 关键点恢复用三个点构建正交基public static Quaternion ComputePalmRotation(Vector3 wrist, Vector3 middleBase, Vector3 indexBase) { Vector3 forward (middleBase - wrist).normalized; Vector3 up Vector3.Cross(indexBase - middleBase, forward).normalized; Vector3 right Vector3.Cross(up, forward).normalized; Matrix4x4 mat Matrix4x4.identity; mat.SetColumn(0, right); mat.SetColumn(1, up); mat.SetColumn(2, forward); return mat.rotation; }逻辑说明forward取手腕到中指根部的方向up用食指根部与中指根部的连线跟forward做叉积得到手掌法线方向再用up叉乘forward得到right三者构成正交基。SetColumn依次填入矩阵前三列最后用mat.rotation提取四元数。左手和右手的数据在叉积方向上正好相反接上左手模型时如果发现手掌翻转在返回的四元数后面补一个绕 z 轴 180 度的修正。4.2 面部 BlendShape 驱动从开合度到表情权重面部骨骼也可以驱动但数字人制作流程里主流是 BlendShape。BlendShape 是 SkinnedMeshRenderer 上的一组形变目标数值是 0 到 100 的权重驱动嘴型、眨眼、眉毛都是直接改权重。using UnityEngine; public class FaceBlendShapeDriver : MonoBehaviour { public SkinnedMeshRenderer faceMesh; public void ApplyFace(FaceData face) { SetBlend(jawOpen, face.mouth); SetBlend(eyeBlinkLeft, face.eye_left); SetBlend(eyeBlinkRight, face.eye_right); } private void SetBlend(string shapeName, float value) { int index faceMesh.GetBlendShapeIndex(shapeName); if (index 0) faceMesh.SetBlendShapeWeight(index, Mathf.Clamp01(value) * 100f); } }逻辑说明GetBlendShapeIndex返回名称对应的索引找不到时返回 -1所以先做判断。SetBlendShapeWeight参数范围是 0 到 100因此把 0~1 的开合度乘以 100。Mathf.Clamp01防止平滑滤波或网络抖动产生的超界值破坏 BlendShape 形态。参数说明jawOpen、eyeBlinkLeft、eyeBlinkRight是 ARKit 标准命名多数从 Blender、iClone 或 Character Creator 导出的角色都带这组名字。模型是手工整理时名称可能完全不同最稳妥的办法是启动时把所有 BlendShape 名称打印到控制台和模型编辑器的名称表逐一对照。4.3 抖动抑制给手部和表情值做平滑MediaPipe 单帧检测存在抖动手掌快速旋转、眨眼瞬间、低光照下的嘴角尤其明显。直接把每帧原始值赋给骨骼和 BlendShape角色会高频颤振。常见抑制方法是指数平滑class SmoothFilter: 一阶指数平滑按 key 维护上次值 def __init__(self, alpha0.6): self.alpha alpha self.cache {} def update(self, key, value): if key not in self.cache: self.cache[key] value else: self.cache[key] (self.alpha * value (1 - self.alpha) * self.cache[key]) return self.cache[key]参数说明alpha是跟随度越大越接近原始值、延迟越低抖动也越明显越小越平滑但滞后越强。手指、手掌、嘴、眉毛的抖动频率不同要给不同值。没有万能参数只能按下面的初值在真机上微调驱动对象alpha 初始值调参观察点手指0.4握拳和张开是否干净利落手掌0.5快速转动手掌是否拖尾嘴巴0.7说话时口型是否跟得上眉毛0.6挑眉动作是否明显滞后平滑和延迟是一对矛盾。调到“不抖且不飘”就算合格建议单独做个调参面板实时改数值确认后再写进配置文件。5. 验证驱动的三个实操技巧延迟测量、丢帧监控与坐标系矫正功能跑通只是第一步要让虚拟角色真正“跟手”需要量化三件事端到端延迟、丢帧率、坐标系方向。下面这套验证方法不依赖第三方工具边跑边调。延迟测量。在 Python 发送的帧里带time.time()时间戳Unity 端收到后用Time.realtimeSinceStartup与时间戳做差再实时显示在屏幕上void OnGUI() { float ms (float)(Time.realtimeSinceStartup - lastTimestamp) * 1000f; GUI.Label(new Rect(10, 10, 300, 30), $端到端延迟 {ms:F1} ms); }50 到 90ms 属于可接受区间超过 150ms 会有明显“手在前面飘”的感觉。延迟偏大时先看摄像头是否低于 30fps再考虑把 Python 端发送频率从 30fps 降一半每两帧发一次。这个操作能立竿见影地降延迟肉眼几乎看不出动作差异。丢帧监控。UDP 没有确认机制需要在接收端统计帧号间隔。用当前frame_id减上一帧frame_id差值大于 1 说明中间丢了数据。运行五分钟统计丢帧比例长期高于 5% 就优先检查 Windows 防火墙对 UDP 端口的限制其次检查 Python 发送端是不是因为 MediaPipe 推理耗时波动而出现了不均匀发送。坐标系矫正。最常见的映射故障是手部镜像。在场景里挂一个调试组件用Gizmos.DrawLine画出从手腕到中指根部的方向线拿着手前后左右转动对比场景里的线段是否和真实手一致。方向不对时在ComputePalmRotation返回的四元数后面乘一个Quaternion.Euler(90, 0, 0)之类的修正量按 90 度或 180 度去试直到对应关系正确然后把偏移量固定下来。先把坐标系矫正做对再去调平滑参数和延迟优化。我遇到的“手势驱动不对”问题排查到最后基本都是手性翻转或 BlendShape 名称错位这两处修好剩下就是参数微调的活。本文还有配套的精品资源点击获取