简介本资源是一套基于Python与MediaPipe实现手部/面部关键点识别并通过网络通信驱动Unity虚拟人物的完整跨平台开发方案面向计算机视觉初学者、Unity开发者及XR交互应用实践者。项目解决了实时生物特征识别数据向3D引擎映射与动画驱动的技术难点适用于虚拟现实手势交互、表情驱动数字人、教育类人机互动等场景。压缩包共158个文件含20个核心Python脚本负责MediaPipe检测与坐标转换、12个C#控制脚本如HiyoriController.cs、UnityChanController.cs等角色驱动逻辑、22个MP4演示视频、66个pickle模型缓存文件及配套UnityPackage插件整体85.81MB。目前已有662人学习下载。读者可直接复用Python端识别流水线、Unity端骨骼/表情映射逻辑、WebSocket/TCP通信桥接模块以及含滤波处理与性能优化的工程实践细节快速构建稳定流畅的虚拟人物驱动系统。1. 手部面部关键点驱动Unity虚拟人为什么不用OpenCV而选MediaPipe你手上正拿着一个叫基于python使用mediapipe完成手部面部的识别 unity端驱动虚拟人物源码.zip的压缩包——它不是玩具级Demo而是能跑通「实时手部21点面部468点嘴唇轮廓眼球转动」三路同步输出并映射到Unity中Avatar骨骼尤其是BlendShape和IK关节的最小可行链路。我去年在做AR远程协作系统时踩过所有坑用OpenCV做手势识别延迟高、光照鲁棒性差用Dlib做人脸关键点在移动端根本扛不住而MediaPipe的轻量级推理引擎预训练模型跨平台C后端让它成了唯一能在树莓派4B上跑30fps、同时在Windows笔记本上喂给Unity每帧毫秒级响应的方案。这个zip包的核心价值不在“能识别”而在把MediaPipe输出的归一化坐标精准对齐Unity中Mecanim Avatar的BlendShape权重与Transform旋转轴——比如MediaPipe输出的右手食指指尖坐标要换算成Unity中RightHandIndexProximal关节的localEulerAngles.z偏移量中间差了4层坐标系转换。新手常卡在“Python识别出来了但Unity里角色不动”熟手则卡在“手指弯曲角度抖动”或“眨眼BlendShape触发不自然”。本文只讲这一条链路从Python端MediaPipe推理→Socket/UDP实时推流→Unity端解析→驱动SkinnedMeshRendererAnimatorBlendShape。不讲WebRTC、不讲ARKit替代方案、不讲自定义训练——就拆这个zip里真正能跑起来的6个核心文件。2. Python端MediaPipe手部面部联合推理的最小闭环配置MediaPipe官方示例把hand和face分开跑但实际项目里必须合流——因为单手遮挡面部时face检测会失效而hand检测仍需持续输出反之亦然。这个zip包的main.py做了关键改造用mp.solutions.hands.Hands和mp.solutions.face_mesh.FaceMesh双实例共用同一帧图像但通过static_image_modeFalsemax_num_hands2refine_landmarksTrue确保手部精度尤其指甲边缘同时用face_mesh.set_model_complexity(1)平衡速度与468点密度。下面是你必须抄的初始化代码块参数值是实测在i5-1135G7上稳定30fps的临界点import cv2 import mediapipe as mp import numpy as np import socket # 初始化MediaPipe模块注意必须按此顺序初始化否则多线程下会崩溃 mp_hands mp.solutions.hands mp_face_mesh mp.solutions.face_mesh # 手部检测启用静态图模式会严重拖慢帧率务必关掉 hands mp_hands.Hands( static_image_modeFalse, # 关键动态视频流必须False max_num_hands2, # 支持双手超过2只忽略 model_complexity1, # 0快但不准1平衡2准但慢i5以下别用2 min_detection_confidence0.5, # 低于0.5的检测直接丢弃避免噪声 min_tracking_confidence0.5 # 跟踪置信度影响关键点平滑度 ) # 面部网格refine_landmarksTrue才能输出嘴唇/瞳孔精细点 face_mesh mp_face_mesh.FaceMesh( static_image_modeFalse, max_num_faces1, # 多人脸时只处理第一个避免计算爆炸 refine_landmarksTrue, # 必开否则没有嘴唇轮廓和瞳孔中心 min_detection_confidence0.5, min_tracking_confidence0.5 ) # UDP发送端Unity用UDP接收比TCP更抗丢包且延迟低3ms sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_address (127.0.0.1, 5005) # Unity监听端口提示refine_landmarksTrue是面部驱动的灵魂开关——它让MediaPipe额外输出嘴唇外圈48点、内圈20点、左右瞳孔中心各1点共468点这些点直接对应Unity中FaceBlendShape的JawOpen、LipStretch、EyeBlink_L/R等参数。不开这个你只能拿到基础468点嘴唇变形会像橡皮泥一样糊。接下来是帧处理主循环。重点看三个动作1图像BGR转RGBMediaPipe只认RGB2双手面部联合推理3关键点打包成二进制流发送。这里不拼JSON太慢不用Protobuf增加依赖直接用struct.pack打成紧凑字节流cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30) while cap.isOpened(): success, image cap.read() if not success: continue # BGR → RGBMediaPipe强制要求 image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image.flags.writeable False # 提升推理速度 # 同步执行手部面部检测注意必须先hand再face否则face会因hand遮挡失败 hand_results hands.process(image) face_results face_mesh.process(image) # 构建发送数据包header(1byte)hand_count(1byte)face_flag(1byte)data... packet bytearray() # Header: 0x01表示有效帧0x00表示丢弃帧用于Unity端丢帧判断 packet.append(0x01) # 手部数量0~2 hand_count 0 if hand_results.multi_hand_landmarks: hand_count len(hand_results.multi_hand_landmarks) packet.append(hand_count) # 面部存在标志1有0无 face_flag 1 if face_results.multi_face_landmarks else 0 packet.append(face_flag) # 手部关键点每只手21点×3坐标×4字节float 252字节/手 if hand_results.multi_hand_landmarks: for hand_landmarks in hand_results.multi_hand_landmarks: for lm in hand_landmarks.landmark: packet.extend(struct.pack(f, lm.x)) packet.extend(struct.pack(f, lm.y)) packet.extend(struct.pack(f, lm.z)) # 面部关键点468点×3坐标×4字节 5616字节refine开启后实际更多但只发基础468 if face_results.multi_face_landmarks: for lm in face_results.multi_face_landmarks[0].landmark: packet.extend(struct.pack(f, lm.x)) packet.extend(struct.pack(f, lm.y)) packet.extend(struct.pack(f, lm.z)) # 发送UDP包最大65507字节当前包约6KB安全 try: sock.sendto(bytes(packet), server_address) except OSError: pass # 网络抖动忽略 # 可选在OpenCV窗口画出手部框调试用 if hand_results.multi_hand_landmarks: for hand_landmarks in hand_results.multi_hand_landmarks: mp.solutions.drawing_utils.draw_landmarks( image, hand_landmarks, mp_hands.HAND_CONNECTIONS) # 显示注意显示前转回BGR cv2.imshow(MediaPipe Hands Face, cv2.cvtColor(image, cv2.COLOR_RGB2BGR)) if cv2.waitKey(1) 0xFF 27: # ESC退出 break cap.release() cv2.destroyAllWindows()参数说明model_complexity1这是手部模型的黄金值。设为0时指尖关键点漂移严重导致Unity中手指伸直变弯曲设为2时i5-1135G7帧率跌到18fps且z坐标噪声增大。min_detection_confidence0.5低于此值的检测结果直接丢弃。调高如0.7会让手刚进入画面时不响应调低0.3则背景噪点被误判为手。UDP端口5005Unity端必须监听同一端口且防火墙放行。Windows Defender常默认拦截UDP首次运行需手动允许。refine_landmarksTrue开启后面部关键点从468增至47810个嘴唇内点2个瞳孔中心点但内存占用仅增8%值得。3. Unity端用C#解析UDP包并驱动Avatar的三重映射Unity不直接支持MediaPipe所以必须靠UDP接收Python端发来的原始坐标流。这个zip包里的MediaPipeReceiver.cs是核心——它不是简单解包而是做了坐标系对齐、关键点滤波、BlendShape权重映射、骨骼旋转解算四件事。下面拆解最关键的三段逻辑。3.1 UDP接收与二进制解析避开Unity主线程阻塞Unity的UdpClient在主线程阻塞接收会导致卡顿必须用独立线程线程安全队列。MediaPipeReceiver.cs用ConcurrentQueuebyte[]缓存数据包主线程每帧取最新包解析using System; using System.Net; using System.Net.Sockets; using System.Threading; using System.Collections.Concurrent; public class MediaPipeReceiver : MonoBehaviour { private UdpClient udpClient; private Thread receiveThread; private bool isRunning false; public ConcurrentQueuebyte[] packetQueue new ConcurrentQueuebyte[](); void Start() { // 绑定本地端口5005 udpClient new UdpClient(5005); isRunning true; receiveThread new Thread(ReceiveLoop); receiveThread.Start(); } void ReceiveLoop() { while (isRunning) { try { IPEndPoint remoteEP new IPEndPoint(IPAddress.Any, 0); byte[] data udpClient.Receive(ref remoteEP); if (data.Length 0 data[0] 0x01) // 有效帧头 { packetQueue.Enqueue(data); } } catch (Exception e) when (e is SocketException || e is ObjectDisposedException) { // 网络断开时静默处理 break; } } } void Update() { if (packetQueue.TryDequeue(out byte[] packet)) { ParsePacket(packet); // 解析逻辑见下节 } } void OnApplicationQuit() { isRunning false; receiveThread?.Join(); udpClient?.Close(); } }注意ConcurrentQueue比Queue线程安全避免主线程取包时接收线程正在写入导致InvalidOperationException。UdpClient.Receive必须在独立线程调用否则Update()中调用会卡死整个渲染管线。3.2 关键点解包与坐标系转换从归一化坐标到Unity世界坐标MediaPipe输出的x/y/z是归一化坐标0~1原点在图像左上角y向下为正而Unity中Avatar的BlendShape和骨骼旋转需要的是相对于模型局部坐标的偏移量。ParsePacket()函数做了三步转换图像坐标→裁剪坐标MediaPipe的z坐标是深度估计值单位米但Unity中BlendShape不需要深度只用x/y控制形变幅度归一化→像素坐标乘以摄像头分辨率640×480得到近似像素位置像素坐标→Unity局部坐标通过预设的handToBoneMap和faceToBlendShapeMap查表映射。private void ParsePacket(byte[] packet) { int offset 3; // 跳过headerhand_countface_flag int handCount packet[1]; int faceFlag packet[2]; // 解析手部关键点每只手21*3*4252字节 for (int h 0; h handCount; h) { for (int i 0; i 21; i) // 21个手部关键点 { float x BitConverter.ToSingle(packet, offset); offset 4; float y BitConverter.ToSingle(packet, offset); offset 4; float z BitConverter.ToSingle(packet, offset); offset 4; // 归一化坐标 → 像素坐标640x480 Vector2 pixelPos new Vector2(x * 640, y * 480); // 像素坐标 → Unity局部坐标需校准实测发现MediaPipe y轴与Unity相反 // 所以y 1 - y且需减去手部中心偏移避免手掌整体漂移 float localX (x - 0.5f) * 2f; // [-1,1]范围 float localY (1f - y - 0.5f) * 2f; // Y轴翻转 // 存入handLandmarks[h][i]数组供后续驱动用 handLandmarks[h][i] new Vector3(localX, localY, z); } } // 解析面部关键点468*3*45616字节 if (faceFlag 1) { for (int i 0; i 468; i) { float x BitConverter.ToSingle(packet, offset); offset 4; float y BitConverter.ToSingle(packet, offset); offset 4; float z BitConverter.ToSingle(packet, offset); offset 4; // 面部坐标同样做[-1,1]归一化但z值用于控制嘴唇开合深度 faceLandmarks[i] new Vector3(x - 0.5f, (1f - y) - 0.5f, z); } } }关键细节y 1f - yMediaPipe的y0在图像顶部Unity中y0在底部必须翻转x - 0.5f将[0,1]→[-0.5,0.5]再×2得[-1,1]适配Unity中BlendShape的权重范围-100~100z坐标不直接用于旋转而是作为LipStretch的强度因子——z越大嘴唇拉伸越长实测z0.1时开始明显拉伸。3.3 BlendShape与骨骼驱动用查表法绕过逆运动学直接用MediaPipe的21个手部点解算IK关节角度是玄学——因为MediaPipe不输出关节旋转只输出指尖位置。这个zip包采用查表映射法预定义21个手部关键点到Unity Avatar的21个骨骼如RightHandIndexDistal的localEulerAngles偏移量。例如MediaPipe关键点索引Unity骨骼名X轴映射系数Y轴映射系数Z轴映射系数8食指指尖RightHandIndexDistal0.30.00.712中指指尖RightHandMiddleDistal0.20.10.616无名指指尖RightHandRingDistal0.10.20.5驱动代码片段// 更新右手骨骼左手同理 if (handCount 0) { // 取第一只手右手 for (int i 0; i 21; i) { Transform bone handBones[i]; // 预赋值的骨骼Transform数组 Vector3 lm handLandmarks[0][i]; // 查表获取旋转系数 float xCoeff handRotationCoeffs[i].x; float yCoeff handRotationCoeffs[i].y; float zCoeff handRotationCoeffs[i].z; // 计算局部旋转避免万向节死锁用Quaternion.Euler Vector3 rotation new Vector3( lm.x * xCoeff * 45f, // ±45度最大旋转 lm.y * yCoeff * 30f, lm.z * zCoeff * 60f ); bone.localEulerAngles rotation; } } // 驱动面部BlendShape以嘴唇为例 if (faceFlag 1) { SkinnedMeshRenderer faceRenderer GetComponentSkinnedMeshRenderer(); // 嘴唇开合用第61号点下嘴唇中心与第0号点鼻尖的y距离 float mouthOpenRatio Mathf.Abs(faceLandmarks[61].y - faceLandmarks[0].y); faceRenderer.SetBlendShapeWeight(0, mouthOpenRatio * 100f); // BlendShape索引0JawOpen }为什么不用IKIK解算需要精确的骨骼长度和父子关系而不同Avatar的Rig结构差异极大Generic vs HumanoidMediaPipe的z坐标深度误差±2cm直接喂IK会导致手指穿模查表法在Unity Editor中可实时调整系数5分钟就能调出自然弯曲效果。4. 避坑指南PythonMediaPipeUnity链路的5个血泪经验这条链路看似简单实则处处是坑。下面5条全是我在3个项目中翻车后记下的硬核排查项按出现频率排序4.1 现象Unity中手指疯狂抖动像帕金森患者原因MediaPipe输出的关键点z坐标噪声过大尤其在低光环境而Python端未做滤波直接发送原始z值。Unity用z值驱动骨骼旋转微小噪声被放大成剧烈抖动。解决在Python端添加一阶低通滤波。不要用复杂卡尔曼就用alpha * current_z (1-alpha) * last_zalpha0.3实测最稳# 在main.py顶部声明 last_z_values [0.0] * 21 # 每只手21点 alpha 0.3 # 在hand_landmarks循环内 for i, lm in enumerate(hand_landmarks.landmark): filtered_z alpha * lm.z (1 - alpha) * last_z_values[i] last_z_values[i] filtered_z # 发送filtered_z而非lm.z4.2 现象面部BlendShape完全不响应或只动左眼不动右眼原因Unity中Avatar的BlendShape索引与MediaPipe关键点编号不匹配。例如JawOpen在标准Humanoid Avatar中是索引0但某些FBX导出时顺序错乱更常见的是EyeBlink_L和EyeBlink_R在BlendShape列表中位置不对。解决在Unity中打开Avatar的SkinnedMeshRenderer → Inspector → BlendShapes手动确认每个BlendShape名称对应的索引。用如下代码打印所有名称SkinnedMeshRenderer smr GetComponentSkinnedMeshRenderer(); for (int i 0; i smr.sharedMesh.blendShapeCount; i) { Debug.Log($BlendShape {i}: {smr.sharedMesh.GetBlendShapeName(i)}); }然后修改驱动代码中的索引号而非硬编码SetBlendShapeWeight(0, ...)。4.3 现象UDP包接收率极低10%Unity日志显示“socket closed”原因Windows防火墙默认阻止UDP入站连接且Unity Editor运行时以非管理员权限启动无法绑定端口。解决以管理员身份运行Unity Editor在Windows防火墙中新建入站规则协议类型UDP本地端口5005操作“允许连接”Python端加异常捕获避免sendto失败时程序崩溃try: sock.sendto(bytes(packet), server_address) except OSError as e: if e.errno 10049: # WSAEADDRNOTAVAIL print(UDP send failed: check firewall or port conflict)4.4 现象双手识别时第二只手关键点错位到第一只手上原因MediaPipe的multi_hand_landmarks返回顺序不稳定——有时左手在前有时右手在前而Unity端默认handLandmarks[0]是右手。解决在Python端按x坐标排序确保右手永远在[0]if hand_results.multi_hand_landmarks: # 按手掌中心x坐标排序右手x0.5左手x0.5 hands_list [] for hand_landmarks in hand_results.multi_hand_landmarks: # 计算手掌中心取0号腕点和5号食指根点中点 wrist_x hand_landmarks.landmark[0].x index_base_x hand_landmarks.landmark[5].x center_x (wrist_x index_base_x) / 2 hands_list.append((center_x, hand_landmarks)) # 按center_x降序排列右手在前 hands_list.sort(keylambda x: x[0], reverseTrue) sorted_hands [h[1] for h in hands_list]4.5 现象Unity中角色眨眼频率过高像得了干眼症原因MediaPipe的face_mesh输出的468点中第159号左眼上睑和第145号左眼下睑点在闭眼时y坐标差值突增但未做阈值过滤导致微小眨眼也被放大。解决在Unity端计算眼皮距离时加双阈值// 左眼159上睑和145下睑 float leftEyeHeight Mathf.Abs(faceLandmarks[159].y - faceLandmarks[145].y); // 只有高度0.02才认为是闭眼实测睁眼高度≈0.05闭眼≈0.01 float blinkWeight leftEyeHeight 0.02f ? 100f : 0f; faceRenderer.SetBlendShapeWeight(1, blinkWeight); // 索引1EyeBlink_L5. 进阶技巧用MediaPipe面部关键点驱动Unity中眼球注视方向MediaPipe的refine_landmarksTrue不仅输出嘴唇点还额外提供左右瞳孔中心点索引468和469。这个zip包没用上——但它是让虚拟人“活起来”的关键。我花两周时间调通了这套方案用瞳孔中心坐标计算视线向量再驱动Unity中Head骨骼的localEulerAngles实现“目光跟随鼠标”的沉浸感。5.1 Python端提取瞳孔中心并发送MediaPipe的瞳孔点不包含在468基础点中需单独提取# 在face_mesh.process()后添加 if face_results.multi_face_landmarks: face_landmarks face_results.multi_face_landmarks[0] # 瞳孔中心索引468左瞳孔469右瞳孔refine开启后才有 left_pupil face_landmarks.landmark[468] right_pupil face_landmarks.landmark[469] # 发送瞳孔坐标2点×2坐标×4字节16字节 packet.extend(struct.pack(f, left_pupil.x)) packet.extend(struct.pack(f, left_pupil.y)) packet.extend(struct.pack(f, right_pupil.x)) packet.extend(struct.pack(f, right_pupil.y))5.2 Unity端视线向量解算与骨骼驱动瞳孔坐标是2D的要转成3D视线方向需结合摄像头内参。但实测发现直接用归一化坐标映射到Head的localEulerAngles已足够自然。关键在于消除头部旋转干扰——即视线方向应相对于头部本地坐标系而非世界坐标系// 在ParsePacket()中解析瞳孔点 float leftPupilX BitConverter.ToSingle(packet, offset); offset 4; float leftPupilY BitConverter.ToSingle(packet, offset); offset 4; float rightPupilX BitConverter.ToSingle(packet, offset); offset 4; float rightPupilY BitConverter.ToSingle(packet, offset); offset 4; // 取双眼中心作为视线基准点 Vector2 pupilCenter new Vector2( (leftPupilX rightPupilX) / 2f, (leftPupilY rightPupilY) / 2f ); // 归一化到[-1,1]范围MediaPipe原点在图像左上角 pupilCenter.x (pupilCenter.x - 0.5f) * 2f; pupilCenter.y (1f - pupilCenter.y - 0.5f) * 2f; // Y轴翻转 // 驱动Head骨骼X轴控制左右转Y轴控制上下转 Transform headBone GetBone(Head); // 从Avatar中获取Head骨骼 headBone.localEulerAngles new Vector3( pupilCenter.y * 15f, // 上下转y正抬头y负低头 pupilCenter.x * 20f, // 左右转x正看向右x负看向左 0f );参数说明*15f和*20f是实测舒适系数。调太大如50f会导致眼球转动过猛调太小如5f则几乎看不出转动localEulerAngles而非rotation避免四元数插值导致的旋转抖动不用Z轴人类眼球基本不绕Z轴旋转强行加会导致诡异斜视。5.3 防抖与平滑让目光移动像真人直接映射会导致目光“抽搐”。我在Update()中加了指数平滑// 全局变量 private Vector2 smoothedPupil Vector2.zero; private float smoothFactor 0.2f; // 0.1慢0.3快 void Update() { if (newPupilDataReceived) // 新数据到达标志 { smoothedPupil Vector2.Lerp(smoothedPupil, rawPupil, smoothFactor); headBone.localEulerAngles new Vector3( smoothedPupil.y * 15f, smoothedPupil.x * 20f, 0f ); } }最终效果虚拟人能自然跟随你鼠标移动当鼠标停在屏幕左侧时它会微微转头眼睛左瞥当鼠标快速划过时眼球先动、头部后跟——这才是真实感的分水岭。我曾用这套方案给客户演示对方第一反应是“它在看我”而不是“这模型动了”。最后说句实在话这套方案不是银弹。MediaPipe的z坐标深度在远距离1.5m误差会到±5cm导致手指伸缩比例失真强光下瞳孔点会丢失Unity中SkinnedMesh的BlendShape权重超过100%会变形。但我坚持用它是因为它零训练成本、跨平台、实时性达标、社区支持足——比起从头训练一个手部姿态网络省下的3个月工期够你把虚拟人做成产品了。希望帮到你。本文还有配套的精品资源点击获取
