ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity与MediaPipe手部追踪整合:从环境搭建到手势识别实战

Unity与MediaPipe手部追踪整合:从环境搭建到手势识别实战 简介这是一套基于Unity引擎与MediaPipe框架构建的手部追踪与手势识别工程源码面向VR/AR交互、手势控制游戏及自然用户界面方向的开发者尤其适合需要快速集成手势交互能力的中高级Unity使用者。资源包共1014个文件约210.87MB以364个cs脚本、526个meta文件、30个prefab预制体、24个asset配置及若干dll、aar、onnx模型文件为主涵盖运行时逻辑、场景预制体、Android原生库与手部关键点推理模型结构完整可直接导入Unity工程。系统支持拳头、点赞、胜利等多种常见手势识别并内置完整事件机制处理手势状态变化开发者可据此触发自定义交互逻辑。目前已有286人学习下载。借助该工程读者可省去从零搭建MediaPipe接入与手势判定流程的成本直接参考其模块划分、事件回调设计与跨平台配置方式快速落地手势控制原型。1. 从零搭一套 Unity MediaPipe 手部追踪为什么我放弃了纯 Unity 方案去年接了个桌面端体感交互的活需求很直白摄像头对着操作员手一挥就触发 UI捏合就拖拽模型。第一反应是在 Unity 里找现成插件试了两天要么识别率在逆光下崩到没法看要么延迟高到挥手动作慢半拍。后来换成 Unity 负责渲染和交互、MediaPipe 负责手部关键点推理中间用本地进程通信把数据喂进来整套跑通之后延迟压到了 30ms 以内逆光场景也没再翻车。这套方案的核心思路就一句话别让 Unity 干它不擅长的事。Unity 的强项是场景、渲染、物理和交互逻辑而手部追踪这种逐帧跑神经网络的活交给 MediaPipe 的 Python 端或者 C 原生库更稳。你要做的是把 MediaPipe 输出的 21 个手部关键点坐标通过一条可靠的管道送进 Unity再在 C# 里做手势判定和事件分发。适合谁看有 Unity 基础、想快速把手势识别接进项目的开发者做过 MediaPipe 但不知道怎么和引擎联调的算法同学以及被各种“Unity 手势插件”坑过、想自己掌控整条链路的人。下面从环境搭建讲到手势判定再到踩过的坑每一步都给出可复现的命令和代码。2. 环境搭建Python 端 MediaPipe 与 Unity 端的版本对齐2.1 为什么 MediaPipe 版本和 Python 版本必须锁死MediaPipe 的 Python 包对 Python 版本有硬性要求而 Unity 端用的通信库又对 .NET 版本有要求两头一夹能选的组合其实不多。我一般会先确定 MediaPipe 的版本再倒推 Python 版本最后选 Unity 的 LTS 版本。截至我最近一次搭建稳定组合是Python 3.9 或 3.10、MediaPipe 0.10.x、Unity 2022.3 LTS。Python 3.11 以上在部分 MediaPipe 版本上会出现AttributeError: module mediapipe has no attribute solutions这是编译 wheel 时的兼容问题不是代码写错了。Unity 这边 2021 及更早版本在System.Net.Sockets的异步接口上有差异用 2022 LTS 最省心。安装 MediaPipe 的命令很直接# 创建独立虚拟环境避免和系统 Python 冲突 python -m venv mp_env # Windows 激活 mp_env\Scripts\activate # macOS / Linux 激活 source mp_env/bin/activate # 安装指定版本不要用 latest pip install mediapipe0.10.9 pip install opencv-python4.8.1.78这里锁mediapipe0.10.9是因为 0.10.10 之后的版本在 Windows 上对protobuf的依赖有变动容易和 Unity 端用的 gRPC 库冲突。opencv-python锁 4.8.1.78 是为了避免 4.9 之后cv2.imshow在某些摄像头驱动上的花屏问题。验证安装是否成功跑这段最小代码import mediapipe as mp import cv2 mp_hands mp.solutions.hands # static_image_modeFalse 表示走视频流模式会做帧间追踪 # max_num_hands2 最多检测两只手 # min_detection_confidence0.5 检测置信度阈值 # min_tracking_confidence0.5 追踪置信度阈值 hands mp_hands.Hands( static_image_modeFalse, max_num_hands2, min_detection_confidence0.5, min_tracking_confidence0.5 ) cap cv2.VideoCapture(0) 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_landmarks in results.multi_hand_landmarks: # 这里先只打印手腕点的归一化坐标 print(hand_landmarks.landmark[0].x, hand_landmarks.landmark[0].y) cv2.imshow(MediaPipe Hands, frame) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()static_image_modeFalse是关键它让 MediaPipe 在视频流里复用上一帧的追踪结果速度比逐帧检测快 3 到 5 倍。min_detection_confidence和min_tracking_confidence这两个参数后面还会细调先记住它们控制的是“多确信才算一只手”。2.2 Unity 端工程配置与通信库选型Unity 这边不需要装任何 MediaPipe 相关的包你要做的是建一个能收数据的 TCP 或 UDP 客户端。选 TCP 还是 UDP取决于你对丢帧的容忍度TCP 保证顺序但会阻塞UDP 快但可能丢包。手部追踪这种场景我推荐TCP 长度前缀因为丢一帧关键点会导致手势判定跳变比延迟几毫秒更难受。新建 Unity 工程后在Assets下建Scripts文件夹然后创建一个HandReceiver.cs。通信层用System.Net.Sockets就够了不需要引入第三方库。工程设置里把Api Compatibility Level设为.NET Standard 2.1Scripting Backend用Mono即可IL2CPP 在调试阶段编译太慢。using System; using System.Net.Sockets; using System.Threading; using UnityEngine; public class HandReceiver : MonoBehaviour { private TcpClient client; private NetworkStream stream; private Thread receiveThread; private byte[] buffer new byte[1024]; // 对外暴露的最新手部数据主线程读取 public volatile string latestData; void Start() { client new TcpClient(); // 连接 Python 端监听的地址和端口 client.Connect(127.0.0.1, 5065); stream client.GetStream(); receiveThread new Thread(ReceiveLoop); receiveThread.IsBackground true; receiveThread.Start(); } private void ReceiveLoop() { while (true) { try { // 先读 4 字节长度头再读实际数据 byte[] lenBuf new byte[4]; int read 0; while (read 4) { int r stream.Read(lenBuf, read, 4 - read); if (r 0) return; read r; } int len BitConverter.ToInt32(lenBuf, 0); byte[] data new byte[len]; read 0; while (read len) { int r stream.Read(data, read, len - read); if (r 0) return; read r; } latestData System.Text.Encoding.UTF8.GetString(data); } catch (Exception e) { Debug.LogError(Receive error: e.Message); break; } } } void OnDestroy() { receiveThread?.Interrupt(); stream?.Close(); client?.Close(); } }这段代码里volatile关键字保证主线程能读到子线程写入的最新值BitConverter.ToInt32处理长度头时要注意字节序Python 端用struct.pack(I, len)小端打包才能对上。OnDestroy里必须关线程和流否则 Unity 编辑器停止播放时会卡住。3. 数据管道把 21 个关键点从 Python 送进 Unity3.1 关键点坐标的归一化与序列化格式MediaPipe 输出的landmark是归一化坐标x和y范围在 0 到 1 之间z是相对深度单位大致和x同量级。直接把这些浮点数转成 JSON 再发体积不大但解析慢用二进制打包更省 CPU但调试时看不到内容。我一般调试阶段用 JSON性能稳定后换二进制。21 个关键点的索引是固定的0 是手腕1 到 4 是拇指5 到 8 是食指9 到 12 是中指13 到 16 是无名指17 到 20 是小指。每个点有x, y, z三个 float一共 63 个 float。JSON 格式大概长这样{handedness:Right,landmarks:[{x:0.52,y:0.61,z:-0.02}, ...]}Python 端发送的代码import socket import struct import json import mediapipe as mp import cv2 mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands2, min_detection_confidence0.5, min_tracking_confidence0.5 ) # 作为 TCP 服务端监听等 Unity 来连 server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 5065)) server.listen(1) print(等待 Unity 连接...) conn, addr server.accept() print(已连接:, addr) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) payload {handedness: None, landmarks: []} if results.multi_hand_landmarks: hand results.multi_hand_landmarks[0] # 取第一只手的左右手标签 if results.multi_handedness: payload[handedness] results.multi_handedness[0].classification[0].label payload[landmarks] [ {x: lm.x, y: lm.y, z: lm.z} for lm in hand.landmark ] data json.dumps(payload).encode(utf-8) # 小端打包长度头和 C# 端 BitConverter 对应 header struct.pack(I, len(data)) try: conn.sendall(header data) except (BrokenPipeError, ConnectionResetError): print(Unity 端断开) break cap.release() conn.close() server.close()SO_REUSEADDR是为了避免上次异常退出后端口被占用。sendall保证数据全部发出但要注意如果 Unity 端读取慢Python 端的sendall会阻塞导致摄像头帧率下降。解决办法是加一个发送队列或者把min_tracking_confidence调高减少无效帧。3.2 Unity 端解析与坐标系转换Unity 的屏幕坐标系原点在左下角MediaPipe 的归一化坐标原点在左上角y轴方向相反。所以拿到y之后要用1 - y翻转。另外 MediaPipe 的x是相对图像宽度的比例Unity 里如果要做屏幕空间映射得乘以Screen.width。在HandReceiver基础上加一个解析类using System; using UnityEngine; [Serializable] public class Landmark { public float x; public float y; public float z; } [Serializable] public class HandPayload { public string handedness; public Landmark[] landmarks; } public class HandParser : MonoBehaviour { public HandReceiver receiver; // 平滑系数越小越平滑但延迟越大 public float smoothFactor 0.5f; private Vector3[] smoothedPoints new Vector3[21]; void Update() { if (string.IsNullOrEmpty(receiver.latestData)) return; HandPayload payload JsonUtility.FromJsonHandPayload(receiver.latestData); if (payload.landmarks null || payload.landmarks.Length 21) return; for (int i 0; i 21; i) { // y 轴翻转z 轴保持 Vector3 raw new Vector3( payload.landmarks[i].x, 1f - payload.landmarks[i].y, payload.landmarks[i].z ); // 指数平滑抑制抖动 smoothedPoints[i] Vector3.Lerp(smoothedPoints[i], raw, smoothFactor); } // 后续手势判定用 smoothedPoints } }JsonUtility.FromJson要求类字段是 public 且可序列化[Serializable]不能漏。smoothFactor设 0.5 时新数据权重一半抖动明显减小但快速挥手会有拖影设 0.8 响应快但手指会抖。我一般根据实际摄像头帧率调30fps 用 0.6 左右比较平衡。4. 手势识别从关键点几何关系到判定逻辑4.1 用向量夹角判断手指伸展与捏合拿到 21 个点之后手势判定本质上是几何计算。最常用的两个手势是“张开手掌”和“捏合”前者用于触发后者用于拖拽。判断手指是否伸展看的是指尖到手腕的距离和指根到手腕的距离之比判断捏合看的是拇指尖和食指尖的距离。// 计算两个关键点之间的欧氏距离 float Dist(Vector3 a, Vector3 b) { return Vector3.Distance(a, b); } // 判断食指是否伸展指尖(8)到手腕(0)距离 指根(5)到手腕距离 * 1.5 bool IsIndexExtended(Vector3[] pts) { float tipToWrist Dist(pts[8], pts[0]); float mcpToWrist Dist(pts[5], pts[0]); return tipToWrist mcpToWrist * 1.5f; } // 判断捏合拇指尖(4)和食指尖(8)距离小于阈值 bool IsPinching(Vector3[] pts, float threshold 0.05f) { return Dist(pts[4], pts[8]) threshold; }1.5f这个系数不是固定的手大手小会有差异。更稳的做法是用手掌宽度做归一化先算手腕(0)到中指根(9)的距离作为参考长度所有距离都除以这个值。这样不同人、不同距离下阈值都能自适应。float PalmSize(Vector3[] pts) { return Dist(pts[0], pts[9]); } bool IsPinchingNormalized(Vector3[] pts, float ratio 0.3f) { float palm PalmSize(pts); if (palm 0.001f) return false; return Dist(pts[4], pts[8]) / palm ratio; }ratio设 0.3 时捏合判定比较宽松适合快速交互设 0.2 更严格适合需要精确控制的场景。这个值我调了大概二十次才找到适合自己项目的点建议在运行时用Debug.Log把比值打出来观察实际捏合时的数值范围。4.2 手势状态机与防抖避免一帧误判触发事件逐帧判定最大的问题是抖动手指在阈值附近来回跳会导致事件反复触发。解决办法是加一个状态机要求连续 N 帧满足条件才切换状态并且加冷却时间。public enum GestureState { Idle, Pinching, Open } public class GestureDetector : MonoBehaviour { public HandParser parser; public GestureState currentState GestureState.Idle; // 连续满足帧数阈值 public int stableFrames 3; // 冷却时间秒 public float cooldown 0.2f; private int pinchCount 0; private int openCount 0; private float lastTriggerTime 0f; void Update() { Vector3[] pts parser.GetSmoothedPoints(); if (pts null) return; bool pinching IsPinchingNormalized(pts); bool open IsIndexExtended(pts) !pinching; if (pinching) { pinchCount; openCount 0; } else if (open) { openCount; pinchCount 0; } else { pinchCount 0; openCount 0; } if (Time.time - lastTriggerTime cooldown) return; if (pinchCount stableFrames currentState ! GestureState.Pinching) { currentState GestureState.Pinching; lastTriggerTime Time.time; OnPinchStart(); } else if (openCount stableFrames currentState ! GestureState.Open) { currentState GestureState.Open; lastTriggerTime Time.time; OnOpen(); } } void OnPinchStart() { /* 触发拖拽开始 */ } void OnOpen() { /* 触发释放或点击 */ } }stableFrames设 3 在 30fps 下相当于 100ms 确认时间既能滤掉抖动又不会让人觉得迟钝。cooldown防止同一个手势在短时间内重复触发设 0.2 秒比较合适。如果项目对响应速度要求极高可以把stableFrames降到 2但误触发率会上升需要自己权衡。5. 避坑与排查手部追踪落地时最容易翻车的五个点5.1 摄像头帧率和 MediaPipe 推理速度不匹配现象Unity 端收到的数据一卡一卡手势判定时好时坏摄像头画面本身流畅但关键点更新慢。原因MediaPipe 的hands.process()是同步阻塞的如果摄像头采集 30fps 但推理一帧要 50ms实际处理帧率只有 20fps中间丢掉的帧不会补回来。解决把摄像头采集分辨率降到 640x480MediaPipe 在这个分辨率下推理速度最快。如果还不够把model_complexity设为 0默认是 1精度略降但速度提升明显。另外不要在 Python 端做cv2.imshow显示窗口会额外消耗 5 到 10ms调试完就关掉。5.2 Unity 编辑器停止播放后端口被占用现象第二次运行时报SocketException: Address already in use必须重启 Unity 才能恢复。原因OnDestroy里虽然关了TcpClient但 Python 端的serversocket 没有正确释放TIME_WAIT状态会持续几十秒。解决Python 端 bind 之前设SO_REUSEADDR并且用try/finally确保server.close()一定执行。Unity 端在OnApplicationQuit里也加一次关闭逻辑双保险。5.3 左右手标签在镜像摄像头下反了现象明明举的是右手handedness返回Left。原因MediaPipe 假设输入图像是镜像过的自拍视角如果摄像头没做水平翻转左右手判定就会反。解决在 Python 端cv2.flip(frame, 1)做水平翻转后再送进 MediaPipe这样标签就对了。或者不改图像在 Unity 端把标签反过来用但推荐前者因为 MediaPipe 的追踪模型也是按镜像视角训练的。5.4 归一化坐标直接当世界坐标用导致位置偏移现象手在画面中间但 Unity 里映射出来的点偏到角落。原因MediaPipe 的x, y是相对图像宽高的比例而 Unity 的屏幕坐标是像素单位两者之间差了一个Screen.width和Screen.height的乘法。解决如果要做屏幕空间映射用new Vector3(x * Screen.width, (1 - y) * Screen.height, 0)。如果要映射到 3D 世界空间得用Camera.ScreenToWorldPoint并且注意z值要设成相机到交互平面的距离不能直接用 MediaPipe 的z。5.5 多只手同时出现时数据串了现象两只手都在画面里时Unity 端收到的关键点一会儿是左手一会儿是右手手势判定乱跳。原因results.multi_hand_landmarks返回的列表顺序不固定每帧可能变。解决用multi_handedness里的标签做区分只取特定标签的手或者给每只手分配一个追踪 ID。MediaPipe 本身不提供跨帧 ID简单做法是按手腕点的x坐标排序左边的手固定为第一个右边为第二个。更稳的方案是引入一个简单的匈牙利匹配但一般项目用排序就够了。6. 进阶技巧用 MediaPipe Model Maker 训练自定义手势6.1 什么时候需要自定义模型而不是几何判定几何判定能覆盖张开、捏合、握拳、比数字这些通用手势但如果你的项目需要识别特定手势比如“OK”“点赞”“比心”几何规则会变得非常复杂且脆弱。这时候用 MediaPipe Model Maker 训练一个自定义分类器更划算。Model Maker 的思路是拿 MediaPipe 已经检测到的 21 个关键点作为特征训练一个轻量级全连接网络做分类。你不需要自己标注关键点只需要按文件夹组织手势图片每个文件夹一个类别。from mediapipe_model_maker import gesture_recognizer # 数据集目录结构 # dataset/ # ok/ (至少 100 张) # thumbs_up/ # pinch/ # none/ data gesture_recognizer.Dataset.from_folder( dirnamedataset, hparamsgesture_recognizer.HandDataPreprocessingParams() ) train_data, rest_data data.split(0.8) validation_data, test_data rest_data.split(0.5) hparams gesture_recognizer.HParams( export_direxported_model, epochs20, batch_size8, learning_rate0.001 ) options gesture_recognizer.GestureRecognizerOptions(hparamshparams) model gesture_recognizer.GestureRecognizer.create( train_datatrain_data, validation_datavalidation_data, optionsoptions ) loss, acc model.evaluate(test_data) print(fTest accuracy: {acc}) model.export_model()每个类别至少 100 张图片且要覆盖不同光照、不同手型、不同距离。epochs20在几百张图片的量级下够用太多会过拟合。learning_rate0.001是默认值如果 loss 不下降可以降到 0.0005。6.2 把自定义模型接回 Unity 的两种方式训练完导出的是一个.task文件有两种方式接回 Unity。第一种是在 Python 端加载这个模型替换掉原来的mp.solutions.hands推理结果里会多出gestures字段直接通过原来的 TCP 管道发给 Unity。这种方式改动最小推荐优先用。第二种是把.task转成 ONNX再用 Unity Barracuda 在引擎内推理。这种方式延迟更低但转换过程容易丢算子且 Barracuda 对某些版本 ONNX 的支持不完整。我试过一次卡在Reshape算子上后来放弃了。除非你对延迟有极端要求否则第一种方式足够。# 加载自定义模型替换默认手势识别 base_options python.BaseOptions(model_asset_pathexported_model/gesture_recognizer.task) options vision.GestureRecognizerOptions(base_optionsbase_options) recognizer vision.GestureRecognizer.create_from_options(options) # 推理结果里多了 recognizer_result.gestures result recognizer.recognize(image) if result.gestures: top result.gestures[0][0] print(top.category_name, top.score)top.score低于 0.7 时建议当作无效手势丢弃避免误触发。这个阈值比几何判定的更敏感因为模型在训练集外的样本上可能给出中等置信度。6.3 一个我踩过的坑训练集里混入了“无手势”样本第一次训练时我只放了三种目标手势没有放“无手势”类别结果模型在用户手放下时仍然强行输出某个手势置信度还不低。后来加了一个none文件夹放了 200 张手自然下垂、手在画面边缘、手被遮挡的图片误触发率直接降了一个数量级。这个none类别的图片不需要标注关键点只要保证画面里没有目标手势就行。我一般会从实际使用场景里录几段视频抽帧后把非手势帧全丢进none文件夹。这个步骤花不了半小时但效果比调任何参数都明显。另外训练完的模型在部署时要注意输入分辨率。Model Maker 默认用 224x224如果你在 Python 端送进去的是 640x480它会自动缩放但缩放算法和训练时不一致会导致精度下降。稳妥做法是在送进模型前自己cv2.resize到 224x224保持和训练一致。这套方案我从头搭到尾大概花了三天其中一天半在调通信和坐标系一天在调手势阈值半天在训练自定义模型。如果你只是做通用手势跳过第 6 章前五章的内容足够跑起来。如果要做产品级的手势交互自定义模型那一步迟早要补早做比晚做省事。希望帮到你。本文还有配套的精品资源点击获取
返回列表