news 2026/9/14 15:08:08

MediaPipe+Unity:手部面部关键点实时驱动虚拟角色

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MediaPipe+Unity:手部面部关键点实时驱动虚拟角色

简介:基于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 个面部关键点,再把关键点换算成“驱动量”,也就是手指弯曲程度、嘴巴开合度这类参数,通过网络实时推给 Unity,Unity 拿到这些量去旋转虚拟角色的骨骼和 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为公共端点构造向量babc,用点积公式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_mode=False, max_num_hands=2, min_detection_confidence=0.5, min_tracking_confidence=0.5, ) face_mesh = mp_face_mesh.FaceMesh( static_image_mode=False, max_num_faces=1, refine_landmarks=True, min_detection_confidence=0.5, min_tracking_confidence=0.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_mode=False让 MediaPipe 进入视频跟踪模式,复用上一帧关键点作为先验,推理速度明显提升;代价是画面突然切换或手快速进出摄像头时,重新检测会慢几帧。max_num_hands=2限制输出手数。refine_landmarks=True必须打开,否则眼部周围只有基础的关键点,上眼睑 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 到 1;palm_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_IPUNITY_PORT是收发两端必须对齐的参数。同机调试用127.0.0.1,Unity 跑在另一台设备上就填那台设备的局域网 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.FromJson<FrameData>(json); // 把 frame 分发给手部驱动和面部驱动组件 } private void OnDestroy() { client?.Close(); client = null; } }

逻辑说明:new UdpClient(listenPort)让 Unity 绑定 9050 端口;ReceiveAsync在有数据到达时返回UdpReceiveResultBuffer是收到的字节数组。Dispatch里用JsonUtility.FromJson<FrameData>转成强类型对象,再分发给后续的驱动脚本。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 是线性插值权重,混在一起写会让组件职责不清晰,建议拆成HandRigDriverFaceBlendShapeDriver两个脚本。

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 形态。

参数说明:jawOpeneyeBlinkLefteyeBlinkRight是 ARKit 标准命名,多数从 Blender、iClone 或 Character Creator 导出的角色都带这组名字。模型是手工整理时名称可能完全不同,最稳妥的办法是启动时把所有 BlendShape 名称打印到控制台,和模型编辑器的名称表逐一对照。

4.3 抖动抑制:给手部和表情值做平滑

MediaPipe 单帧检测存在抖动,手掌快速旋转、眨眼瞬间、低光照下的嘴角尤其明显。直接把每帧原始值赋给骨骼和 BlendShape,角色会高频颤振。常见抑制方法是指数平滑:

class SmoothFilter: """一阶指数平滑,按 key 维护上次值""" def __init__(self, alpha=0.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 名称错位,这两处修好,剩下就是参数微调的活。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 15:07:25

Dozzle Agent 模式完全指南:用 TLS 加密连接远程 Docker 主机

Dozzle Agent 模式完全指南&#xff1a;用 TLS 加密连接远程 Docker 主机 【免费下载链接】dozzle Realtime log viewer for containers. Supports Docker, Swarm and K8s. 项目地址: https://gitcode.com/GitHub_Trending/do/dozzle Dozzle 的 Agent&#xff08;代理&…

作者头像 李华
网站建设 2026/9/14 15:04:15

招聘岗位数据爬虫与可视化分析:从Scrapy到pyecharts的完整实现

简介&#xff1a;一套基于Python的招聘岗位数据爬虫与可视化分析完整项目&#xff0c;面向正在准备毕业设计或期末大作业的计算机相关专业学生&#xff0c;也适合需要实战练习的Python数据分析初级开发者。包内含五十九个文件&#xff0c;主要包含9个.py源码、12个.pyc编译文件…

作者头像 李华