资讯动态

手部关键点检测Android Demo:模型转换、量化与端侧部署全解析

发布时间:2026/10/15 17:09:53 来源:尧图企业网站定制
简介面向Android开发者与AI算法学习者的手部关键点检测演示安装包基于深度模型实现手部姿态估计在Android手机上安装后即可实时调用摄像头完成手部关节点定位与姿态识别快速验证移动端检测效果。压缩包内共2个文件一个APK安装文件用于直接安装运行一个JSON元数据文件记录构建版本与变体信息整体大小65.98MB部署门槛低无需额外编译配置。该Demo适用于手部姿态估计效果评估、手势交互Demo开发、课堂演示及算法预研等场景安装包内集成推理流程便于开发者直观感受模型在手机端的运行速度与精度。配套的Pytorch训练、Android源码和C实现等系列技术文章已在作者博客中详细展开帮助从体验Demo平滑过渡到模型训练、源码移植与工程级实现。目前已有2380人学习下载是快速上手手部关键点检测并评估移动端方案可行性的直接参考资料。1. 手部关键点检测 Android Demo它到底是什么值不值得花一周去碰手部关键点检测 Android Demo是我见过的入门 AI 端侧落地最典型、也最容易中途翻车的一个方向。它要解决的事情听起来很窄让手机摄像头实时识别出一只手给出 21 个关键点在画面里的坐标再把点和点连成手骨架画在屏幕上。但这个目标一旦拆开就牵扯到模型选型、数据集格式转换、模型量化、端侧推理、图像预处理、前后摄像头镜像、性能优化一整条链路几乎每个环节都有能让你卡一整天的暗坑。对想从服务端模型转做移动端推理的开发者来说这是性价比很高的练习对想评估手部交互在自家 App 里可行性的团队来说它也能用最短路径回答“能跑多少帧、精度够不够、包体涨多少”这三个问题。2. 模型选型与数据准备21 个点的坐标是从哪里来的2.1 两阶段检测与单阶段回归两条技术路线的取舍手部关键点检测的主流做法分两种。一种是先检测手部边界框再在框内回归关键点即先检测后对齐的两阶段方案。这种方案精度上限高因为第二步只看手部裁剪区域背景干扰小但代价是模型多、流程长、端侧延迟累加明显。另一种是单阶段方案直接在一张图上同时输出手部中心和关键点偏移模型只有一个推理更快但在多人场景或手掌较小的画面里精度容易崩。如果做的是单人单手交互类 Demo我一般会建议选单阶段轻量模型配上裁剪跟踪或者直接落地已经封装好的手部关键点检测框架先跑通再替换。这样做的原因是 Android Demo 的核心目标是验证交互链路而不是追求多手鲁棒性。真要多手场景再去考虑两阶段方案那就需要引入目标检测模型先框手再把框送入关键点模型GPU 委托不做好很难上 30 帧。2.2 数据标注格式公开数据集的标注顺序未必是你需要的关键点检测模型训练前先要把标注数据统一成同一种关键点顺序和坐标归一化方式。常用公开手部数据集的标注顺序是固定的 21 点0 是腕部1 到 4 是拇指5 到 8 是食指9 到 12 是中指13 到 16 是无名指17 到 20 是小指。这个顺序在训练、转换、端侧解析三个环节必须完全一致只要差一个索引画出来的手指就会拧成麻花。自己标注时我常见到有人用标注工具导出 JSON 格式的折线点坐标是图像像素值而模型训练输入往往是 256x256 的归一化坐标所以中间需要一个转换脚本。这里最容易踩的坑是标注工具导出的关键点顺序是画的时候的顺序比如有人从拇指画到小指这个顺序和模型预期的 COCO 顺序完全不一致。转换脚本里必须先做一步索引映射否则训练过程不会报任何错但评估时精度会异常低。import json import numpy as np # 标注工具导出的顺序依次为 腕、食、中、无、小、拇 tool_order [wrist, index, middle, ring, pinky, thumb] # 模型需要的 COCO 顺序 model_order [wrist, thumb, index, middle, ring, pinky] def remap_keypoints(src_kpts, src_names, dst_names): # 建立名称到索引的映射 name_to_idx {name: i for i, name in enumerate(src_names)} remapped [] for name in dst_names: remapped.append(src_kpts[name_to_idx[name]]) return np.array(remapped, dtypenp.float32) # 示例把单张图的标注做重排 with open(hand_annotation.json) as f: ann json.load(f) kpts remap_keypoints(ann[keypoints], tool_order, model_order) print(kpts.shape) # (6, 2)这段脚本先建立名称到索引的映射再按目标顺序逐个取点防止手写一串魔法数字。注意 remap_keypoints 的输入 src_kpts 是原始坐标数组dst_names 是目标顺序两个列表必须覆盖完全相同的点集合否则会漏点或越界。实际项目中这一步还会顺带做坐标归一化和 ROI 裁剪但核心逻辑就是这一条索引映射。2.3 训练配置与损失函数关键点回归别只看 MSE关键点坐标回归最常用的损失函数是 MSE但手部关键点有个特殊性关节活动范围大不同手指的位移尺度差异明显拇指根部关节的移动范围远大于指尖附近的小关节。直接对归一化坐标做 MSE模型会偏向预测大位移的关节导致指尖在姿态变化大时抖动明显。我常用的替代方案是 Wing Loss 或 L1 Loss 配上热力图回归。热力图方式是让模型输出一张 64x64 的特征图每个通道对应一个关键点的高斯响应推理时取响应最大的位置作为坐标。这个方案在端侧有一点额外开销但精度稳定性和跨设备一致性更好。import torch.nn as nn class WingLoss(nn.Module): def __init__(self, omega10, epsilon2): super().__init__() self.omega omega self.epsilon epsilon def forward(self, pred, target): x pred - target abs_x x.abs() mask abs_x self.omega loss torch.where( mask, self.omega * torch.log(1 abs_x / self.epsilon), abs_x - self.omega ) return loss.mean()Wing Loss 的核心逻辑是误差小于阈值 omega 时用对数形式放大梯度误差大于阈值时退化为 L1。这里 omega 设为 10、epsilon 设为 2 是我在单手握拳和张开指尖这两种极端姿态下试出来的经验值。如果只做食指点击这类单一交互可以把 omega 调小到 5让模型更敏感于小误差。3. 模型转 TFLite量化模型时最容易丢精度的三个参数3.1 从检查点导出 TFLite不是所有模型都能一步转换训练完成后模型要转成 TFLite 才能在 Android 上跑。转换脚本本身不长但每一步都有变量。先把训练好的模型导出为 SavedModel再走 TFLiteConverter。如果训练框架是 PyTorch就需要先导出为 ONNX再转 TFLite中间的算子映射是第一个翻车点比如某些动态 shape 的算子没法直接转。import tensorflow as tf # 从 SavedModel 转换saved_model_dir 是训练导出目录 converter tf.lite.TFLiteConverter.from_saved_model( saved_model_direxports/hand_landmark ) # 默认优化优先考虑模型大小 converter.optimizations [tf.lite.Optimize.DEFAULT] # 指定输出浮点类型为 float16 converter.target_spec.supported_types [tf.float16] # 设定输入输出张量 shape避免动态维度 converter.input_shape (1, 256, 256, 3) tflite_model converter.convert() with open(hand_landmark_fp16.tflite, wb) as f: f.write(tflite_model)这段脚本里saved_model_dir 指向训练框架导出的模型目录optimizations 为 DEFAULT 表示让转换器做默认的大小优化。target_spec.supported_types 设为 float16 意味着权重精度降到半精度模型体积接近减半在支持 FP16 的设备上推理速度更快。input_shape 这一行很关键有些模型的输入维度是动态的不小固定下来转换出的模型在 Android 端会频繁报维度不匹配。3.2 动态范围量化与全整数量化校准集决定精度下限float16 转换只是第一步。如果想进一步压缩体积就得做动态范围量化或全整数量化。动态范围量化会把权重从 float32 压到 int8但激活值保持浮点全整数量化连激活值也一起量化运行时需要一张有代表性的数据做校准校准数据的分布直接决定量化后模型的精度。converter.optimizations [tf.lite.Optimize.DEFAULT] converter.target_spec.supported_types [tf.int8] # 校准数据集从训练集随机抽 200 张 def representative_dataset(): import random sample_indices random.sample(range(len(train_images)), 200) for idx in sample_indices: img train_images[idx].astype(np.float32) img img / 255.0 img np.expand_dims(img, axis0) yield [img] converter.representative_dataset representative_dataset tflite_model converter.convert()校准数据集负责统计每一层激活值的数值范围数值范围统计得不准量化后关键点坐标会整体漂移比如手指画出来的位置和实际位置偏移好几个像素。这里建议校准集覆盖多种肤色、多种手部姿态、不同光照而不是只从训练集开头顺序抽否则推理时遇到没见过的亮度分布很容易精度骤降。校准集数量 200 到 500 张之间比较合适太少了统计不稳定太多了转换时间成倍增加。3.3 转换后必须做的模型对比验证别只看准确率转换完模型很多人直接丢进 Android 工程就开始调界面等真机跑通了再回来看精度已经晚了。我习惯在转换后立刻用它跑一组相同的输入把 TFLite 的输出和原始模型的输出直接对比。import numpy as np import tensorflow as tf # 加载转换后的模型 interpreter tf.lite.Interpreter(model_pathhand_landmark_fp16.tflite) interpreter.allocate_tensors() input_details interpreter.get_input_details() output_details interpreter.get_output_details() # 读入一张训练时的预处数据 sample np.load(sample_input.npy).astype(np.float32) interpreter.set_tensor(input_details[0][index], sample) interpreter.invoke() tflite_output interpreter.get_tensor(output_details[0][index])这个脚本在 PC 上就能跑不需要手机。对比 TFLite 输出和原模型输出如果最大坐标误差超过 5 个像素就要回头调整量化策略。常见情况是 float16 转换后误差很小全整数量化后误差变大此时优先检查 representative_dataset 的采样分布而不是急着改模型结构。还有一种情况是输出张量的顺序变了转换器可能会重排输出拿到 Android 端才发现索引对不上。4. Android 端部署Interpreter 与 Task 库怎么选4.1 Gradle 依赖与工程结构原生 Interpreter 还是 Task LibraryAndroid 端接入 TFLite 有两条路。一条是直接依赖 TFLite Runtime 和 Interpreter API自己管理输入输出张量另一条是使用 Task Library 封装好的 HandLandmarker几行代码就完成加载和推理。两条路我都实际用过各自边界很清楚Task Library 适合快速验证几行代码拿到 21 个点原生 Interpreter 适合精细控制比如自己处理多帧缓冲、自己决定预处理顺序、自己接 GPU 委托。// build.gradle (app 模块) dependencies { // 原生 Interpreter 方式 implementation org.tensorflow:tensorflow-lite:2.14.0 implementation org.tensorflow:tensorflow-lite-support:0.4.4 // 可选NNAPI 和 GPU 委托 implementation org.tensorflow:tensorflow-lite-gpu:2.14.0 }Task Library 方式倒是简单dependencies { implementation com.google.mediapipe:tasks-vision:0.10.14 }两个方式对比Interpreter 路线的输入输出完全由自己控制便于处理前置摄像头的镜像和旋转Task 库路线的优势在于内部已经处理好归一化和坐标换算拿到就是像素坐标但自定义能力弱一旦模型不是它的标准输出结构就得放弃。4.2 输入预处理顺序旋转、缩放、归一化谁先谁后Android 摄像头出来的原始帧是 YUV 格式转为 Bitmap 之后还有旋转和镜像的问题。常见做法是先把预览帧裁剪成正方形再缩放成模型输入尺寸。这里的顺序不能乱先做中心裁剪再旋转旋转后 Bitmap 尺寸会变化先旋转再裁剪又会丢画面内容。我一般在 LockCamera 的分析回调里先做镜像翻转再旋转到竖屏方向最后按短边中心裁剪。// 前置摄像头先镜像再按中心裁剪为正方形 fun preprocessBitmap(bitmap: Bitmap, inputSize: Int): Bitmap { val matrix Matrix() matrix.postScale(-1f, 1f) // 镜像翻转 val mirrored Bitmap.createBitmap( bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true ) val side min(mirrored.width, mirrored.height) val left (mirrored.width - side) / 2 val top (mirrored.height - side) / 2 val cropped Bitmap.createBitmap(mirrored, left, top, side, side) val scaled Bitmap.createScaledBitmap(cropped, inputSize, inputSize, true) return scaled }这个函数的核心是先用 Matrix 完成镜像再做中心裁剪和缩放。注意 createBitmap 在传入 Matrix 时返回的新 Bitmap 尺寸可能和原图不一致因此裁剪坐标要用 mirrored 的宽高去算。如果漏掉镜像在屏幕上画出来的关键点会和真实手左右相反看起来像在照镜子但手势识别和坐标记录全部错位。4.3 输出张量解析从热力图到 21 个坐标的转换模型输出常见两种形式直接输出 21 个点的归一化坐标或者输出热力图。坐标形式解析简单直接乘以输入尺寸即可热力图形式解析麻烦一点要先做 argmax 找最大响应位置再换算。fun parseHeatmap( heatmap: ArrayArrayArrayFloatArray, inputSize: Int ): ListPairFloat, Float { // heatmap 维度: 1 x H x W x numPoints val h heatmap[0].size val w heatmap[0][0].size val numPoints heatmap[0][0][0].size val points mutableListOfPairFloat, Float() for (p in 0 until numPoints) { var maxVal -1f var maxX 0 var maxY 0 for (y in 0 until h) { for (x in 0 until w) { val v heatmap[0][y][x][p] if (v maxVal) { maxVal v maxX x maxY y } } } val px maxX * inputSize / w.toFloat() val py maxY * inputSize / h.toFloat() points.add(px to py) } return points }这段代码是热力图解析的标准写法找每个通道的峰值位置。注意一个性能问题这个双层循环对每个关键点都要完整遍历一遍热力图21 个点在 64x64 分辨率下就是 21 次 4096 次遍历Kotlin 层慢一点能接受但如果你用的是 Java 纯循环帧率会掉得很难看。优化思路是先把热力图降到 32x32 再找峰值或者用浮点转字节后走 NDK但 Demo 阶段可以先不折腾。4.4 画关键点与连接线Canvas 绘制的性能陷阱拿到 21 个点的坐标后要把它们画在 Preview 上。大多数人第一版用的是 Paint 逐帧画圆和画线这里有个隐藏性能陷阱每帧都 new 一个 Paint 对象和 Path 对象GC 会被频繁触发画面看着就像一卡一卡的。class HandOverlayView(context: Context) : View(context) { private val pointPaint Paint().apply { color Color.GREEN style Paint.Style.FILL strokeWidth 4f } private val linePaint Paint().apply { color Color.YELLOW style Paint.Style.STROKE strokeWidth 3f } private val handConnections 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, 0 to 9, 9 to 10, 10 to 11, 11 to 12, 0 to 13, 13 to 14, 14 to 15, 15 to 16, 0 to 17, 17 to 18, 18 to 19, 19 to 20 ) var keypoints: ListPairFloat, Float emptyList() setWillNotDraw(false) override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (keypoints.size ! 21) return // 先画连接线 for ((a, b) in handConnections) { val p1 keypoints[a] val p2 keypoints[b] canvas.drawLine(p1.first, p1.second, p2.first, p2.second, linePaint) } // 再画关键点覆盖在线上面 for ((x, y) in keypoints) { canvas.drawCircle(x, y, 6f, pointPaint) } } }这段代码把 Paint 对象放在 View 初始化时创建onDraw 里只做 drawLine 和 drawCircle不会触发反复创建对象。注意 handConnections 列表定义的 20 条线对应 COCO 手部关键点的连接规则如果漏掉了 0 到 5、0 到 9 这些腕部到手指根部连线手掌会显得散架。这里的绘制坐标默认已经换算到 View 尺寸如果 Preview 和 Overlay 的尺寸不一致需要在 set 坐标时做一次映射。5. 手部关键点检测 Demo 避坑与排查5 个常见事故5.1 模型加载直接崩溃算子兼容性问题现象App 启动后加载 tflite 文件实例化 Interpreter 时直接抛异常或者首次推理时进程崩溃。原因TFLite Runtime 版本和模型导出时的算子不兼容。尤其是 PyTorch 转 ONNX 再转 TFLite 的模型某些自定义算子会被映射成 Flex 算子而 Android 端默认不包含 Flex 支持。解决先在 build.gradle 里换成tensorflow-lite完整版本而不是tensorflow-lite-base如果还是不行就在转换脚本里用converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS]强制只使用内建算子再逐个排查不支持算子改写模型里对应的层。5.2 画面左右反转前置摄像头镜像引发的手势错乱现象后置摄像头一切正常切到前置摄像头后关键点位置全部左右互换握拳检测成了张开手。原因前置摄像头的预览画面默认是镜像的但推理用的输入帧没有跟着镜像坐标和显示就反了。解决在摄像头分析回调里用 4.2 那段代码先做postScale(-1f, 1f)镜像再做后续处理。注意推理和绘制用同一份镜像后的 Bitmap不要在推理里用原始帧、绘制时又镜像两边一不一致坐标就全乱了。5.3 手一移动就检测不到关键点ROI 裁剪与跟踪缺失现象手静止时关键点画得很稳手一动或者快速挥手画面上关键点就消失或跳得很夸张。原因模型是在单帧上独立推理的没有利用时间维度上的连续性。手快速移动时运动模糊和画面截断都会让单帧质量急剧下降。解决加入简单的 ROI 跟踪。上一帧检测到的手部区域中心记为基准点下一帧先在这个区域附近做中心裁剪而不是全图推理。推理结果出来后用当前手的位置更新 ROI。这个策略能把快速挥手场景的掉帧率大幅降低。注意 ROI 要设置最大移动速度限制防止手突然移出画面后 ROI 追踪到背景上。5.4 量化后关键点漂移校准集代表性不足现象float16 模型画点很准切成 int8 量化模型后指尖位置普遍偏移 5 到 8 个像素拇指尤其明显。原因校准集里都是正对摄像头、光线充足、手部居中的样本没有覆盖侧手和背光场景。量化时统计的激活值范围把这些边缘样本排除在外面推理时就出现漂移。解决重新构造校准集把背光、侧手、快速移动这些场景各抽几十张混合后做校准。校准集不需要带标注只需要覆盖输入分布。改完校准集后重新量化一般能恢复大部分精度。5.5 偶发 ANR在 UI 线程跑推理现象应用运行几分钟后弹“系统无响应”Logcat 里出现 Input dispatching timed out。原因推理和画点放在了 Activity 主线程里。手部关键点模型即使轻量单次推理在低端机上也要 20 到 30 毫秒叠加相机回调、Canvas 绘制主线程就撑不住了。解决推理放到独立单线程的 HandlerThread 里在那个线程里完成 Bitmap 预处、Interpreter.invoke() 和坐标换算最后通过 onFrameAvailable 回调把坐标抛回主线程绘制。注意 Interpreter 实例不是线程安全的推理线程只能有一个不要在多个线程里同时 invoke。6. 用 NNAPI 和 GPU 委托做性能调优别让帧率卡在 CPU 上Demo 跑通之后下一步就是对着帧率表调参。我习惯先做一轮基线测试加载同一模型分别在纯 CPU、NNAPI 委托、GPU 委托三种模式下跑 200 帧统计平均推理耗时。常见的现象是 GPU 委托在 Adreno 芯片上能把单帧推理从 25 毫秒压到 8 毫秒但在 Mali 芯片上反而比 CPU 慢原因是部分算子没有 GPU kernel走了回退路径反而多一次内存拷贝。// GPU 委托示例 val options Interpreter.Options() val gpuDelegate GpuDelegate() options.addDelegate(gpuDelegate) val interpreter Interpreter(modelFile, options)NNAPI 委托的接入代码几乎一样只是把 GpuDelegate 换成 NnApiDelegate。需要特别注意的是这两种委托都要求模型内算子被完整支持一旦覆盖不全TFLite 会静默回退到 CPU帧率反而更差。排查方法是在转换时开启算子兼容性报告拿到哪一层不支持、为什么不支持再决定是改模型结构还是保留 CPU。做完委托调优后下一个验证点是关键点的稳定性。我见过帧率很高但画面里关键点抖动明显的模型帧率高并没有意义。验证方法是在固定场景下保持手静止观察连续 30 帧的指尖坐标标准差如果超过 2 个像素就说明输出没有做时序平滑。轻量级做法是加一个一阶低通滤波对每个关键点的 x、y 做指数移动平均alpha 取 0.4 到 0.6 之间。alpha 太小会显得手跟不上动作太大滤波器就没作用这个值得真机试样 3 到 5 轮才定得下来。最后说一个我踩过的坑调优初期我在高端旗舰机上把帧率跑到了 60就以为任务完成了结果换到中端机上掉到 18 帧才意识到 GPU 委托在低端设备上的驱动支持完全不同。从那以后我的验收标准改成“中端机维持 25 帧以上”而不是只看旗舰机数据。做手部关键点检测 Android Demo帧率、精度、包体三个指标必须拿中端机做基准否则上线验证时大概率返工。希望这篇笔记能帮你把这条链路一次走通少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑