☰
Android端YOLO人体关键点检测部署实践:从PyTorch到NCNN双引擎优化
2026/11/4 8:41:12 网站建设 项目流程

先回答一个最常被问的问题:手机上到底能不能跑YOLO人体关键点检测?答案是能,而且不是实验室里的勉强能跑,是带着实时预览、CPU和GPU双引擎、还能稳定几十毫秒出结果的能跑。这篇文章就是把这条链路完整走一遍——从PyTorch权重开始,经过导出、转换、量化,到Android工程里通过NCNN和ONNX Runtime做推理,再到关键点解码、骨架绘制,最后给出我在不同处理器上的实测帧率。全程有源码,跟着步骤走就能在自己的设备上跑起来。


1. 从模型到产物:端侧姿态估计为什么先选型再动手

很多人在Android上部署YOLO,上来就找模型、抄代码,结果不是跑不起来,就是能跑但速度感人。我个人的经验是:端侧部署这件事,模型选型和引擎选型占整个项目70%的工作量,剩下才是写代码。所以先说清楚这三条模型线和两个引擎到底该怎么选。

1.1 YOLOv8-Pose、YOLO11-Pose、YOLO26-Pose的核心差异

这三个模型从名字看是版本迭代关系,但实际部署时要关注的不是版本号,而是结构、参数量、计算量和落地成熟度。

模型参数量级推理特点适合场景
YOLOv8-Poses版约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.bin

NCNN的接入方式可以直接在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引擎备注
骁龙778G28-33ms22-26msGPU稳定快于CPU
麒麟900018-22ms15-18msGPU可用,提升幅度中等
天玑810020-24ms17-21msGPU略快,偶尔跳帧
骁龙8 Gen212-15ms8-11msGPU优势明显
Mali-G76(旗舰偏老)30-36ms42-50msGPU比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=false

6.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更新到什么版本,你只要照着导出、转换、微调步骤走一遍,新的模型就能在半小时内落地到手机上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询