MediaPipe手势识别Android集成实战:从关键点到源码避坑
发布时间:2026/10/30 18:11:35 锦皓数字建站

简介基于MediaPipe的Android Studio手势识别系统源码包聚焦实时手势追踪与交互控制适合移动开发学习者、高校人工智能与人机交互专业学生及科研人员参考。代码集成21个手部关键点实时追踪模型、多线程处理架构与手势轨迹平滑算法并附详细技术注释、完整技术文档及自定义手势事件注册接口可帮助读者理解从相机帧输入、MediaPipe推理到手势动作响应的完整处理链路便于二次开发与算法研究。压缩包共35个文件主要包含tflite与binarypb模型文件、java业务逻辑、xml界面布局与配置、png演示图片以及txt说明文档整体约22.67MB目录分类清楚便于按模块检索。已有64人学习下载既能支撑高校课程实践与毕业设计也可作为移动应用手势交互功能的参考实现资源采用Apache 2.0开源协议建议在理清核心算法的基础上按需扩展以充分发挥其技术价值。1. 手势识别不是新概念但 MediaPipe 把落地门槛第一次压到了“一个 Android 工程”会议室白板翻页、车机音量盲调、AR 菜单点选这些场景都需要一个不依赖额外硬件的交互入口。基于 MediaPipe 的 Android Studio 手势识别系统就是拿一颗普通摄像头加一套现成的机器学习模型在手机上实时输出“你比了什么手势”。对 Android 开发者来说它意味着不需要自己训练模型、不需要懂深度学习细节也能在 Android Studio 里把手势识别跑成一个可用 App。这篇笔记适合三类人准备做课程设计或毕设的本科生、想把 AI 能力塞进现有 App 的移动端工程师、以及刚接触 MediaPipe 想快速验证方案的开发者。我会从模型原理讲到源码结构再到 Android Studio 里的具体集成步骤和踩坑记录让你拿到方案后能直接照着做。2. MediaPipe 手势识别在解决什么问题21 个关键点与三种方案的选型2.1 先理解 MediaPipe 是“解决方案集”不是单一 SDK第一次接触 MediaPipe 的人最容易走弯路以为它只是一个手势识别的库导入依赖就能调。实际上MediaPipe 是一套跨平台的机器学习流水线框架手势识别只是它众多“解决方案”中的一个官方叫 MediaPipe Hands。它要做的事情分两步先在整帧画面里找到手再在手部区域内定位 21 个关键点。这里的模型设计值得记住手掌检测器和关键点回归器是分开的。为什么不在全图上直接找 21 个点因为手部关节自由度太大自遮挡严重直接回归 21 点在小目标上误差会急剧放大。所以 MediaPipe 用了级联思路先通过一个轻量的手掌检测器定位手部区域——手掌相对整只手更好识别然后把手掌区域裁剪出来送到 landmark 模型。Android Studio 端要处理的数据流底层就是这个双模型流水线。2.2 21 个关键点协议与坐标含义输出结果里最关键的结构是Landmark一张手有 21 个点索引固定。做手势分类时这 21 个点的顺序就是“协议”不要记错索引区间部位说明0手腕整个骨架的根部1 - 4拇指CMC、MCP、IP、TIP5 - 8食指MCP、PIP、DIP、TIP9 - 12中指MCP、PIP、DIP、TIP13 - 16无名指MCP、PIP、DIP、TIP17 - 20小指MCP、PIP、DIP、TIP来自 MediaPipe 的关键点坐标有三种形式第一种是归一化坐标x、y 在 0 到 1 之间直接除以了输入图像的宽高第二种是带 z 值的坐标z 表示以手腕为原点的深度估计值单位与 x、y 同量纲第三种是世界坐标单位是米但只能作粗略参考不能拿去做精确测量。画骨架或者算手指弯曲角度时用的是归一化坐标。比如判断食指是否弯曲取索引 5食指 MCP、6PIP、8TIP三个点算夹角接近 180 度是伸直明显小于 180 度是弯曲。很多人直接拿模型返回的 gesture 标签做判断其实自己按关键点算角度往往更可控。2.3 Hand Landmarker、Gesture Recognizer、Holistic 怎么选MediaPipe 在 Android 端有多个手部相关 API选错会让代码白写一堆。以我现在常用的稳定版 API 为准常见的三类是方案输出适合场景Hand Landmarker21 个关键点无手势分类需要自己画骨架、算距离角度的特效应用Gesture Recognizer关键点 手势分类结果直接识别“点赞、握拳、OK”等语义手势Holistic手 身体 脸部关键点全身动捕移动端负载偏高如果你做的项目标题叫“手势识别系统”我建议直接用 Gesture Recognizer。它内部已经包含关键点能力识别结果里既能拿到 21 个点也能拿到手势标签和置信度。如果你只需要手部关键点去做渲染再用 Hand Landmarker。两者 API 不兼容初始化方式和参数结构都不同项目里不要混用不然会陷入“API 看起来一样但字段对不上”的泥潭。3. 在 Android Studio 里集成 MediaPipe环境、模型与最小识别闭环3.1 环境准备版本匹配比想象中更重要先交代环境。Android Studio 的安装教程网上一大堆搜“android studio 安装教程”基本都能跑通我重点说容易翻车的版本匹配。MediaPipe 的 Android 库要求你的项目使用 Android Gradle Plugin 7.x 以上JDK 11 以上compileSdk和targetSdk建议用当前主流值。新建项目时Android Studio 默认的模板版本通常都是可用的不用刻意改。如果第一次编译时 Gradle 下载依赖特别慢把settings.gradle里google()和mavenCentral()仓库顺序调前或者用国内镜像源。想切中文界面的话在 Settings 插件市场搜 Chinese Language Pack 装上重启就是我常见的“android studio 怎么设置中文”的办法不影响编译配置。3.2 引入 MediaPipe Tasks Vision 依赖新版 MediaPipe 的 Android SDK 统一在com.google.mediapipe:tasks-vision这个包里。早期那种往build.gradle里加com.google.mediapipe:solutions写法已经过时了网上很多老源码包还在用旧依赖直接抄会编译失败。模块级build.gradle写法dependencies { implementation com.google.mediapipe:tasks-vision:0.10.14 // CameraX 组件官方示例首选用于获取相机帧 implementation androidx.camera:camera-core:1.3.0 implementation androidx.camera:camera-camera2:1.3.0 implementation androidx.camera:camera-lifecycle:1.3.0 implementation androidx.camera:camera-view:1.3.0 }这里的tasks-vision版本号以 Maven Central 上最新稳定版为准我写 0.10.14 是能编译的版本。CameraX 用来替代 Camera2 的样板代码MediaPipe 官方 Android 示例也是这么配的。3.3 模型文件放对位置不是 .tflite而是 .task这里一定要澄清一个坑。旧版 MediaPipe 示例会给你一个.tflite手部模型文件然后代码里传modelAssetPath给Hands类。新版 Gesture Recognizer 用的模型是打包好的.task文件里面同时包含手部关键点模型和手势分类器直接放进app/src/main/assets/目录。app/ ├── src/main/ │ ├── assets/ │ │ └── gesture_recognizer.task │ ├── java/... │ └── res/....task文件去哪拿两种常见渠道一种是从 MediaPipe 官方 Model Garden 下载已经训练好的 Gesture Recognizer 模型另一种是用 MediaPipe Model Maker 训练自定义模型后导出。如果你拿到的源码包里只有.tflite说明那是老版本方案要么换模型文件要么改用 Tasks API 的HandLandmarker重写调用方式。3.4 初始化 GestureRecognizer关键参数与回调识别器要作为成员变量单独创建不要放在 Activity 的onCreate里每次重建那样切换一次页面就要重新加载一次模型卡顿非常明显。import com.google.mediapipe.tasks.vision.core.RunningMode import com.google.mediapipe.tasks.vision.gesturerecognizer.GestureRecognizer private fun createGestureRecognizer(context: Context): GestureRecognizer { val options GestureRecognizer.GestureRecognizerOptions.builder() .setModelAssetPath(gesture_recognizer.task) .setDelegate(Delegate.GPU) // 优先用 GPU 委托 .setRunningMode(RunningMode.LIVE_STREAM) .setResultListener { result, _ - runOnUiThread { processGestureResult(result) } } .setErrorListener { error - Log.e(GestureTag, 识别出错${error.message}) } .build() return GestureRecognizer.createFromOptions(context, options) }这段代码有三个参数必须理解。第一个是Delegate.GPU让模型跑在 GPU 上真机上帧率更高但兼容性差一些后面避坑会细说。第二个是RunningMode.LIVE_STREAM这是连续手势识别必须的模式它要求必须通过setResultListener拿结果不能像 VIDEO 模式那样从函数直接 return。第三个是runOnUiThread因为 MediaPipe 的回调在后台线程直接更新 UI 会崩溃。3.5 把相机帧转成 MediaPipe 能吃的格式CameraX 拿到的是ImageProxy需要先转成Bitmap再包成 MediaPipe 的BitmapImageBuilder。这里最容易漏的是旋转角度和裁剪。private fun analyze(imageProxy: ImageProxy) { // 1. 按相机旋转角度把 Bitmap 旋转到正确的方向 val bitmap imageProxy.toBitmap() .rotate(imageProxy.imageInfo.rotationDegrees.toFloat(), bitmapCenterX, bitmapCenterY) // 2. 居中裁剪到正方形让手部比例更稳定 val size 320 val cropped centerCrop(bitmap, size, size) // 3. 构建 MediaPipe 图像对象并异步识别 val mpImage BitmapImageBuilder(cropped).build() recognizer?.recognizeAsync(mpImage, SystemClock.uptimeMillis()) // 4. 一定要关闭 imageProxy否则 CameraX 会卡住 imageProxy.close() }这段代码里rotationDegrees是 CameraX 根据前/后置摄像头方向算好的角度。centerCrop是手势识别最关键的一步MediaPipe 的手部模型输入是正方形直接拿竖屏 16:9 的帧送入模型手部区域会被压缩变形识别率明显下降。常见做法是裁剪中间区域到 320x320手在画面中央时效果最好。recognizeAsync的第二个参数时间戳必须单调递增上SystemClock.uptimeMillis()就行别自己随手塞一个随机数。3.6 绘制手势骨架在 PreviewView 上叠加自定义 View拿到GestureRecognizerResult后里面有两类数据gestures()是手势分类列表landmarks()是手部 21 个关键点列表。自定义绘制 View 的onDraw里这样画override fun onDraw(canvas: Canvas) { super.onDraw(canvas) result?.landmarks()?.forEach { landmarkList - val pointRadius 8f val lineWidth 6f val paint Paint().apply { color Color.GREEN strokeWidth lineWidth style Paint.Style.STROKE } // 画点 for (landmark in landmarkList) { val cx landmark.x() * width val cy landmark.y() * height canvas.drawCircle(cx, cy, pointRadius, paint) } // 画连线按手指的索引顺序连接 val connections listOf( 0 to 1, 1 to 2, 2 to 3, 3 to 4, 0 to 5, 5 to 6, 6 to 7, 7 to 8, 5 to 9, 9 to 10, 10 to 11, 11 to 12, 9 to 13, 13 to 14, 14 to 15, 15 to 16, 13 to 17, 17 to 18, 18 to 19, 19 to 20 ) for (connection in connections) { val start landmarkList[connection.first] val end landmarkList[connection.second] canvas.drawLine(start.x() * width, start.y() * height, end.x() * width, end.y() * height, paint) } } }注意 landmark 坐标是归一化的必须乘以 View 的宽高。到这里一个从相机取帧、模型识别、结果绘制的最小闭环就跑通了。很多“基于 MediaPipe 的 Android Studio 手势识别系统源码”文件包里的内容核心就是这几步剩下的工作基本都是业务逻辑和 UI 装饰。4. 源码级拆解一个可维护的手势识别 App 该怎么组织4.1 文件结构与职责边界网上能下到的源码包质量参差不齐有的把几百行代码全塞进 MainActivity跑通没问题想改模型和业务逻辑时痛苦。我拆过的项目里通用可维护的结构基本长这样文件职责关键点MainActivity.kt权限申请、生命周期管理、UI 容器在这里只做装配不做识别逻辑CameraManager.ktCameraX 绑定与帧回调统一输出旋转后的 BitmapGestureRecognizerHandler.kt识别器创建、异步调用、资源释放所有 MediaPipe API 调用隔离在这个类里GestureOverlayView.kt关键点与手势文字绘制只看数据不关心数据来源GestureResult.kt业务数据模型把 gestures 和 landmarks 封装成业务对象如果你要“解析”一套源码先从这五个类入手就足够看懂。很多新手把GestureRecognizer的初始化直接放在 Activity 里结果每个页面想要识别都要复制一遍代码后面想换成自己的自定义模型改动范围大得想放弃。隔离是对的因为 MediaPipe 的 API 内部变化比较频繁封装一层后升级版本只需要改一个文件。4.2 帧回调、线程切换与 UI 更新的正确姿势手势识别是高频场景相机 30fps 进帧每个回调都触发一次 UI 更新UI 线程根本忙不过来。MediaPipe 的LIVE_STREAM模式下resultListener 每处理完一帧就会回调回调线程是 MediaPipe 内部线程。常见的做法是耗时操作做内外两层处理。外层用Handler把结果切回主线程内层对文本手势做节流比如手势标签每 500ms 更新一次关键点骨架每帧都画。我这个示例代码里的写法是private val mainHandler Handler(Looper.getMainLooper()) private var lastUiUpdate 0L private fun processGestureResult(result: GestureRecognizerResult) { val gesture result.gestures()?.firstOrNull()?.firstOrNull()?.categoryName() ?: None // 节流只有手势名称变化或距离上次超过 500ms 才更新 UI 文本 val now SystemClock.uptimeMillis() if (now - lastUiUpdate 500 || gesture ! lastShownGesture) { lastShownGesture gesture lastUiUpdate now mainHandler.post { tvGestureName.text gesture } } // 手势关键点必须每帧更新否则动画会撕裂 mainHandler.post { overlayView.update(result) overlayView.invalidate() } }关键在post而不是postDelayedMediaPipe 本身已经按时间戳排序了结果你不需要再自己维护队列。很多人在这里做了双重缓存反而导致手势延迟严重。识别器内部已经处理乱序帧我们只需要把最后的结果丢到主线程。4.3 内置手势类别不够用自己写规则比重新训练更快拿到源码后第一件该做的事是打印模型支持的手势类别。不同版本的gesture_recognizer.task内置标签不一样常见的有Closed_Fist、Open_Palm、Pointing_Up、Thumb_Up、Thumb_Down、Victory等但不要默认全都有以你的模型实际输出为准。如果你要识别的手势不在这套标签里比如“食指和中指交叉”有两个选择一是用 MediaPipe Model Maker 重新训练分类器训练数据需要自己采集链路较长二是直接基于 21 个关键点写规则比如判断两根手指是否交叉可以看两指 TIP 点之间的欧氏距离是否小于某个阈值再叠加 MCP 点距离做归一化。写规则判断手指伸直的通用函数fun isFingerStraight(landmarks: ListLandmark, mcp: Int, pip: Int, tip: Int): Boolean { val v1 landmarkVector(landmarks[mcp], landmarks[pip]) val v2 landmarkVector(landmarks[pip], landmarks[tip]) val cosAngle dot(v1, v2) / (vectorLength(v1) * vectorLength(v2)) val angleDeg Math.toDegrees(acos(cosAngle.toDouble())) return angleDeg 160 }三个点之间的夹角越接近 180 度说明关节越伸直。这个方式的优势是零训练成本缺点是得自己对每种手势调阈值属于“轻度编码换训练时间”。在一套毕设级别的源码里大部分手势用规则方式足够交付。4.4 识别结果与业务层解耦数据模型封装源码解析到最后会看到一套系统怎么把识别结果送进业务逻辑。最简单的做法不是直接传GestureRecognizerResult到各个业务模块而是先封装成自己的数据类data class GestureFrame( val gestureName: String, val confidence: Float, val landmarks: ListLandmark, val timestamp: Long, val handedness: String )Activity 只依赖GestureFrame不直接依赖 MediaPipe 类型。这样后续换 TensorFlow Lite 或者其他识别方案业务代码不需要跟着改。我见过不少源码把GestureRecognizerResult传到 ViewModel 里看起来能跑但一旦 MediaPipe 升级字段名变化全部调用处都要跟着改这是源码设计上最影响可维护性的点。5. 手势识别系统的避坑清单从编译报错到识别玄学5.1 坑老示例代码的 API 与新版库冲突现象从网上下载的“基于 MediaPipe 手势识别源码”在 Android Studio 里直接编译失败报错提示cannot resolve symbol Hands或者ClassNotFoundException。原因MediaPipe 在 0.8.x 版本用的是 Solutions API类名是Hands后来升级到 Tasks API类名变成GestureRecognizer、HandLandmarker解决思路和参数完全变了。很多流传的源码包停留在老版本你用了新版依赖自然编译不过。解决用新版 Tasks API 重写调用层。检查build.gradle里如果有com.google.mediapipe:solutions或com.google.mediapipe:hands依赖先删掉。注意排查是否有一个老工程同时引入了新旧两套依赖会冲突到怀疑人生。5.2 坑GPU 委托让应用在部分设备上闪退现象应用在主力机型上跑得好好的换到一台国产中低端机或者模拟器上启动后切入相机页面直接崩溃logcat 里能看到OpenGL ES 3.1 is not supported之类的字眼。原因MediaPipe 的 GPU Delegate 依赖 OpenGL ES 3.1模拟器默认的软件渲染和部分厂商的 GPU 驱动不完整导致图形上下文初始化失败。这个问题在真机上也很玄学同一芯片不同 ROM 表现都不一样。解决初始化时先尝试 GPU如果抛异常就回退到 CPUfun createRecognizerSafely(context: Context): GestureRecognizer { val baseBuilder GestureRecognizer.GestureRecognizerOptions.builder() .setModelAssetPath(gesture_recognizer.task) .setRunningMode(RunningMode.LIVE_STREAM) // 这里塞入回调... return try { baseBuilder.setDelegate(Delegate.GPU).build() GestureRecognizer.createFromOptions(context, baseBuilder.build()) } catch (e: Exception) { Log.w(GestureTag, GPU 初始化失败回退到 CPU) baseBuilder.setDelegate(Delegate.CPU).build() GestureRecognizer.createFromOptions(context, baseBuilder.build()) } }这段代码注意要重新build()两次builder对象不能复用同一个配置。回退到 CPU 后帧率会低一些但至少不会闪退。血泪经验是凡是拿模拟器做手势识别调试的建议直接换真机模拟器里的 MediaPipe 叫“能用但不多”。5.3 坑手在画面左边识别结果却在右边现象前置摄像头开镜像后手往左挥画面上手势往右动或者后置摄像头竖屏持握时手势上下颠倒。原因CameraX 的ImageProxy自带的旋转信息没有应用到输入帧。某些教程里用imageProxy.imageInfo.rotationDegrees只是写了一个变量名根本没有调用还有的是后置摄像头应该旋转 90 度代码里写死了 0导致模型看到的是横着的画面。解决把Bitmap.rotate放在最前面旋转中心用bitmap.width / 2f和bitmap.height / 2f。前置摄像头如果要像镜子一样显示再做一次水平翻转val matrix Matrix().apply { postRotate(imageProxy.imageInfo.rotationDegrees.toFloat()) if (isFrontCamera) { postScale(-1f, 1f, bitmap.width / 2f, bitmap.height / 2f) } } val rotated Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)5.4 坑手势明明摆了输出一直是 None现象模型能跑骨架点也能画出来但gestures().firstOrNull()永远是空或者偶尔识别成功一次、大部分时间失败。原因最常见的是输入图像分辨率太低。把帧裁到 160x160 会让手掌检测器丢失小目标手部区域检测器找不到手后面的关键点和手势分类自然全部为空。另一个原因是recognizeAsync时间戳传了固定的时间值比如 0L导致 MediaPipe 内部排序逻辑把帧当无效数据处理。解决把centerCrop的尺寸提到 320 以上时间戳必须用SystemClock.uptimeMillis()。在 resultListener 里先检查result.landmarks()是否为空如果关键点为空优先查输入图片尺寸和时间戳如果关键点正常但手势为空这才是手势分类器的问题可以用 4.3 里的规则兜底。5.5 坑运行几分钟后越来越卡GC 频繁现象刚进入识别页面流畅连续跑三到五分钟开始掉帧logcat 里频繁出现Alloc和GC打印。原因每帧imageProxy.toBitmap()都创建一个新的Bitmap并且没有回收同时BitmapImageBuilder包装的 Bitmap 在识别完成后也没有释放。Android 堆内存持续波动GC 频繁触发识别线程就被顺带拖慢。解决复用 Bitmap。第一种方式是Bitmap.createBitmap一次后续用Canvas.drawBitmap把 CameraX 帧内容画进去第二种方式是不用toBitmap()直接用 ImageProxy 的原生格式交给 MediaPipe 的Image类型。如果只能用 Bitmap 接口识别完立刻bitmap.recycle()并注意centerCrop产生的新 Bitmap 也要一并回收。这个优化做完帧率稳定性的提升会非常直观。6. 结束前的最后一件事验证帧率与自定义手势模型6.1 用 FPS 覆盖层验证而不是靠肉眼接入完成后先别急着调业务逻辑装一个帧率显示到界面上。常见做法是在绘制 View 里累计帧数每半秒算一次 FPSprivate var frameCount 0 private var lastFpsTime 0L override fun onDraw(canvas: Canvas) { super.onDraw(canvas) frameCount val now SystemClock.uptimeMillis() if (now - lastFpsTime 500) { fps frameCount * 1000f / (now - lastFpsTime) frameCount 0 lastFpsTime now } canvas.drawText(FPS: ${fps?.toInt()}, 20f, 40f, textPaint) }真机上识别加绘制稳定在 15 到 30 FPS 属于正常范围。如果掉到个位数优先怀疑输入尺寸和 GPU 委托。6.2 自定义手势从数据集到 .task 文件的完整链路内置手势不够用是常态MediaPipe Model Maker 是专门干这件事的不需要从零写训练代码。训练端用 Python 完成数据集不用强求“yolo手势识别数据集”那种格式Model Maker 接受按标签分文件夹的数据组织方式。pip install mediapipe-model-maker python train.py训练脚本核心内容from mediapipe_model_maker import gesture_recognizer dataset gesture_recognizer.Dataset.from_folder( dirnamegesture_data, cache_dir/tmp/cache ) model gesture_recognizer.GestureRecognizer.create( train_datadataset, epochs30, batch_size16 ) model.export(gesture_recognizer.task)gesture_data下按手势名称建子文件夹每个文件夹里放该手势的图片每个手势至少准备 100 到 200 张背景多变、角度多样图片拍到手部和背景。训练导出的gesture_recognizer.task直接替换 assets 目录里的同名文件Android 端代码一行都不用改。第一次把手势数据采到模型里的时候我只在桌前固定角度拍结果换到走廊和会议室就全军覆没数据多样性不够带来的掉点比模型结构问题更严重。后来强制自己在不同光照、不同背景、左右手都拍识别率才真正稳定下来这是我在手势识别项目里最大的教训。希望这篇笔记能帮你少走这一步弯路。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。