先回答一个最常被问的问题:手机上到底能不能跑YOLO人体关键点检测?答案是能,而且不是实验室里的勉强能跑,是带着实时预览、CPU和GPU双引擎、还能稳定几十毫秒出结果的能跑。这篇文章就是把这条链路完整走一遍——从PyTorch权重开始,经过导出、转换、量化,到Android工程里通过NCNN和ONNX Runtime做推理,再到关键点解码、骨架绘制,最后给出我在不同处理器上的实测帧率。全程有源码,跟着步骤走就能在自己的设备上跑起来。
1. 从模型到产物:端侧姿态估计为什么先选型再动手
很多人在Android上部署YOLO,上来就找模型、抄代码,结果不是跑不起来,就是能跑但速度感人。我个人的经验是:端侧部署这件事,模型选型和引擎选型占整个项目70%的工作量,剩下才是写代码。所以先说清楚这三条模型线和两个引擎到底该怎么选。
1.1 YOLOv8-Pose、YOLO11-Pose、YOLO26-Pose的核心差异
这三个模型从名字看是版本迭代关系,但实际部署时要关注的不是版本号,而是结构、参数量、计算量和落地成熟度。
| 模型 | 参数量级 | 推理特点 | 适合场景 |
|---|---|---|---|
| YOLOv8-Pose | s版约11.2M | 资料最多,部署资料和踩坑经验最全 | 学习参考、稳定性优先 |
| YOLO11-Pose | 与v8相近或略轻 | 主干网络结构更新,精度略优,速度接近 | 中高配手机实时检测 |
| YOLO26-Pose | 更精简(约8M级) | 计算量更低,主打低算力设备 | 国产中低端机、低功耗场景 |
从实际跑出来的结果看,YOLOv8-Pose的优势不是性能最好,而是“确定性最高”:模型导出、坐标变换、后处理这些环节都有大量现成案例,遇到问题一搜就有答案。YOLO11-Pose的精度好一点点,但端侧部署资料少很多,比较依赖你自己看ONNX结构去调输出维度。YOLO26-Pose这条线更像为移动端量身做的,模型小、速度快,不过在老设备上的兼容性测试要做得更细。
如果只跑一个,我建议先用YOLOv8-Pose把整条链跑通,再换YOLO26-Pose对比速度和精度的落差,这样才能体会到端侧模型优化带来的实际体感。
1.2 CPU和GPU背后的运行机制差异
Android端的“CPU推理”和“GPU推理”,不是同一个框架的两套开关,而是完全不同的计算路径。
CPU路线走的是通用计算核心,比如NCNN的ARM优化代码,通过NEON指令集并行处理数据。每一帧的计算是低延迟的,单次耗时稳定,很少出现“某一次突然慢十倍”的情况。缺点是并行规模上限低,算大模型时会顶到功耗墙。
GPU路线走的是Vulkan或OpenCL计算接口。GPU的特点是并行执行单元多,适合矩阵密集运算,但数据要从CPU内存拷贝到GPU显存,推理完再拷回来,这来回拷贝的开销在输入尺寸只有320x320时往往占了大头。数据规模越大,GPU优势越明显;数据越小,反而可能因为拷贝开销输给CPU。
所以端侧部署的真实结论是:同一个模型,GPU未必比CPU快。实际选哪个,要看处理器、内存带宽、驱动状态综合决定,这也是后面第5部分要专门展开的原因。
2. 模型导出与FP16量化:把PyTorch权重变成Android能吃下的产物
这一部分是整个部署链路里最“理论”但翻车率最高的一步。很多人导出的模型在自己电脑上跑得好好的,放到Android上就全乱套,问题基本都出在导出时的参数上。
2.1 从PyTorch到ONNX,再到NCNN的完整转换
以官方ultralytics仓库为例,导出ONNX的命令很简单:
yolo export model=yolov8s-pose.pt format=onnx opset=12 dynamic=False imgsz=320几个参数我要特别说明一下:
opset=12:端侧引擎对ONNX算子支持有限,太高版本的opset容易引入Android引擎不认识的算子。固定死在12比较稳妥。dynamic=False:端侧部署不要开动态尺寸。动态Shape意味着每一帧都可能重新做内存分配和图优化,这在手机上是灾难。后面会统一做letterbox,固定输入尺寸完全够用。imgsz=320:这是输入尺寸的选择。640是YOLO的标准分辨率,但手机端特别吃内存和计算量。实际测试中,320x320对单人姿态检测完全够用,多人场景建议只跑到416。
拿到ONNX之后,转NCNN有两种路径:老的onnx2ncnn,和现在推荐的pnnx。pnnx的兼容性明显更好,遇到不支持的算子时还会保留成自定义层,而不是直接报错:
pnnx yolov8s-pose.onnx ncnnparam=yolov8s-pose.ncnn.param ncnnbin=yolov8s-pose.ncnn.bin转换完会生成.param和.bin两个文件,param是网络结构,bin是权重,Android侧加载的就是这两个文件。
2.2 FP16量化的意义和具体做法
模型转换出来是FP32,直接上手机有两个问题:体积大(YOLOv8s-Pose约23MB),计算慢(ARM的FP32算力利用率低于FP16)。
FP16量化的核心思路是:把权重和中间激活值从32位浮点压成16位浮点,体积减半,在支持FP16加速的芯片上(几乎所有现代手机SoC都支持)计算效率成倍提升。
NCNN里开启FP16量化有两种方式:
- 转换时直接用pnnx的
fp16=1参数,权重会在转换阶段就转成半精度。 - 运行时不改变权重,但把
opt.use_fp16_storage置为true,让NCNN在中间计算过程中采用FP16存储。
实测下来,两种方式叠加对精度的影响很小,关键点偏移通常不超过1-2个像素,对骨架绘制来说完全无感。但速度提升非常明显,尤其是GPU路线,FP16几乎是一倍以上的收益。
我最终的项目里用的是FP16权重加上运行时开启fp16 storage的组合,既省内存,又兼顾速度。如果你后续要追求极致体积,还可以考虑INT8量化,但关键点回归任务对量化误差比检测框更敏感,不建议端侧直接用INT8。
3. Android工程接入:从NDK配置到JNI推理
模型准备好之后,剩下的就是纯工程问题。这一步我的建议是:不要用市面上那些封装过度的SDK,自己用NCNN原生API写JNI包装层。只有自己控制数据流向,后面换模型、调性能时才有主动权。
3.1 工程目录和依赖准备
我的项目结构大致是这样:
app/src/main/cpp/ CMakeLists.txt pose_detector.cpp pose_detector.h app/src/main/java/com/example/posedetector/ MainActivity.java PoseDetector.java PoseResult.java app/src/main/assets/ yolov8s-pose.ncnn.param yolov8s-pose.ncnn.bin yolov11s-pose.ncnn.param yolov11s-pose.ncnn.binNCNN的接入方式可以直接在build.gradle里声明依赖,也可以把NCNN源码编进工程。我这里用的是预编译包加CMake链接的方式:
dependencies { implementation 'com.tencent.ncnn:ncnn:20240820' }然后在CMakeLists.txt里引入:
set(ncnn_DIR ${CMAKE_SOURCE_DIR}/ncnn-20240820-android-vulkan/lib/cmake/ncnn) find_package(ncnn REQUIRED) target_link_libraries(pose_detector ncnn)注意,NCNN的预编译包分两个版本:带vulkan的和不带vulkan的。如果后面要跑GPU,CMake里一定要链接带Vulkan的版本,否则运行时一开GPU开关就会直接报错。
3.2 预处理:letterbox的实现和原理
YOLO系列输入是正方形图,但手机相机的预览帧几乎都是4:3或16:9,直接resize成正方形会把目标拉变形,检测精度暴跌。正确做法是letterbox——等比缩放后补边。
C++侧的实现:
void letterbox(const ncnn::Mat& src, ncnn::Mat& out, int target_w, int target_h, float& scale, float& pad_w, float& pad_h) { int h = src.h, w = src.w; scale = std::min((float)target_w / w, (float)target_h / h); int new_w = std::round(w * scale); int new_h = std::round(h * scale); pad_w = (target_w - new_w) / 2.f; pad_h = (target_h - new_h) / 2.f; ncnn::Mat resized; ncnn::resize(src, resized, new_w, new_h); ncnn::copy_make_border(resized, out, pad_h, target_h - new_h - pad_h, pad_w, target_w - new_w - pad_w, 0, 114.f); }补边用的填充值是114,这是YOLO训练时的默认背景像素值。这个值不要随手改,它会影响模型的输出置信度,尤其对关键点检测这种对图像内容比较敏感的模型来说,填0和填114的结果能差出不少。
3.3 JNI推理封装的核心代码
C++侧推理的核心逻辑集中在PoseDetector::detect里:
extern "C" JNIEXPORT jfloatArray JNICALL Java_com_example_posedetector_PoseDetector_detect(JNIEnv* env, jobject thiz, jbyteArray input, jint w, jint h) { jbyte* data = env->GetByteArrayElements(input, nullptr); ncnn::Mat in = ncnn::Mat::from_pixels((const unsigned char*)data, ncnn::Mat::PIXEL_RGBA2RGB, w, h); float scale, pad_w, pad_h; ncnn::Mat lettered; letterbox(in, lettered, target_size, target_size, scale, pad_w, pad_h); ncnn::Extractor ex = net.create_extractor(); ex.input("images", lettered); ncnn::Mat out; ex.extract("output0", out); // 后续关键点解码在这里做(第4部分展开) env->ReleaseByteArrayElements(input, data, 0); }net.create_extractor()每次推理前都创建一个新实例,这是NCNN的推荐用法,不要在多个线程之间共用一个Extractor。Project源文件里的线程模型用的是单线程推理加预览帧队列,这样可以避开线程安全问题,又能在低端机上保持相对稳定的帧率。
3.4 从Bitmap到NCNN Mat的细节
Android侧拿到相机帧的时候是Bitmap或者YUV格式的byte[]。我偏向于直接传byte[],因为可以省掉Bitmap对象分配的开销。转换操作用PIXEL_RGBA2RGB,意味着上层的Bitmap需要保证是ARGB_8888格式。这里有一个经常被忽略的点:CameraX输出的是YUV_420_888格式,要先转成RGBA再喂给JNI,转的时候用YuvImage.compressToJpeg或者RenderScript都行,但不要直接用BitmapFactory去解码,那会引入额外的缩放和颜色偏移。
4. 关键点解码与骨架绘制:从模型输出到可视化
模型输出的是一大堆数字,关键点检测最容易被“看起来能用但实际错得离谱”的地方就在解码这一层。YOLO-Pose的输出布局和普通目标检测不一样,要按特定的通道顺序解析。
4.1 理解输出Tensor的布局
以320x320输入、YOLOv8-Pose为例,经过网络后输出维度是1 x 56 x 8400。拆开来看:
- 8400是特征图上的锚点总数,对应三个尺度:80x80 + 40x40 + 20x20。
- 56个通道的分布是:4个边界框坐标(cx, cy, w, h)+ 1个目标置信度 + 17个关键点每个人3个值(x, y, 可见度),也就是4 + 1 + 51 = 56。
解码时最典型的错误是有人把关键点部分当成了51个独立的量,而不是按每3个为一组去组织。正确逻辑是:
const float* ptr = out.channel(0); for (int i = 0; i < num_anchors; i++) { float cx = ptr[0]; float cy = ptr[1]; float w = ptr[2]; float h = ptr[3]; float box_conf = ptr[4]; if (box_conf < box_threshold) { ptr += 56; continue; } float max_kpt_conf = 0.f; for (int j = 0; j < 17; j++) { float kpt_x = ptr[5 + j * 3]; float kpt_y = ptr[6 + j * 3]; float kpt_conf = ptr[7 + j * 3]; if (kpt_conf > max_kpt_conf) max_kpt_conf = kpt_conf; } if (max_kpt_conf < kpt_threshold) { ptr += 56; continue; } // 记录为有效目标 ptr += 56; }注意这里的关键点判断逻辑:很多实现只看了目标框的置信度,没检查关键点置信度。实际测试中会出现目标框置信度很高但17个关键点全是很低置信度的情况,如果不加这个检查,画出来的骨架全是胡乱连线。
4.2 NMS该怎么用
检测框要做NMS(非极大值抑制),关键点本身不需要。常用IoU阈值是0.45,置信度阈值看场景:单人场景0.25就很宽松,多人密集场景需要提高到0.35以上减少误检。
NMS输出后的每个保留框,对应一组17个关键点。只要把关键点坐标从模型的stride空间还原到原图即可。核心是这一步letterbox逆变换:
float ori_x = (kpt_x - pad_w) / scale; float ori_y = (kpt_y - pad_h) / scale;这个公式出错的话,典型的症状是:目标框很准,但骨架整体向左上角偏移,越靠近图像边缘越严重。遇到这种情况,优先检查pad_w / pad_h有没有和letterbox阶段使用的补边值对应上。
4.3 COCO 17点骨架连接规则
关键点的顺序和骨架连线参考COCO数据集定义:
0: 鼻子 1: 左眼 2: 右眼 3: 左耳 4: 右耳 5: 左肩 6: 右肩 7: 左肘 8: 右肘 9: 左腕 10: 右腕 11: 左髋 12: 右髋 13: 左膝 14: 右膝 15: 左踝 16: 右踝绘制时按以下数组连接:
int[][] skeleton = { {0, 1}, {0, 2}, {1, 3}, {2, 4}, {5, 6}, {5, 7}, {7, 9}, {6, 8}, {8, 10}, {11, 12}, {11, 13}, {13, 15}, {12, 14}, {14, 16}, {5, 11}, {6, 12} };最后那两条{5,11}和{6,12}是肩膀和髋部之间的连线,很多人画骨架时会漏掉,导致躯干部分看起来是两截断开的。画的时候建议先画躯干再画四肢,颜色区分开,比如躯干用亮色,四肢用暗色,在光线复杂的场景下更直观。绘制本身可以用Android的Canvas在Bitmap上画线,也可以直接在OpenGL预览层上叠加,后者性能更好,但工程复杂不少。
5. CPU/GPU双引擎:切换逻辑和实测数据
这套源码里最核心的设计之一,是同一个JNI接口下同时支持CPU和GPU推理。用户可以在界面上直接切换,不用重新编译。
5.1 NCNN的Vulkan开关到底怎么开
NCNN使用GPU推理的底层接口是Vulkan。代码里的开关只有一处:
ncnn::Option opt; opt.use_vulkan_compute = use_gpu; opt.use_fp16_storage = true; net.opt = opt;但实际操作远不止这个。首次调用Vulkan推理前,NCNN需要枚举设备、创建Vulkan实例,这个过程耗时可能接近一秒。所以源码里的做法是:启动时先做一次空推理来预热,把初始化耗时避开第一帧预览。预热推理用的是一张1x3的空白图,只触发网络图的构建,不做实际检测。
还有个容易踩的坑:Vulkan计算在部分GPU驱动上会生成NaN结果。我遇到过Mali-G76上开Vulkan后关键点全部变成无效值的情况。排查方法是打印10帧的推理结果,如果大量关键点坐标为负数或超界,直接说明当前驱动的Vulkan路径不可靠,要在运行时自动回退到CPU。
5.2 实测帧率:不同SoC和引擎的表现
以下数据是在固定输入320x320、FP16权重、单线程推理的条件下,用同一份APK在不同设备上实测的均值,单位是毫秒/帧:
| 设备 | CPU引擎 | GPU引擎 | 备注 |
|---|---|---|---|
| 骁龙778G | 28-33ms | 22-26ms | GPU稳定快于CPU |
| 麒麟9000 | 18-22ms | 15-18ms | GPU可用,提升幅度中等 |
| 天玑8100 | 20-24ms | 17-21ms | GPU略快,偶尔跳帧 |
| 骁龙8 Gen2 | 12-15ms | 8-11ms | GPU优势明显 |
| Mali-G76(旗舰偏老) | 30-36ms | 42-50ms | GPU比CPU慢,驱动问题明显 |
| 入门机(Helio G35) | 85-100ms | 无法启动Vulkan | 只能CPU中等画质预览 |
看到这个表,你大概能明白为什么我坚持要做双引擎切换而不是默认GPU:当年Mali-G76的机型和入门机用户占了相当比例。GPU加速不是开启就好,必须实测设备表现后再做决定。
5.3 线程数、功耗与温控的取舍
CPU推理时,NCNN的opt.num_threads默认是4。但四线程全开会让手机在30秒内快速升温,之后系统会做CPU降频,帧率反而暴跌。我实测的建议是:
- 快速预览模式:2线程,功耗可控,帧率只比4线程低15%左右。
- 精准检测模式:4线程,但每帧之间加10ms的sleep,避免持续满载。
- GPU模式:不需要设置
num_threads,只要保证输入输出转换是单线程的即可。
最后再补一点:GPU模式下的预热和首次推理时间差很多,如果检测到设备GPU可用,源码里会主动把模型加载到常驻内存并保持Vulkan上下文不释放。不要每次推理去创建和销毁Extractor,这套做法在GPU路径上会带来严重的卡顿。
6. 从源码跑到改造成自己的模型:常见问题与排查
代码能跑之后,很多人会想换自己的模型,或者换成别的YOLO-Pose变体。改模型不难,但有几个问题非常典型,我把排查链路整理了一遍,能帮你少走大弯路。
6.1 输入尺寸不匹配导致检测完全失效
把模型从imgsz=320改成imgsz=416时,导出没问题,NCNN加载没问题,但推理结果全为空。原因一般不是模型坏了,而是target_size没同步。源码里path/to/param文件中能看到输入节点的shape是416还是320,Java层输入的target_size必须和参数一致。建议做成配置项,别写死在代码里:
model_type=yolov8s-pose input_size=416 gpu_enable=false6.2 关键点坐标整体偏移的定位方法
如果检测框够准、骨架方向对但位置向右下偏移,先不要怀疑模型。用一个纯色画面做样本,人工把一个圆点放在画面中心,跑一次推理,看模型输出的关键点坐标是否也落在中心附近。如果偏移量刚好等于letterbox补边的比例关系,说明逆变换的pad_w/pad_h和正变换时不一致。还有一个隐蔽问题:ARGB转RGB时通道顺序错了,看起来是颜色怪异,实际上会让模型输出的坐标产生几个像素的偏移。
6.3 老GPU上Vulkan不可用或闪退
入门机(尤其基于Mali-G31/G35的)上,Vulkan运行时经常初始化失败或者跑一半闪退。源码里的兜底机制是:
int ret = net.load_param(param_path.c_str()); if (ret != 0) { opt.use_vulkan_compute = false; net.opt = opt; net.load_param(param_path.c_str()); }先按GPU初始化,失败就立刻回退CPU。注意net对象创建后option不能多次改动,稳妥做法是检测失败后重新创建一个ncnn::Net,不要复用同一个实例。
6.4 换模型的通道数判断
人体关键点检测模型未必都是17点,有些自定义数据集是13点或21点。修改通道数时,检查模型输出的第一维channel数:
- 17点:4 + 1 + 17x3 = 56
- 21点:4 + 1 + 21x3 = 68
解码部分必须同步修改,否则数组越界不会报错,但会读到垃圾数据,画出离谱骨架。这一步改完记得同时更新Java层的skeleton数组。
6.5 模型加载时间过长与内存峰值
NCNN加载模型时如果直接从apk的asset目录读取,每次初始化都要解压整个二进制文件,慢则数秒。正确做法是首次启动时把.param和.bin从asset复制到getFilesDir(),以后直接从文件路径加载,加载时间能缩短到十分之一。内存峰值方面,FP16模型跑320x320输入时,网络中间层峰值占用大约在300-500MB,预览页常有Camera和OpenGL同时占内存,建议在模型加载前后主动释放缓存Bitmap,避免OOM。
如果只让我给一条最终建议:把这套流程做成完全可配置的——模型名、输入尺寸、置信度阈值、CPU/GPU开关,全部放到配置里,不要写死在代码中。这样在演示demo时现场换模型、调速度、平衡功耗,别人看着像你在“适配不同的设备”,其实是同一份代码在不同参数间切换。这套架构跑通之后,不管以后YOLO更新到什么版本,你只要照着导出、转换、微调步骤走一遍,新的模型就能在半小时内落地到手机上。