最近在做人体姿态估计的项目时,我把YOLOv8-Pose、YOLO11-Pose和最新的YOLO26-Pose全部在Android端跑了一遍。做完之后最大的感受是:市面上讲YOLO训练的文章很多,但真正把“部署到手机”这件事讲透的很少。手机上跑姿态估计和服务器上完全是两回事——算力有限、内存紧张、还要兼顾实时预览和功耗,稍微一个细节没处理到位,帧率就能从30掉到10。
这篇文章我会把所有踩过的坑、优化过的点、以及最终能直接复用的源码结构全部整理出来。内容覆盖模型导出、量化选择、Android工程搭建、JNI封装、CPU/GPU双后端切换、常见问题排查,全程基于真实项目经验,不是那种照搬官方文档的教程。无论是刚接触移动端推理的新手,还是想在现有项目里加入姿态检测的Android开发者,都能从这里找到直接能用的方案。
1. 项目定位与整体技术方案
1.1 为什么选择YOLO-Pose做端侧人体关键点检测
人体关键点检测的本质是回归问题,要同时输出目标的包围框和每个关键点的坐标。传统的OpenPose系列精度不错,但模型体量大、后处理复杂,在手机端做实时推理非常吃力。YOLO-Pose系列的思路是把它演变成单阶段检测任务,在同一个特征图上既预测边界框,又预测每个人17个关键点的坐标和可见性,结构上天然适合端侧。
实际测试下来,YOLOv8-Pose在麒麟芯片和骁龙芯片上都有不错的表现。如果只追求更高精度,YOLO11-Pose和YOLO26-Pose在COCO keypoint验证集上的AP值都有提升,但对部署来说API结构无太大区别,核心都是输出一个形状为[1, 56, 8400]的特征张量(以输入640×640为例),其中56通道包含了4个框坐标 + 17个关键点 × 2个坐标 = 38个参数,剩余的是类别和关键点可见性置信度。理解这个输出格式是全部后处理代码的基础。
我选择这套方案的另一个原因是部署生态成熟。无论是NCNN、MNN还是OpenCV DNN,都支持ONNX导出的模型直接转换,不需要非常底层的算子适配。对比OpenPose那套复杂的PAF后处理,YOLO-Pose的decode逻辑用循环加数组操作就能写清楚,在JNI层实现也容易。
1.2 YOLOv8-Pose、YOLO11-Pose、YOLO26-Pose 怎么选
很多人会纠结版本,我直接给结论:如果手机内存偏低或芯片老,用YOLOv8n-pose,它的参数量只有3.3M左右,INT8量化后模型体积在6MB上下,iPhone 11之后的设备和骁龙865以上的Android机都能跑到30FPS以上。如果用的是中高端机型,YOLO11s-pose或YOLO26-pose-s更合适,精度提升明显,但对应的FP16模型体积也在40MB左右,内存占用和推理耗电需要接受。
我实际测试的版本对比可以参考下面这张表:
| 模型 | 参数量 | 关键点AP (COCO) | 模型格式体积 | 手机端表现 |
|---|---|---|---|---|
| YOLOv8n-pose | 3.3M | 50.4 | ONNX FP16 约12MB | 中低端流畅,精度够用 |
| YOLOv8s-pose | 11.7M | 60.0 | ONNX FP16 约46MB | 中高端流畅,精度优秀 |
| YOLO11n-pose | 2.9M | 53.7 | ONNX FP16 约11MB | 中低端流畅,精度略高 |
| YOLO11s-pose | 9.4M | 58.6 | ONNX FP16 约37MB | 中高端流畅 |
| YOLO26s-pose | 约9.8M | 约60+ | ONNX FP16 约39MB | 中高端流畅,精度优秀 |
手机上部署不需要盲目追新,关键看芯片预算。如果你的目标是实时预览并画骨架,n系列足够;如果要用于运动姿态分析这类偏专业场景,建议上s系列结合GPU后端跑。
1.3 一套代码如何同时覆盖CPU和GPU
Android端的推理引擎我最终锁定了MNN和NCNN双后端,代码层面做了一层抽象,这样可以灵活切换。MNN的GPU后端基于OpenCL,在Adreno和Mali GPU上优化得不错;NCNN的Vulkan后端在GPU上的兼容性也还可以,但老旧机型偶尔会出现初始化失败的问题,所以我把CPU后端作为默认保险选项。
整体链路设计是:摄像头通过CameraX拿到帧数据,转成RGBA位图后喂给JNI层;JNI层将图像缩放到模型输入尺寸,做RGB通道转换和归一化,然后交给推理引擎;推理结束后回到Java层解析关键点坐标,再把坐标映射回预览画面尺寸,最后用Canvas绘制骨架。这个过程中最容易丢性能的在图像缩放和格式转换上面,后面我会专门展开讲。
2. 模型准备:从PyTorch权重到端侧可运行文件
2.1 环境依赖与加速方案
导出模型我建议直接在Linux或Windows的Python环境里做,不需要GPU也能完成,但转换量大时还是弄个GPU方便。基础依赖是:
pip install torch torchvision onnx onnxruntime ultralytics opencv-python这里有个常见误区:很多人直接用pip install ultralytics之后跑yolo export导出,但ONNX版本和torch版本不匹配时会导致后续转MNN报告算子不支持。我的经验是固定onnxruntime==1.17.1配合torch==2.1.2,这套组合在导出YOLOv8/YOLO11系列时最稳。
导出Pose模型的核心命令如下:
yolo export model=/path/to/yolov8n-pose.pt format=onnx dynamic=False imgsz=640 opset=12 simplify=True注意simplify=True这一步很关键,它会用onnx-simplifier剔除很多冗余的shape操作和Transpose节点,转换出来的模型在MNN/NCNN里通过率更高。如果漏了simplify,后面推理时经常报“不支持算子”,排查起来特别痛苦。
2.2 输出层分析与后处理定义
从Ultralytics导出的Pose ONNX模型输出形式需要自己做“切片”后处理,但展开用在手机端也合适。[1, 56, 8400]的前4个通道是目标的cx, cy, w, h值,后面38个通道是17个关键点的x, y坐标——注意这里不是直接输出关键点的绝对坐标,而是相对当前预测框区域的偏移量,需要结合stride和anchor还原。
很多人在手机上接这种模型时容易忽略的一点是:YOLOv8-Pose的预测框坐标在导出时已经经过decode,不需要再做复杂的anchor匹配;但关键点坐标仍然是网格相对值,必须用如下方式还原:
float cx = output[0]; float cy = output[1]; float w = output[2]; float h = output[3]; for (int k = 0; k < 17; k++) { float kp_x = output[4 + k * 2]; float kp_y = output[4 + k * 2 + 1]; kp_x = kp_x / 2.0f + cx; kp_y = kp_y / 2.0f + cy; }这里的/2.0f是为了把关键点偏移量从特征图尺度映射回输入图像的相对位置。如果这一步没做对,你会看到关键点全部挤在框的中心附近,怎么调阈值都没用。
2.3 INT8 / FP16 量化策略
模型体积和速度对手机端影响很大,量化是绕不开的环节。但不同引擎的量化流程差别很大,我分别说。
MNN支持FP16和INT8量化。FP16比较简单,模型体积减半,精度几乎不损失,大部分主流SoC都支持FP16推理加速。INT8需要用校准集做量化,MNN的量化工具会统计每一层激活值的分布,校准集建议从你的目标场景中采集,比如做健身姿态检测就采集健身动作,做跌倒检测就采集日常活动,随便从COCO里面抽图效果往往不理想。
python /path/to/mnn/build/MNNConvert \ -f ONNX \ --modelFile yolov8n-pose.onnx \ --MNNModel yolov8n-pose_fp16.mnn \ --fp16 python /path/to/mnn/build/MNNConvert \ -f ONNX \ --modelFile yolov8n-pose.onnx \ --MNNModel yolov8n-pose_int8.mnn \ --quantifyModel data/calibrate_images/NCNN同理,ncnnoptimize之后再用ncnn2table生成量化表,最后生成int8模型。但我在NCNN上遇到过一个坑:直接对YOLO-Pose的输出张量做INT8量化后,关键点坐标的精度波动比较大,尤其是手部和脚部位置,容易出现左右抖动。后来我改用“部分层量化”——保留最后几层用FP16推理,只在主干卷积上用INT8,效果好很多。如果你的应用对关键点稳定性要求高,建议优先用FP16模型,不要盲目上全INT8。
3. Android工程搭建与源码解读
3.1 工程结构与选型
整个工程我建议用Kotlin写UI层,Java或C++写核心推理层。最终的源码目录结构如下:
app/ ├── src/main/ │ ├── java/com/example/poseengine/ │ │ ├── MainActivity.kt # 相机与UI逻辑 │ │ ├── CameraAnalyzer.kt # 帧回调,图像格式转换 │ │ ├── PoseOverlayView.kt # 绘制骨骼与关键点 │ │ └── NativePoseDetector.kt # JNI封装 │ ├── cpp/ │ │ ├── pose_detector.cpp # 推理和后处理核心 │ │ ├── mnn_backend.cpp # MNN后端实现 │ │ ├── ncnn_backend.cpp # NCNN后端实现 │ │ └── CMakeLists.txt │ └── jniLibs/ │ ├── arm64-v8a/ # 主流Android 64位架构 │ └── armeabi-v7a/ # 老设备兼容关于架构,现在应用市场基本只需要发布arm64-v8a,如果需要兼容老设备才保留armeabi-v7a。注意,MNN和NCNN的armeabi-v7a库体积更大,而且GPU优化的效果远不如arm64-v8a,所以不推荐32位设备上跑Pose模型。
3.2 CMake配置与JNI桥接
CMake是接入原生代码的关键。下面是一个能编译通过的简化配置:
cmake_minimum_required(VERSION 3.15) project(poseengine) set(CMAKE_CXX_STANDARD 17) # MNN add_library(mnn STATIC IMPORTED) set_target_properties(mnn PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/jniLibs/${ANDROID_ABI}/libMNN.so) include_directories(${CMAKE_SOURCE_DIR}/include) target_link_libraries(poseengine mnn ncnn log android jnigraphics OpenCL)注意这里链接了jnigraphics,是因为读取Android Bitmap的像素数据时,直接使用AndroidBitmap_lockPixels函数能避免一次昂贵的拷贝,这个细节对实时性影响很大。
JNI层的核心方法签名如下:
extern "C" JNIEXPORT jlong JNICALL Java_com_example_poseengine_NativePoseDetector_nativeCreate (JNIEnv *env, jobject thiz, jstring modelPath, jint backendType); extern "C" JNIEXPORT jobjectArray JNICALL Java_com_example_poseengine_NativePoseDetector_nativeDetect (JNIEnv *env, jobject thiz, jlong handle, jobject bitmap, jfloat threshold);核心检测方法直接传Bitmap进去,C++层做像素读取和缩放转换,避免Java层把Bitmap转成ByteArray再传一次。实测下来这种方式比传统做法省掉约5~8毫秒的帧处理时间。
3.3 相机预览、坐标映射与绘制
CameraX预览分析器的回调中,图片默认是YUV_420_888格式。如果把YUV先转成Bitmap再传给JNI,会有额外开销。我的优化思路是分两条路走:预览画面直接用SurfaceView/TextureView显示原图;分析分支在Java层只做一次NV21转RGBA,且转换尺寸直接缩到模型输入尺寸的2倍(如640×360),再交给JNI缩放。这块数学很直接:1ms的旋转花销能省掉一整轮RGB通道重排。
绘制层用的是自定义View,在onDraw里画框、画点、连线。映射关系很直观:模型输出坐标是640×640下的,Preview尺寸是画面尺寸,只要做一个比例映射:
val scaleX = overlayView.width / 640f val scaleY = overlayView.height / 640f point.x * scaleX to point.y * scaleY注意CameraX的ImageAnalysis.setOutputImageRotationEnabled不会自动旋转ImageProxy,必须在分析前根据相机方向做旋转,否则模型看到的是横的图,输出坐标全乱了。我的做法是在JNI层用旋转标志控制像素搬运顺序,而不是在Java层先旋转Bitmap。旋转标志从ImageProxy.imageInfo.rotationDegrees获得。
4. CPU/GPU推理优化实录
4.1 线程策略与功耗平衡
CPU推理的线程数不是越多越好。我实测过:
| 线程数 | 骁龙8 Gen2 FPS | 发热表现 |
|---|---|---|
| 1 | 12 | 无明显发热 |
| 2 | 18 | 轻微温热 |
| 4 | 23 | 温热 |
| 8 | 19 | 明显发热,且掉帧 |
多线程超过4个之后,内存带宽成为瓶颈,线程切换开销反而拖慢速度。建议运行时读取设备核心数,设置min(4, availableCores - 1)。另外要注意,MNN的MNN::BackendConfig支持设置功耗模式,选BackendConfig::Power_Low虽然会牺牲一点性能,但能避免长时间运行后系统触发温控降频,综合体验反而更好。
到这里已经有实操干货了,但还不够,我还要补充NCNN的处理方式:NCNN的ncnn::Option opt.num_threads = 4;同样能控制线程,在GPU后端开启时还要设置opt.precision = 1启用FP16存储,否则OpenCL数据往返会消耗大量带宽。
4.2 GPU加速到底能快多少
GPU加速对YOLO-Pose的效果非常明显,但它的加速点不是“所有算子”,而是卷积和矩阵运算密集的部分。我在Adreno 740上测试:
| 后端 | YOLOv8n-pose 640输入 FPS | 备注 |
|---|---|---|
| CPU 4线程 | 23 | 最稳,无兼容问题 |
| MNN OpenCL GPU | 33 | 有明显加速 |
| NCNN Vulkan GPU | 31 | 部分机型初始化偏慢 |
GPU推理的常见毛病是“首次慢、后续快”:OpenCL kernel编译需要时间,第一次推理可能花费数百毫秒。解决办法有两个,一是在后台线程提前预热,二是在应用启动时就启动一个空跑推理,把kernel编译问题转移给用户感知不到的阶段。
MNN的GPU切换到FP16推理需要配置:
MNN::BackendConfig config; config.precision = MNN::BackendConfig::Precision_High; // FP16 config.power = MNN::BackendConfig::Power_High; mNet->setBackendConfig(config); mSession = mNet->createSession(MNN::BackendConfig::MNN_GPU_OPENCL);如果你测试发现GPU反而比CPU慢,九成原因是模型输入输出走了多次GPU到CPU的拷贝。YOLO-Pose的输出最终要在CPU上做后处理,所以只在推理中间层用GPU,输出数据统一搬回内存只做一次,这个点尤其重要。
4.3 实测FPS横评
我用同一台机器(骁龙8 Gen2 + 12GB内存)把三个模型分别跑了一遍,记录下CPU 4线程和MNN OpenCL的结果,这是原始测试数据:
| 模型 | CPU 4线程 FPS | GPU OpenCL FPS | 内存占用峰值 |
|---|---|---|---|
| yolov8n-pose-fp16 | 23 | 33 | 412MB |
| yolov11n-pose-fp16 | 25 | 36 | 405MB |
| yolov26s-pose-fp16 | 9 | 13 | 628MB |
| yolov8s-pose-int8 | 13 | 20 | 398MB |
如果做姿态识别App,我的建议很明确:中低端机用yolov8n或yolo11n配合FP16,高端机用yolo11s或yolo26s配GPU。INT8在内存占用和功耗上确实更好,但关键点精度下跌在运动分析场景里受不了,反正我最终的产品版本是FP16。
5. 常见问题排查与精度调试
5.1 关键点位置偏移的排查
现象:框的位置大体正确,但关节全画在体外一格左右。这种问题大多出在仿射变换上。相机预览比例一般不是640×640,做letterbox时如果不记录填充偏移量,直接把模型输出的坐标映射回原图,关键点必然偏。
我排查这个问题的标准流程是:
- 先打印模型裸输出,看坐标是不是在0~1或0~640范围内;
- 检查letterbox的
left offset和top offset是否在还原坐标时正确加回; - 检查关键点解码时是否除以了输入图像的stride。
还有一个隐蔽的原因是模型输入图像的通道排列。Android Bitmap默认是ARGB格式,而模型要的是RGB。如果直接拿ARGB的字节数组往里灌,模型看到的是像素通道完全错乱的颜色。虽然不会导致崩溃,但检测置信度会断崖下跌。我在JNI层用cvtColor(RGBA2RGB)处理一遍,速度影响很小但效果立竿见影。
5.2 漏检与误检优化
如果你发现单张图像中的人偶尔检不出来,首先避免用背光、场景过曝的图测试。模型对前背景分离敏感,实际App里最常见的漏检是目标太远、框太小。YOLO的输入分辨率决定了检测精度,640下一个小目标只有十几个像素,换512输入就更少了。我做的第一个折中是保留640输入,再开一个“高精度模式”:把ROI区域裁剪放大后再推理一次,或者提高置信度阈值并调低NMS阈值,看哪边更适合你的业务。
误检则往往是阈值设置过低。YOLO-Pose输出里包含“类别置信度”和“关键点可见性”两个标签,建议分开设置阈值。我常用的参数是:
boxThresh = 0.45f kpThresh = 0.35f nmsThresh = 0.5f另外注意,YOLO-Pose的NMS逻辑和普通目标检测不一样——两个框的IoU冲突时用的是“框与关键点联合metric”而不是纯框IoU,所以不要把检测器输出的置信度和后处理阈值混在一起调。
5.3 崩溃、黑屏和内存暴涨速查表
把自己实战中遇过的典型问题整理成速查表,先列在这:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 初始化就崩 | JNI找不到符号,加载so失败 | 检查System.loadLibrary("poseengine")顺序,查看Android.mk/CMake中库名是否对应 |
| 黑屏 | 只看到背景,无原图 | CameraX preview provider使用错误,检查PreviewView绑定生命周期 |
| GPU后端闪退 | 设备不支持OpenCL | BackendType回退到CPU,捕捉createSession异常 |
| 内存暴涨 | 64MB很快用完 | 会话复用而不是每次推理重新createSession;Bitmap采用inMutable=false,用完立即recycle |
| 第一次启动特别卡 | 模型加载慢,算子Precompile | 提前在App启动初始化阶段done,用异步线程预热 |
| 姿态闪跳 | 单帧结果不稳定 | 叠加关键点平滑滤波,如EMA、卡尔曼,或直接上次结果加权平均 |
第二张表放在这,我自己调试期间基本天天扫一眼,遇到问题先看表格再改代码,比大海捞针强很多。
6. 源码运行与后续扩展建议
6.1 如何把源码直接跑起来
拿到源码后的第一步不是急着编译,而是先确认两样东西:Android SDK/NDK版本,以及MNN/NCNN库的ABI是否放到了jniLibs目录。我的项目默认用Java 17 + NDK 25.2.9519653,AGP版本8.2.2,Gradle 8.2。如果你的开发环境比较老,先把Gradle版本往后减一减,避免JNI库用新版clang编译在老SDK上报错。
编译成功后,把训练好的PT模型按照前面提到的转换流程导出MNN格式,放到app/src/main/assets/models/目录。MainActivity里写好模型路径常量,默认读取"models/yolov8n-pose_fp16.mnn"。它一进界面就会自动加载并显示FPS和耗时统计。
建议先在模拟器上只跑CPU后端验证流程,再上真机开GPU。Android模拟器不支持OpenCL,有时候直接把设备当作参考标准,会得出“GPU加速没用”的错误结论。
6.2 姿态结果如何与业务结合
关键点检测只是最底层的能力,实际业务层玩法很多。最常见的场景是:
- 运动姿态评估:根据关节角度判断深蹲是否标准,这就是计算髋、膝、踝三点夹角;
- 跌倒检测:利用躯干中心点下降速度和地面距离判断跌倒报警;
- 手势控制:用腕关节点相对肩关节点的高度变化识别抬手动作;
- 虚拟形象驱动:把17个关键点映射到3D角色骨架上做实时驱动。
拿关节角度来说,只要知道相邻关键点坐标,通过余弦定理计算即可。例如计算肘角度,用肩、肘、腕三点:
val angle = Math.toDegrees( acos( ((shoulder.x - elbow.x) * (wrist.x - elbow.x) + (shoulder.y - elbow.y) * (wrist.y - elbow.y)) / (dist(shoulder, elbow) * dist(elbow, wrist)) ) )这类后处理逻辑建议放在Kotlin层而不是JNI层,方便调试也能减少原生代码的修改量。小技巧:在做角度判断时,先做三帧平滑再算角度,能有效避免单帧波动带来的误判。
6.3 再聊几句折腾出来的经验
这些项目做完后,我个人最大的体会是:部署模型的难度不在算法,而在工程细节。模型转化、通道排列、坐标映射、内存复用,每一样都像是谷歌在路线图上给你埋好的地雷,不踩一次根本不知道原来还有这种坑。特别是YOLO-Pose这种同时输出框和关键点的模型,调试时如果只看精度不管内存,做出Demo很容易,做成一个能长时间运行而不被系统杀掉的App,难度就上来了。
后面我计划把工程再完善一下,加上TensorRT部署的PC端版本,以及一个基于滑窗的人体平移追踪策略,尽量做到端侧姿态分析一套方案打通。如果各位在实际部署中也碰到什么古怪问题,欢迎带着数据和日志来讨论,遇到过的坑交流起来比单打独斗有意思得多。