简介:这份源码资源面向计算机相关专业学生与项目实战学习者,提供一套基于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 的 Mac,MediaPipe 从 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_mode=False, # 视频流模式,False 会做帧间跟踪,更快 max_num_hands=2, # 最多检测两只手 min_detection_confidence=0.5, # 首次检测的置信度阈值 min_tracking_confidence=0.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_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(): 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_landmarks=True这个参数值得单独说:开启后会在眼睛区域额外输出虹膜关键点,总共 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_memory,Unity 用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_hands=2, min_detection_confidence=0.5) face_mesh = mp_face.FaceMesh(max_num_faces=1, refine_landmarks=True) # 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.FromJson<PoseData>(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,肉眼能感觉到轻微延迟,但做虚拟主播够用了。
想再压延迟,有三个方向。第一,把摄像头分辨率降到 480p,MediaPipe 推理时间能省三分之一,关键点精度损失很小。第二,Python 端把model_complexity参数从默认的 1 降到 0(手部检测支持这个参数),推理快一倍,但手指细节会糙一点。第三,Unity 端把Application.targetFrameRate设成 60,确保渲染不拖后腿。
帧率对齐上,Python 端的识别帧率和 Unity 的渲染帧率不需要一致。UDP 是异步的,Python 发多少 Unity 收多少,Unity 在Update里用最新收到的数据就行。但要注意:如果 Python 端帧率远高于 Unity,latestData会被频繁覆盖,浪费带宽。我一般会在 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 Queue<Vector3>[] history = new Queue<Vector3>[21]; Vector3 Smooth(int index, Vector3 raw) { if (history[index] == null) history[index] = new Queue<Vector3>(); 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_landmarks=True,虹膜关键点会插在原有索引之间,导致后面的索引偏移。
解决:不要硬编码索引,在 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 的真实数据。这样出问题的时候能快速定位是识别端还是驱动端。希望帮到你。
本文还有配套的精品资源,点击获取