简介基于Python与MediaPipe的手部、面部识别并联动Unity端虚拟人物驱动的实战项目源码主要面向计算机相关专业正在做毕业设计、课程设计、期末大作业的学生也适合需要项目练习的开发者。项目核心链路完整Python侧利用MediaPipe进行手部/面部关键点检测通过数据交互驱动Unity虚拟人物动作可支撑跨端交互类课题的快速搭建。压缩包共157个文件大小85.91MB主要包含20个Python源码、66个pickle数据文件、22个MP4演示视频、12个Unity C#脚本以及XML、PDF、UnityPackage等辅助文件目录结构清晰涵盖Unity角色控制、UI与数据管理等模块便于按模块阅读与二次开发。目前已有71人浏览学习代码完整可运行项目经导师指导评审获得99分适合作为毕业设计、课程设计或期末大作业的完整参考亦可作为理解MediaPipe识别与Unity人物动画联动流程的入门模板。1. 从摄像头到虚拟人一套完整的识别联动链路摄像头前的你只要抬手、歪头屏幕里的虚拟角色就会同步做出动作。这套基于 Python MediaPipe 的手部/面部识别方案把关键点坐标通过局域网传给 Unity驱动 UnityChan 这类虚拟人物。源码包里既有 Python 识别端也有完整的 Unity 脚本——HiyoriController、UnityChanController、UISystem 以及配套的 Pref 和存档管理。它不是简单的单帧识别 demo而是把识别、通信、驱动、UI 状态和参数保存串起来的一个完整闭环运行起来能看到虚拟人跟随你的手动和面部表情。各脚本之间已经接好不需要自己拼凑从架构上看它很适合作为毕业设计或课程设计的主干工程。如果你对虚拟形象交互、动作捕捉、手势控制感兴趣可以拿这个做起点也可以只抽其中某一段来学习。2. Python 侧 MediaPipe 识别管线手部关键点与面部关键点的高效输出2.1 环境准备与模型初始化常见做法是用 venv 或 conda 隔离环境避免把系统 Python 弄乱。推荐 Python 3.93.11MediaPipe 在 3.11 上已经能稳定运行。安装核心依赖pip install mediapipe opencv-python numpyMediaPipe 会根据 Python 版本自动拉取对应的预编译库不需要手动编译。初始化手部和面部模型时我的做法是把两个模型放在同一个上下文里这样摄像头只需读一帧就能同时拿到手和脸的关键点import mediapipe as mp mp_hands mp.solutions.hands mp_face_mesh mp.solutions.face_mesh mp_draw mp.solutions.drawing_utils hands mp_hands.Hands( static_image_modeFalse, max_num_hands2, min_detection_confidence0.5, min_tracking_confidence0.5, ) face_mesh mp_face_mesh.FaceMesh( max_num_faces1, refine_landmarksTrue, min_detection_confidence0.5, )有几个参数需要特别注意。static_image_mode如果设为TrueMediaPipe 会放弃前一帧的跟踪状态每帧重新检测适合图片处理实时视频流里保持False才能利用跨帧跟踪减少算力开销。max_num_hands控制同时跟踪的手数量一般设置为 2当两只手重叠时不容易把右手识别成左手。min_detection_confidence是最小检测置信度调低可以让手更小或遮挡更多时也能检测但也会引入误检0.5 是一个均衡点实际项目中如果摄像头是 720p我一般会调到 0.6 来减少抖动。face_mesh的refine_landmarks参数很关键它会把原本的 468 点扩展出更多瞳孔周围的轮廓点对后续眼神跟随有很大的帮助。在手部模型初始化后我们通常还需要一个参数表来确认不同手势对应的关键点范围。MediaPipe 官方给出手部区域和手指各关节的索引表 2-1 列出了经常需要用到的几个位置。表 2-1 手部关键点索引常用映射关键点索引用途手腕0手部整体位置基准拇指尖4拇指伸展判断食指尖8指向、点击手势中指尖12确认手势是否冲突无名指尖16手势稳定性判断小指尖20五指张开判断食指掌指关节5计算手指弯曲角度这个表在 Unity 端做手势映射时也会用到所以千万不要只记坐标不记索引。2.2 手部识别从坐标到手势语义拿到 21 个手部关键点后最常见的需求是判断“哪根手指伸出来”。直接比较尖端点与对应掌指关节的 y 坐标即可因为 MediaPipe 输出的坐标是归一化到 [0,1] 的而且以图像左上角为原点。下面这段代码在读取摄像头帧后同时执行手和脸的推理并把关键点序号和坐标存成 JSON 数组用于发送import cv2 import json cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: continue frame cv2.cvtColor(cv2.flip(frame, 1), cv2.COLOR_BGR2RGB) rgb_frame cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) result_hands hands.process(frame) result_face face_mesh.process(frame) if result_hands.multi_hand_landmarks: for hand_landmarks in result_hands.multi_hand_landmarks: mp_draw.draw_landmarks( rgb_frame, hand_landmarks, mp_hands.HAND_CONNECTIONS, mp_draw.DrawingSpec(color(0, 255, 0), thickness1, circle_radius1), mp_draw.DrawingSpec(color(0, 0, 255), thickness1), ) cv2.imshow(hand tracking, rgb_frame) if cv2.waitKey(1) 27: break cap.release() cv2.destroyAllWindows()上面代码中的cv2.flip是为了让画面像照镜子一样和用户左右对齐也就是“镜像模式”。注意 MediaPipe 的process接收 RGB 输入所以我先把 BGR 转成 RGB 再送入但可视化时又要转回 BGR这里很容易漏掉。关于手势判定的逻辑常见做法是用食指的 6、7、8 三点计算角度或者直接比较 8 和 6 的坐标。在靠近摄像头时同一手势的坐标值会整体放大所以直接用绝对坐标做阈值是不够的。我一般会先把坐标除上手腕到中指掌关节的距离来做归一化再计算手势。2.3 面部识别与关键区域提取FaceMesh 输出 468 个面部关键点但驱动虚拟人并不需要全部 468 点。通常只关心左眼、右眼和嘴巴周围的一组点左眼眼角两个点33 和 133、右眼眼角362 和 263、嘴唇上下13 和 14以及下巴末端152。有了这些头部旋转角度可以根据鼻尖相对两眼的偏移估算出来。例如如果鼻尖点1稳定地偏向画面右侧就可以判断头部右转。面部识别还有个容易踩的坑MediaPipe 的 FaceMesh 坐标受脸的大小影响必须归一化后再传给 Unity否则离摄像头远近会产生不同幅度的动作。下面这段代码把面部关键点中的眼睛和嘴巴区域提取成固定格式face_indices { nose: 1, left_eye_outer: 33, left_eye_inner: 133, right_eye_inner: 362, right_eye_outer: 263, mouth_left: 61, mouth_right: 291, mouth_top: 13, mouth_bottom: 14, chin: 152, } def extract_face_data(landmarks): data {} for name, idx in face_indices.items(): lm landmarks.landmark[idx] data[name] [lm.x, lm.y, lm.z] return data这里返回的是归一化坐标x、y 都在 [0,1] 附近z 是深度信息。z 值在 MediaPipe 中不是真实距离但它可以反映鼻尖和面部基线的相对位置。在后续驱动 Unity 角色时z 值可以当作头部前后倾的参考信号不过要记得限制幅度不然虚拟人会过度变形。2.4 用 UDP 把结果推给 UnityUnity 端读取摄像头不方便而且 Python 这边已经处理完了所以数据传输常用 UDP。为什么不用 TCPUDP 不需要握手丢了关键帧直接跳过对实时驱动来说这种“允许丢”的特性比严格有序更重要。我一般会在本机用 127.0.0.1:8888 端口通信下面这段代码把手和脸的数据打包成 JSON以固定频率发送import socket import time sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) unity_addr (127.0.0.1, 8888) while True: payload {hands: [], face: {}} if result_hands.multi_hand_landmarks: for hand in result_hands.multi_hand_landmarks: payload[hands].append( [[lm.x, lm.y, lm.z] for lm in hand.landmark] ) if result_face.multi_face_landmarks: payload[face] extract_face_data( result_face.multi_face_landmarks[0] ) message json.dumps(payload).encode(utf-8) sock.sendto(message, unity_addr) time.sleep(0.03) # 约 30 FPStime.sleep(0.03)表示每 30ms 发一帧也就是 33 FPS 左右。如果你的 Unity 端驱动刷新率是 60 FPS可以在sendto之后用带时间戳的方式在 Unity 中插值让动画更平滑。我建议不要把发送频率提得比摄像头实际帧率还高否则网络队列里会堆积过期数据反而增大延迟。参数 8888 端口需要和 Unity 端保持一致下面的章节会看到 C# 侧的监听代码。3. Unity 端接收与骨骼驱动从 UDP 数据到虚拟人动作3.1 两个控制器脚本的职责拆分项目里最重要的脚本是 HiyoriController.cs 和 UnityChanController.cs它们承担了不同的角色。表 3-1 列出了整个 Unity 工程里主要脚本的用途方便你快速定位功能点。表 3-1 Unity 端脚本职责对照脚本职责HiyoriController.cs负责接收 UDP 数据、解析 JSON、维护关键点缓存UnityChanController.cs把关键点转换成骨骼旋转驱动 UnityChan 模型UISystem.cs显示连接状态、FPS、识别是否成功HiyoriPref.csHiyori 控制器的参数配置端口、平滑系数等UnityChanPref.cs角色驱动参数配置旋转速度、骨骼偏移等FileManager.cs读写本地配置文件SaveDataManager.cs统一管理存档的保存、加载和路径检查从这里可以看出工程并不是把所有逻辑塞到一个 MonoBehaviour 里而是把数据接收、角色驱动、UI 和存档分开。HiyoriController 更像一个数据服务层UnityChanController 则只消费数据不关心数据来自哪里。这样设计的好处是如果你想更换数据源比如从 Python 换成其他动捕设备只需要修改 HiyoriController 的接收逻辑角色驱动部分完全不用动。3.2 在 Unity 中监听 UDP 并解析关键点我一般会把 UDP 接收放在独立线程里避免阻塞主线程的 Update。下面这段脚本展示了 HiyoriController 的简化实现它在线程里接收数据解析 JSON 后写入 ConcurrentQueue主线程每帧从中取出最新的关键点。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Collections.Concurrent; using UnityEngine; public class HiyoriController : MonoBehaviour { [SerializeField] private int listenPort 8888; private UdpClient udpClient; private Thread receiveThread; private ConcurrentQueuestring dataQueue new ConcurrentQueuestring(); private void Start() { udpClient new UdpClient(listenPort); receiveThread new Thread(ReceiveLoop); receiveThread.IsBackground true; receiveThread.Start(); } private void ReceiveLoop() { IPEndPoint remoteEP new IPEndPoint(IPAddress.Any, 0); while (true) { byte[] data udpClient.Receive(ref remoteEP); string message Encoding.UTF8.GetString(data); dataQueue.Enqueue(message); } } private void Update() { if (dataQueue.TryDequeue(out string latest)) { // 调用 UnityChanController 处理 latest 中的 JSON Debug.Log(latest.Substring(0, Mathf.Min(120, latest.Length))); } } }udpClient.Receive是阻塞的所以开了后台线程。注意 Update 里用的是TryDequeue队列里可能会积压好几帧这里只取最新的但更好的做法是清空旧数据。很多第一次接触 Unity 网络编程的人会直接在 Update 里写同步接收那样 Unity 编辑器会在数据到达时卡住几十毫秒体验非常差。采用独立线程后主线程每帧只处理一条消息就不会在识别过程中卡顿。3.3 从关键点坐标到角色骨骼旋转UnityChanController 拿到 JSON 之后要先反序列化。Unity 自带的 JsonUtility 不支持数组嵌套数组所以我一般会把数组定义成可序列化的类。将归一化坐标映射到手部关节时需要做两个变换一是把坐标系从 MediaPipe 的图像坐标系转换到角色模型空间二是调整灵敏度。下面这段代码展示了如何根据食指指尖坐标让虚拟手做简单瞄准using Newtonsoft.Json.Linq; using UnityEngine; public class UnityChanController : MonoBehaviour { public Transform indexTip; public float positionScale 10f; public Vector3 offset new Vector3(0, 0, 0); public void ApplyHandData(string json) { JObject root JObject.Parse(json); var hands root[hands] as JArray; if (hands null || hands.Count 0) return; var hand0 hands[0] as JArray; // MediaPipe 第 8 个点是食指尖 var tip hand0[8]; float x tip[0].Valuefloat() * positionScale offset.x; float y tip[1].Valuefloat() * positionScale offset.y; float z tip[2].Valuefloat() * positionScale offset.z; indexTip.localPosition Vector3.Lerp(indexTip.localPosition, new Vector3(x, y, z), Time.deltaTime * 12f); } }这里positionScale是把 [0,1] 的归一化坐标放大到 Unity 场景中的单位。由于渲染出来的角色手部大小有限scale 通常取 2~10需要根据场景比例调整。Vector3.Lerp的第三个参数是平滑速度12f表示每秒向目标位置收拢 12 次插值增量。这个值如果太大视觉上会发飘太小会感觉到手部明显滞后。我一般会在 8~15 之间试效果。坐标轴方向也要注意MediaPipe 的 x 是图像右方向y 是图像下方向而 Unity 的 y 通常向上所以接入后要在 x、y 之间做一次翻转否则虚拟手会上下颠倒。3.4 头部跟随与手势触发动画面部数据中的鼻尖点经过旋转估算后可以映射成头部骨骼的 lookAt 方向。常见做法是把鼻尖 x 偏移映射为 Yaw把 y 偏移映射为 Pitch再把 z 偏移映射为角色头的 Roll。让角色头部跟随这个过程要注意把角度限制在一个合理范围内比如 Yaw 限制在正负 30 度Pitch 限制在正负 20 度否则角色看起来会像脖子折断。手势触发动画则使用一个简单规则如果食指尖在手腕的上方超过一定比例就把 Animator 的 Boolean 设为 true使 UnityChan 触发挥手动画。如果你想进一步实现“unity摄像机跟随”也可以把同样的坐标数据应用到 Cinemachine 的 Target Group 上这样摄像头会随着手部位置自动调整视线范围。要留意手部进入画面边界时坐标会突然跳到 0 或 1最好在映射前加一个边界截断。4. UISystem 与数据持久化控制端和存档的工程化处理4.1 UISystem 的状态反馈与参数控制UISystem.cs 主要承担两类功能一类是显示运行状态另一类是让用户调整关键参数并写入 Pref。实际调试时如果看不到“识别中”和“已连接”的状态很难判断到底是 Python 端没检测到人还是 Unity 端没收到包。下面这段 C# 代码演示了 UISystem 如何从 HiyoriController 读取最近一条消息的时间戳并刷新 UI 文本public class UISystem : MonoBehaviour { public Text statusText; private HiyoriController controller; private void Update() { float timeSinceLast controller.TimeSinceLastPacket(); if (timeSinceLast 0.5f) { statusText.text 已连接; statusText.color Color.green; } else { statusText.text 等待数据; statusText.color Color.yellow; } } }TimeSinceLastPacket返回从上次收到 UDP 包到现在的秒数如果超过 0.5 秒就认为数据链路中断或者 Python 端没有输出。这个阈值不要设得太小因为 UDP 本身允许丢包偶尔一次超时并不代表连接断开。真正的断线通常表现为连续 2 秒收不到数据此时 UI 状态切换为红色。UISystem 里还可以做参数的动态调整比如把平滑系数、端口号绑定到 Slider 和 InputField调用方不需要打开代码就能改参数演示的时候特别好用。4.2 Pref 与 SaveDataManager 的配合HiyoriPref.cs 和 UnityChanPref.cs 在工程里通常是两种写法一种是 ScriptableObject另一种是静态配置类。从源码继承性来看更常见的做法是让它们继承一个抽象 Pref 基类里面定义参数列表和序列化方法。FileManager.cs 负责把这些参数写到持久化目录SaveDataManager.cs 则负责控制保存时机和版本号。表 4-1 给出了典型参数存储的字段设计表 4-1 Pref 配置字段示例字段类型默认值说明listenPortint8888UDP 监听端口smoothingfloat12f骨骼插值速度scalefloat5f坐标放大倍数invertYbooltrue是否翻转 Y 轴maxYawfloat30f头部 Yaw 限制saveVersionint1存档版本号SaveDataManager 的保存方法需要处理路径不存在的情况。我的习惯是把数据保存到Application.persistentDataPath而不是项目目录这样打包后也能正常读写。下面这段代码展示了用 FileManager 写 JSON 存档的完整逻辑public class SaveDataManager : MonoBehaviour { private string SavePath Path.Combine(Application.persistentDataPath, hiyori_settings.json); public void SavePref(HiyoriPref pref) { string json JsonUtility.ToJson(pref, true); File.WriteAllText(SavePath, json); } public HiyoriPref LoadPref() { if (!File.Exists(SavePath)) return ScriptableObject.CreateInstanceHiyoriPref(); string json File.ReadAllText(SavePath); HiyoriPref pref ScriptableObject.CreateInstanceHiyoriPref(); JsonUtility.FromJsonOverwrite(json, pref); return pref; } }JsonUtility会忽略没有[SerializeField]的字段所以 Pref 类里的字段都要标记为 public 或加[SerializeField]。每次改完 UI 上的参数调用SavePref后再LoadPref可以看到配置被完整还原。这种方式比 PlayerPrefs 更适合管理多组预设因为它是独立文件可以随工程带出去评审。如果你想把存档同步给 Python 端只需在 SavePref 的最后调用一次 UDP 发送让 Python 也保存一份同样的 JSON。4.3 握手协议让 Unity 准备好后再发数据直接启动 Python 端Unity 端还没有监听前几十帧数据会被丢弃。因为 UDP 没有连接状态所以这些数据不是丢失而是永远不会被处理。为了在演示现场不丢开场动作我一般会加一个简单的握手Unity 启动后向 Python 端的 8889 端口发送一个UnityReady字符串Python 端收到后才开始发送关键点。这样可以确保第一次伸手一定被虚拟人捕获。握手也要加超时重试Unity 端每 2 秒重发一次直到收到Ack。如果 Python 端没做握手逻辑也可以简单在启动后延迟 1 秒再发数据但效果不如显式握手可靠。4.4 延迟优化与丢包应对驱动虚拟人物时最影响体验的是延迟而不是帧数。表 4-2 对比了三种常见通信方式的适用场景表 4-2 通信方式对比方式延迟丢包处理适用场景UDP低直接丢帧实时的关键点驱动TCP中自动重传参数和存档传输WebSocket中依赖底层实现跨平台、WebGL本方案默认使用 UDP是最省事的。丢包时虚拟人会出现短暂卡顿缓解办法是在 Unity 端对关键点做缓冲插值保留前 5 帧数据当前帧没有新数据时用差值补出中间帧。HiyoriController 的 ConcurrentQueue 里就可以维护一个小数组每次取队列中最新一帧时同时参考上一帧和当前帧的时间差用 Lerp 计算出更平滑的位置。这个缓冲长度不能太长否则整体延迟会变大我用 3 帧作为缓冲上限。5. 复现和排错从 Python 环境到 Unity 联调的关键细节5.1 环境版本组合Python 3.10 MediaPipe 0.10.8 是我测试过最稳定的组合。Unity 建议用 2021.3 LTS因为工程里的 C# 脚本没有使用高版本特性LTS 版本在 WebGL 和 Android 上都能编译通过。安装时不要直接pip install mediapipe新版可能拉取到带更多依赖的版本和 OpenCV 的兼容性需要额外检查。建议安装顺序是先装 opencv-python再装 mediapipe最后装 numpy1.26.4。这样能避开 numpy 2.x 与 mediapipe 的编译期冲突。5.2 三个常见的联调报错第一个常见问题是 Python 端报RuntimeError: please use the Python3 interpreter这通常是因为 conda 环境的 Python 版本和 MediaPipe 预编译 wheel 不对应换到 3.10 即可。第二个问题是 Unity 端收不到数据多半是防火墙拦截了 8888 端口或者 Python 端发送地址写成了公网 IP。第三个问题是虚拟人手部抖动原因一是 smoothing 太小二是把 MediaPipe 的 z 坐标直接当深度用没有做平滑。遇到抖动先调 smoothing把 12f 改成 20f 试试。5.3 一个调参技巧让摄像头画面作为调试基准我一般会在 Python 端同时显示带关键点绘制的画面并让 Unity 里的虚拟角色与摄像头画面并排显示。这样一旦虚拟人的手部位置不对可以直接对照绘制的关键点调整坐标映射。如果手部上下颠倒检查 invertY如果左右反了检查 cv2.flip。下面这段脚本是最终的启动命令python run.py --udp_port 8888 --mirror true --show_debug true推荐的调试顺序是先确认 Python 窗口中手部关键点稳定再确认 Unity 的 Debug.Log 有输出最后看虚拟角色是否运动。不要一开始就同时调两个端否则出了 bug 很难定位。用这个顺序十分钟内就能把链路跑通。本文还有配套的精品资源点击获取