简介:基于Unity与MediaPipe的手部追踪与手势识别系统项目包,面向VR/AR交互、手势控制游戏及需要自然用户界面的应用开发者,可用于快速构建非接触式、直观自然的交互体验。系统支持拳头、点赞、胜利等多种常见手势识别,并内置完整事件系统处理手势状态变化,开发者可将不同手势指令灵活映射为项目中的具体业务逻辑。压缩包共1014个文件,约210.87MB,以364个C#脚本承载核心识别与事件处理逻辑,辅以30个prefab预制体、24个asset场景与配置资源,并包含shader、dll、onnx模型、Android平台AAR包及原生库等关键组件,覆盖从MediaPipe算法模型到Unity工程集成的完整链路。工程内含完整的Unity环境配置及跨平台依赖文件,目录结构清晰,便于二次开发。已有288人学习下载,适合具备一定Unity基础、希望在移动端或桌面端快速接入MediaPipe手势能力的开发者。
1. Unity 手势识别:从 MediaPipe 手部追踪到可交互的游戏逻辑
做 Unity 交互的同学应该都遇到过这种场景:老板说"用摄像头控制 UI",需求文档里写着"隔空点按钮"、"挥挥手翻页"。第一个想到的方案大多是用 Kinect,或者干脆让用户拿手柄。但如果你手头只有一个普通 RGB 摄像头,MediaPipe 的手部追踪方案是目前成本最低、落地最快的一条路——Python 端跑 MediaPipe 拿到手部 21 个关键点坐标,通过 WebSocket 推给 Unity,Unity 侧解析成骨架点、渲染成模型或 UI 光标,再基于关键点之间的几何关系做手势分类。这套路线的核心优势在于:MediaPipe 不需要深度摄像头,单目 RGB 就能稳定输出 21 个关键点,Unity 端只需要处理数据接收和逻辑映射,不需要自己碰视觉算法。适合做体感交互原型、展览互动、AR 滤镜前置验证的开发者。这篇笔记把数据通信、坐标转换、手势分类和踩坑记录完整走一遍。
2. 搭建手部追踪数据管线:MediaPipe 关键点输出与 Unity 接收协议
2.1 手部 21 个关键点的数据结构与坐标含义
MediaPipe 输出的每只手包含 21 个关键点,索引顺序是固定的:0 是 wrist 腕关节点,1-4 是拇指(从掌指到指尖),5-8 是食指,9-12 是中指,13-16 是无名指,17-20 是小指。每个点的坐标是归一化的 x、y、z 值,x 和 y 范围在 [0, 1],z 表示深度,以腕关节为原点,指尖方向为正。这个 z 并不是真实的物理距离,而是相对深度,单位是相对比例,受手和摄像头距离影响很大,不能直接当毫米用。
在 Unity 里接收数据时,我一般直接建立一个 21 个 Vector3 的数组来对应每个关键点。数据结构建模很直白,但有一个坑:MediaPipe 的 x 轴方向是镜像的——摄像头看到的左手在画面的右边,x 坐标偏大,而 Unity 世界坐标系的 x 正方向是右手边。如果不做镜像处理,左手会变成右手,整个交互逻辑全反,后面第 5 章详细说。
// Unity 侧定义手部数据结构 public struct HandPoint { public int index; // 关键点索引 0-20 public Vector3 position; // 归一化坐标或世界坐标 public float confidence; // 该点的置信度 } public class HandSkeleton { public HandPoint[] points = new HandPoint[21]; public float handedness; // 0 代表右手,1 代表左手(MediaPipe 输出) public float score; // 整只手的追踪置信度 }这个结构里带了置信度字段,是因为实际调的时候,手偶尔会被遮挡或快速移动导致某个点抖动,有了置信度就可以在 Unity 侧做插值或者直接丢弃低置信度的帧,避免骨架点乱飞。
2.2 Python 侧发送数据:为什么选 WebSocket 而不是 UDP
MediaPipe 的 Python 推理结果需要实时传给 Unity。常用的方案有 UDP、共享内存和 WebSocket。我实测下来的结论是:局域网内的原型项目优先选 WebSocket,原因有三个。第一,WebSocket 走 TCP 协议,数据不丢包,Unity 侧收到的骨架点顺序稳定;第二,WebSocket 是文本帧或二进制帧结构,直接把 JSON 序列化后的 21 个点传过去,Unity 的 WebSocket 库解析起来非常干净;第三,Unity 编辑器里调试时不需要额外装 UDP 插件,用原生的ClientWebSocket就能跑。
UDP 的问题在于:手部关键点数据每秒 30 帧,每帧不到 2KB,丢包率哪怕只有 1%,Unity 侧就会出现关键点跳跃,一条手臂的某个点跳一下特别鬼畜。共享内存方案性能最好,但跨进程调试麻烦,而且打包后权限控制比较折腾,原型阶段不值得投入。最终我选择 WebSocket,Python 端作为服务端,Unity 作为客户端连接。
# Python 端 MediaPipe 推理 + WebSocket 发送 import cv2 import mediapipe as mp import websockets import asyncio import json mp_hands = mp.solutions.hands hands = mp_hands.Hands( static_image_mode=False, max_num_hands=2, min_detection_confidence=0.7, min_tracking_confidence=0.5 ) async def send_hand_data(websocket, path): cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while True: ret, frame = cap.read() if not ret: continue frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = hands.process(frame_rgb) data = {"hands": []} if results.multi_hand_landmarks: for hand_landmarks, handedness in zip( results.multi_hand_landmarks, results.multi_handedness ): points = [] for lm in hand_landmarks.landmark: points.append({ "x": lm.x, "y": lm.y, "z": lm.z, "confidence": lm.visibility if hasattr(lm, 'visibility') else 1.0 }) data["hands"].append({ "label": handedness.classification[0].label, # "Left" 或 "Right" "score": handedness.classification[0].score, "points": points }) await websocket.send(json.dumps(data)) await asyncio.sleep(0.033) # 约 30FPS start_server = websockets.serve(send_hand_data, "127.0.0.1", 8765) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever()这段代码里,min_detection_confidence控制的是第一次检测到手的置信度阈值,0.7 是比较稳妥的值,再低会频繁出现误检;min_tracking_confidence是追踪过程中的置信度,0.5 就够了,因为追踪比检测快得多,阈值太高手一快速移动就丢追踪导致断断续续。发送频率用asyncio.sleep(0.033)限到 30FPS,实际上 MediaPipe 在普通 CPU 上跑不到 30FPS,反而是这个 sleep 保证了 Unity 侧不会因为突发的数据洪峰导致 UI 线程卡顿。
2.3 Unity 侧 WebSocket 客户端与骨架点渲染
Unity 端接收数据,我用的是ClientWebSocket原生类加一个简易的 JSON 解析。这里有一个关键设计:不要在主线程里直接做网络接收和 JSON 解析,否则每帧 GC 分配会很大,手部数据 30FPS 传输时 Unity 的 Profiler 会很难看。我会把接收逻辑放在一个独立线程里,解析成HandSkeleton结构后放进队列,主线程Update只从队列里取最新的一帧。
骨架点的渲染有两种常见方式:一种是把 21 个点连成线,画出骨架;另一种是直接用一个球体预制体实例化 21 个点,手指尖单独用高亮的材质。我一般推荐第二种,因为调试阶段能直观看到每个点的位置是否合理,而且球体实例化在 21 个点这个数量级上性能完全没问题。
// Unity 侧简化版:接收 JSON 并更新骨架点位置 using System; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; public class HandDataReceiver : MonoBehaviour { public GameObject pointPrefab; // 关键点球体预制体 public Transform handRoot; // 骨架点挂载的父节点 private ClientWebSocket _socket; private Transform[] _pointTransforms = new Transform[21]; private HandSkeleton _latestHand; private object _lock = new object(); async void Start() { _socket = new ClientWebSocket(); await _socket.ConnectAsync( new Uri("ws://127.0.0.1:8765"), CancellationToken.None ); for (int i = 0; i < 21; i++) { _pointTransforms[i] = Instantiate(pointPrefab, handRoot).transform; } _ = Task.Run(ReceiveLoop); } async Task ReceiveLoop() { var buffer = new byte[4096]; while (_socket.State == WebSocketState.Open) { var result = await _socket.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None ); var json = Encoding.UTF8.GetString(buffer, 0, result.Count); lock (_lock) { _latestHand = JsonUtility.FromJson<HandSkeleton>(json); } } } void Update() { HandSkeleton hand; lock (_lock) { if (_latestHand == null) return; hand = _latestHand; } if (hand.points.Length != 21) return; for (int i = 0; i < 21; i++) { Vector3 pos = new Vector3( hand.points[i].position.x, hand.points[i].position.y, hand.points[i].position.z ); _pointTransforms[i].localPosition = pos; } } }注意我在Update里用lock加锁后快速取引用然后立刻释放锁,解析和赋值都不在锁内,避免主线程阻塞。JsonUtility.FromJson解析数组需要包一层{"hands": [...]}的结构,Python 端发送时已经是这个格式了,C# 类定义必须和 JSON 字段名完全一致,大小写也要严格对应,否则解析出来全是默认值,这是新手最容易翻车的地方。
3. 坐标转换与坐标系对齐:MediaPipe 归一化坐标映射到 Unity 世界空间
3.1 从归一化坐标到 Unity 左手坐标系的换算
MediaPipe 输出的 x、y 是归一化坐标,范围 [0, 1],原点在画面左上角。Unity 的世界坐标系原点在场景中心,且 y 轴向上。直接把归一化坐标赋值给 Vector3 会导致手的位置偏到场景角落,而且上下颠倒。标准做法是定义一个映射区域:你把摄像头画面映射到 Unity 的哪个平面范围。
我一般用一个公开的Rect参数指定映射范围,比如希望手部在 x 方向映射到[-2, 2]米,y 方向映射到[-1, 1]米,则公式为:
public Rect mappingArea = new Rect(-2f, -1f, 4f, 2f); Vector3 MapToWorld(Vector3 normalized) { float worldX = mappingArea.x + normalized.x * mappingArea.width; // MediaPipe y 轴向下,Unity y 轴向上,需要反转 float worldY = mappingArea.y + (1f - normalized.y) * mappingArea.height; return new Vector3(worldX, worldY, normalized.z); }这里 y 轴反转是必须的。MediaPipe 的 y 是图像坐标系里的向下方向,Unity 的 y 是向上的,如果不反转,你举手时骨架会往下走,整个交互逻辑全部反过来。z 值我就直接透传了,因为 MediaPipe 的 z 是相对深度,绝对值没有物理意义,Unity 侧只在需要做"手靠近屏幕"这类效果时用它做相对插值。
3.2 单摄像头深度信息的局限性:z 值只适合做手势判断
很多第一次接触 MediaPipe 的开发者会问一个问题:能不能直接用 z 坐标实现手在三维空间里的推拉,比如把手移近摄像头让物体放大?答案是可以做,但精度约等于没有。MediaPipe 的 z 是从手部包围盒估算的相对深度,它对手掌平面面积变化很敏感,但手指指向镜头方向时 z 值会乱跳,因为这时候手部轮廓从张开变成了收缩,估算模型会认为手在快速远离,实际上只是转了个方向。
所以 z 值的合理用法是:做相对判断,比如"指尖是否比手腕更靠近摄像头",或者"三根手指是否朝向屏幕外",而不是绝对距离测量。我在项目里把 z 值做了归一化处理,z / 100之后范围大概在[-0.3, 0.3]之间,再用这个偏移量影响 UI 按钮的缩放,效果比直接用原始 z 稳定得多。要真正做三维空间的精确追踪,还是得换深度摄像头,MediaPipe 这套方案的天花板就在这里。
3.3 手部平滑与抖动抑制:指数移动平均与关键点插值
手部追踪数据天然带抖,尤其是手指尖的细小震颤,在 Unity 里表现就是 UI 光标不停哆嗦。处理这个问题的常见做法是给每个关键点加一个指数移动平均(EMA)平滑器,核心参数是平滑因子alpha,取值在 0 到 1 之间,越小越平滑但延迟越大,越大越跟手但抖动越明显。我实测下来的经验值是:对腕关节alpha=0.3,对指尖alpha=0.5,因为指尖移动快,需要更快响应。
// 关键点 EMA 平滑 Vector3 SmoothPoint(Vector3 current, Vector3 previous, float alpha) { return Vector3.Lerp(current, previous, alpha); }要注意的是,EMA 平滑是有代价的,它引入了固定延迟。延迟大小和alpha的关系是:延迟约等于(1 - alpha) / alpha帧。alpha=0.3时大约延迟 2.3 帧,在 30FPS 下是 77ms 左右,这个延迟在手势交互里是可以接受的;但如果你做的是需要快速甩手翻页的效果,alpha不能低于 0.5,否则手甩过去了 UI 光标还在半路,那体感就崩了。另一个更进阶的做法是卡尔曼滤波,但配置参数多、调起来耗时,原型阶段不建议上,EMA 到打包验证阶段基本够用。
4. 手势识别实现:从关键点几何关系到可配置的分类器
4.1 静态手势特征提取:指尖距离、角度与比例
手势识别最直接的做法不是上深度学习分类器,而是用关键点的几何特征做规则判断。手部 21 个关键点之间的相对位置关系已经包含了足够的信息来判断常见手势。以"比赞"(大拇指朝上,手指握拳)为例,特征是大拇指指尖在腕关节上方,且其余四指的指尖距离腕关节很近(因为握拳了)。再比如"五指张开"的识别,特征是每个指尖到腕关节的距离都大于某个比例阈值。
我习惯先把每根手指的"伸展度"算出来,这是后面所有手势判断的基础。伸展度定义为:该手指指尖到腕关节的距离除以中指指尖到腕关节的距离作为归一化因子。因为手离摄像头远近会导致绝对距离变化,但手指之间的比例基本不变。
# 基于 21 个关键点计算手指伸展度 def finger_stretch(landmarks, finger_tip_idx, wrist_idx=0): # 手指长度参考值:用食指根部到小指根部的平均距离 base_length = ( distance(landmarks[5], landmarks[17]) + distance(landmarks[9], landmarks[13]) ) / 2 tip = landmarks[finger_tip_idx] wrist = landmarks[wrist_idx] dist = distance(tip, wrist) return dist / base_length # 判定规则:伸展度 > 2.0 视为伸展 def is_finger_extended(landmarks, finger_tip_idx): return finger_stretch(landmarks, finger_tip_idx) > 2.0这个base_length归一化因子我觉得比直接用中指尖到腕关节的距离更稳定,因为手部旋转或者单指弯曲时,中指尖到腕关节的距离会变,导致其他手指的判定跟着偏移。用四指根部的平均距离做基准,抗手部旋转的能力强很多。阈值 2.0 是我在多个场景里测出来的折中值,手小的人和手大的人这个比例值差异很小,因为本身是比例关系。
4.2 动态手势:滑动方向与速度阈值的判定
静态手势之外,交互里更需要的是动态手势,比如"向左滑动翻页"。动态手势的判定建立在静态识别的基础上:先识别出当前是"五指张开"状态,然后跟踪手掌中心(腕关节或者掌心点 9)在一段时间内的位移方向和速度。
我实现的滑动判定逻辑很朴素:维护一个长度为 10 帧的手掌位置队列,每帧把新位置推进去并弹出最旧的位置。当队列满时,计算首尾两帧的位置差delta,如果delta.x > 阈值且delta.y的绝对值远小于delta.x,就判定为横向滑动。阈值要按映射区域的实际尺寸来定,比如映射范围是 4 米宽,那滑动判定阈值设为 0.6 米比较合理,配合时间窗口 10 帧(约 333ms),能有效区分"有意的滑动"和"手的自然晃动"。
class SwipeDetector: def __init__(self, window_size=10, threshold=0.6): self.window = [] self.window_size = window_size self.threshold = threshold def add_frame(self, palm_center): self.window.append(palm_center) if len(self.window) > self.window_size: self.window.pop(0) return self.detect() def detect(self): if len(self.window) < self.window_size: return None delta = self.window[-1] - self.window[0] # 优先判断 x 方向滑动,y 方向位移过大则不判定 if abs(delta.x) > self.threshold and abs(delta.y) < abs(delta.x) * 0.5: return "left" if delta.x < 0 else "right" return None这里我把滑动判定逻辑放在 Python 端了,因为滑动检测是纯时序逻辑,放在数据源头做可以减少 Unity 侧的网络往返。实际上 Unity 侧也可以做,但从架构解耦的角度看,手势识别属于"感知层",应该在数据产生端完成,Unity 只需要接收"left"、"right"、"punch"这类语义化事件。这一点在后面打包部署时好处很明显:Python 端可以独立调试替换,Unity 工程不用动。
4.3 手势分类器的可配置化:JSON 规则文件与事件回调
手势规则如果直接硬编码在代码里,每次改一个阈值都要重新编译,调试体验极差。我的做法是把手势规则抽象成 JSON 配置文件,代码里只写一个通用的规则引擎。每个手势的定义包括:需要检查的手指组合、每个手指的伸展状态、指尖之间的角度约束,以及关键点在某个轴向上的相对位置。
{ "gestures": [ { "name": "thumbs_up", "check": { "thumb_extended": true, "index_extended": false, "middle_extended": false, "ring_extended": false, "pinky_extended": false, "thumb_tip_above_wrist": true } }, { "name": "peace", "check": { "index_extended": true, "middle_extended": true, "middle_index_gap_ratio": 0.3 } } ] }规则引擎解析这个配置文件,逐个检查当前手部关键点的状态是否满足所有约束条件,满足则触发对应手势事件。Unity 侧通过 WebSocket 收到手势名称字符串后,用SendMessage或者事件派发机制通知业务逻辑。这套架构的好处是新增一个手势只需要修改 JSON 并调整摄像头验证一次,不用改动 C# 代码。
5. 避坑指南:Unity+MediaPipe 集成中常见的五个坑
5.1 现象:Unity 侧收不到数据,Python 端无报错
原因:WebSocket 服务监听地址是127.0.0.1,但 Unity 打包到安卓设备或另一台电脑上运行,连接的是开发机的局域网 IP,127.0.0.1只指向本机,无法跨设备访问。
解决:Python 端监听地址改成0.0.0.0,表示接受所有网络接口的连接;Unity 端连接地址改成开发机的局域网 IP,比如ws://192.168.1.101:8765。顺手检查防火墙是否拦截了 8765 端口,Windows 桌面端经常因为防火墙弹窗没点"允许"导致局域网连接失败。
# 修改监听地址,允许局域网设备连接 start_server = websockets.serve(send_hand_data, "0.0.0.0", 8765)5.2 现象:左手右手在 Unity 里显示镜像,左边的人抬手变成右边的人抬手
原因:MediaPipe 的输出坐标系是画面坐标系,摄像头画面本身是左右镜像的,但 MediaPipe 并没有自动做镜像还原,它的Left和Right标签是基于画面中的位置判断的。Unity 如果直接把归一化坐标按正常方向映射,就会出现左右手颠倒。
解决:在映射坐标时做 x 轴翻转,worldX = mappingArea.x + (1f - normalized.x) * mappingArea.width。注意这会导致整体左右镜像,如果场景里有文字或者方向性图标也会跟着翻,需要额外处理 UI 层的反向补偿。
Vector3 MapToWorld(Vector3 normalized) { float worldX = mappingArea.x + (1f - normalized.x) * mappingArea.width; // 注意这里已经是镜像修正后的坐标 float worldY = mappingArea.y + (1f - normalized.y) * mappingArea.height; return new Vector3(worldX, worldY, 0f); }5.3 现象:手势识别响应慢,手都到位了 Unity 侧还要犹豫一下
原因:这个坑通常不是你代码的问题,而是两个叠加因素。第一,WebSocket 传输 30FPS 数据,asyncio.sleep(0.033)是附加延迟项;第二,EMA 平滑的alpha值太低,导致指尖位置延迟大,手势判定需要等位置稳定后才触发。
解决:降低 WebSocket 发送端的 sleep 到0.016(约 60FPS 数据频率),虽然 MediaPipe 推理达不到 60FPS,但能预留出系统调度的余量;alpha值针对手势判定专用的关键点单独提权,用 0.5 而不是 0.3。实测这一套改完后,手势触发的体感延迟从约 300ms 降到 160ms,能明显感觉到"跟手"了。
5.4 现象:手快速甩动时骨架点飞出去,甚至识别出诡异手势
原因:MediaPipe 在快速运动时可能会出现关键点丢失或误匹配,单帧识别结果里某个点跳到了完全错误的方位。规则引擎判断时,如果恰好碰上这帧的异常点,就会触发误判。
解决:在规则引擎判定前加一个置信度过滤,只有score > 0.7时才允许触发手势事件;同时对连续帧做一致性校验,比如同一个手势必须连续出现 3 帧才真正触发,防止单帧毛刺。这个 3 帧机制在交互中会有约 66ms 的额外延迟,但换来的是几乎不误触。
5.5 现象:Unity 打包后运行卡顿,Profiler 显示主线程 GC 压力大
原因:Unity 侧的 WebSocket 接收线程用JsonUtility解析 JSON,如果每帧都产生新数组、新对象,GC 压力会传导到主线程。尤其是骨架点数据每秒 60 帧进入,每帧分配几十个 Vector3 和 float 数组,加上字符串解析的临时内存,主线程不得不频繁做 GC。
解决:接收线程解析完 JSON 后,复用预分配的HandSkeleton对象,不要每帧new;把数组初始化到固定长度 21,用索引赋值而不是List.Add。字符串层面不要直接Encoding.UTF8.GetString拼接,用渎取到的buffer偏移量做片段处理。这套优化做完,实测 GC Alloc 从每帧 48KB 降到 2KB 以内,长时间运行的堆内存增长基本平稳。
6. 进阶:延迟实测方法与参数收敛的实验流程
把基础功能跑通之后,真正决定交互体验的是延迟和跟手度。这一步没有捷径,需要一个可量化的实验流程来调参。我的做法是在 Unity 场景里放一个虚拟手模型,让它按照固定轨迹运动(比如画圆),然后让真实手跟着这个虚拟手移动,记录真实手和虚拟手之间的位置误差。误差越大,说明平滑延迟越高;误差波动越大,说明抖动抑制不足。
实验流程分四步。第一步,先关掉所有平滑,测出裸数据的延迟,这个值代表了方案底层的固定延迟,包含摄像头采集、MediaPipe 推理、网络传输和 Unity 渲染四段。第二步,逐步增加 EMA 平滑的alpha,以 0.1 为步长,从 0.1 测到 0.9,记录每种参数下的平均误差和最大误差,画出一条曲线。第三步,找出误差曲线中拐点附近的值——误差开始急剧增大的前一个点就是当前的甜点参数。第四步,用动态手势触发测试验证这个参数在真实交互场景下的表现,因为画圆的平滑需求比滑动翻页更高,所以实际参数通常比实验值稍微激进一点(alpha调大)。
以我的实测数据来看,在普通笔记本摄像头、MediaPipe 默认配置、WebSocket 局域网传输的条件下,裸数据延迟大约在 80ms 到 100ms。加上 EMA 平滑后,延迟会增加到 120ms 到 180ms,这个范围在 UI 点击类交互里是能接受的。如果你要做的是第一人称射击游戏的手部瞄准,180ms 是完全不行的,这时候就需要换共享内存方案并关掉反向平滑的延迟优化,才能把端到端延迟压到 100ms 以内。
从那以后,我每次拿到新的摄像头环境或新的 Unity 版本,都会强制走一遍这个延迟测量流程,把参数记在项目文档里。事实证明,不同摄像头、不同光线条件下的最佳alpha值差异很大,闭眼抄参数必翻车。希望帮到你。
本文还有配套的精品资源,点击获取