简介:基于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、TIP |
| 5 - 8 | 食指 | MCP、PIP、DIP、TIP |
| 9 - 12 | 中指 | MCP、PIP、DIP、TIP |
| 13 - 16 | 无名指 | MCP、PIP、DIP、TIP |
| 17 - 20 | 小指 | MCP、PIP、DIP、TIP |
来自 MediaPipe 的关键点坐标有三种形式:第一种是归一化坐标,x、y 在 0 到 1 之间,直接除以了输入图像的宽高;第二种是带 z 值的坐标,z 表示以手腕为原点的深度估计值,单位与 x、y 同量纲;第三种是世界坐标,单位是米,但只能作粗略参考,不能拿去做精确测量。
画骨架或者算手指弯曲角度时,用的是归一化坐标。比如判断食指是否弯曲,取索引 5(食指 MCP)、6(PIP)、8(TIP)三个点算夹角,接近 180 度是伸直,明显小于 180 度是弯曲。很多人直接拿模型返回的 gesture 标签做判断,其实自己按关键点算角度往往更可控。
2.3 Hand Landmarker、Gesture Recognizer、Holistic 怎么选
MediaPipe 在 Android 端有多个手部相关 API,选错会让代码白写一堆。以我现在常用的稳定版 API 为准,常见的三类是:
| 方案 | 输出 | 适合场景 |
|---|---|---|
| Hand Landmarker | 21 个关键点,无手势分类 | 需要自己画骨架、算距离角度的特效应用 |
| 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.kt | CameraX 绑定与帧回调 | 统一输出旋转后的 Bitmap |
| GestureRecognizerHandler.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而不是postDelayed,MediaPipe 本身已经按时间戳排序了结果,你不需要再自己维护队列。很多人在这里做了双重缓存,反而导致手势延迟严重。识别器内部已经处理乱序帧,我们只需要把最后的结果丢到主线程。
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: List<Landmark>, 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: List<Landmark>, 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,如果抛异常就回退到 CPU:
fun 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 里累计帧数,每半秒算一次 FPS:
private 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( dirname='gesture_data', cache_dir='/tmp/cache' ) model = gesture_recognizer.GestureRecognizer.create( train_data=dataset, epochs=30, batch_size=16 ) model.export('gesture_recognizer.task')gesture_data下按手势名称建子文件夹,每个文件夹里放该手势的图片,每个手势至少准备 100 到 200 张,背景多变、角度多样,图片拍到手部和背景。训练导出的gesture_recognizer.task直接替换 assets 目录里的同名文件,Android 端代码一行都不用改。
第一次把手势数据采到模型里的时候,我只在桌前固定角度拍,结果换到走廊和会议室就全军覆没,数据多样性不够带来的掉点比模型结构问题更严重。后来强制自己在不同光照、不同背景、左右手都拍,识别率才真正稳定下来,这是我在手势识别项目里最大的教训。希望这篇笔记能帮你少走这一步弯路。
本文还有配套的精品资源,点击获取