1. 为什么 YOLOv8-pose 转 ncnn 总在 scatterND 上翻车
YOLOv8-pose 是 Ultralytics 官方支持的人体关键点检测模型,输入一张图就能输出 17 组关键点的 x、y、score,适合做健身计数、手势交互、姿态比对这类端侧应用。ncnn 是腾讯开源的高性能推理框架,对移动端 CPU/GPU 都很友好,模型体积小、依赖少,是很多 Android/iOS 项目落地的首选。把这两者接起来,理论上就是「PyTorch 训练 → 导出 → 转 ncnn → 端侧推理」四步,但真正动手你会发现中间卡点特别多。
最常见的坑就是scatterND算子。YOLOv8-pose 的头部在做 DFL 解码和关键点回归时,会用到scatterND这类索引写入操作,而 ncnn 的算子集里并没有原生支持它。你直接用onnx2ncnn转,工具不会报致命错误,照样吐出.param和.bin,但一加载就崩,或者输出全是 NaN。很多人第一次遇到会以为是模型导出错了,反复重导 ONNX,其实问题出在算子层面。
另一条路是用 pnnx 把 TorchScript 转成 ncnn,思路是把复杂算子拆成简单算子组合。但实测下来,pnnx 对 YOLOv8-pose 里的slice with step 3、select along batch axis这些操作同样支持不好,日志里会刷一堆not supported,最后还是转不出可用的模型。
所以真正能走通的方案只有一个:改模型结构,只导出 backbone + neck,把后处理(anchor 解码、DFL、关键点解码、NMS)全部搬到 C++ 或 Python 侧手写。这样导出的 ONNX 里没有scatterND,ncnn 能干净地转过去。下面我把整条路径完整记录一遍,包括 pnnx 的安装、两种转换方案的对比、以及最终的结构更改方案。
2. 前置准备:TaoToken 与 pnnx 环境
在开始折腾模型转换之前,先把两件事准备好:一个是模型下载和 API 调用的通道,一个是 pnnx 的编译环境。
模型权重和文档查询我习惯走 TaoToken 的模型对话入口,它把常用模型的说明和调用方式集中在一起,查 YOLOv8 的导出参数、opset 版本这些比较顺手。如果你后面要写脚本批量跑导出,也可以用它的 API 通道,地址是 https://taotoken.net/api ,配合 API Keys 页面生成的 key 就能调。API Keys 在 https://taotoken.net/api-keys 这里管理,接入文档在 https://taotoken.net/doc ,需要长期跑编码任务或者 Agent 的话可以看 Coding Plan:https://taotoken.net/coding-plan 。
pnnx 这边,官方推荐两种安装方式:直接下载可执行文件,或者从源码编译。我两种都试过,直接下载的可执行文件在部分 Linux 发行版上会因为 glibc 版本不匹配跑不起来,chmod +x之后依旧报错。所以更稳的做法是从 ncnn 源码里编译 pnnx,这样版本和 ncnn 本身是对齐的。
先装依赖,Ubuntu 下大概是这样:
sudo apt update sudo apt install -y build-essential cmake git libprotobuf-dev protobuf-compiler libopencv-dev然后拉 ncnn 源码,注意要带 submodule:
git clone https://github.com/Tencent/ncnn.git cd ncnn git submodule update --initpnnx 的源码就在ncnn/tools/pnnx目录下,编译它需要 libtorch。libtorch 的路径可以用 Python 查:
import torch print(torch.__path__)拿到路径后,编译 pnnx:
mkdir -p ncnn/tools/pnnx/build cd ncnn/tools/pnnx/build cmake -DCMAKE_INSTALL_PREFIX=install -DTorch_INSTALL_DIR=/path/to/libtorch .. cmake --build . --config Release -j$(nproc) cmake --build . --config Release --target install编译大概三四十分钟,完成后在ncnn/tools/pnnx/build/src下会生成pnnx可执行文件。用 mobilenet_v2 做个冒烟测试:
import torch import torchvision.models as models net = models.mobilenet_v2(pretrained=True) net = net.eval() x = torch.rand(1, 3, 224, 224) mod = torch.jit.trace(net, x) torch.jit.save(mod, "mobilenet_v2.pt")然后跑:
./pnnx mobilenet_v2.pt inputshape=[1,3,224,224]如果目录下出现mobilenet_v2.ncnn.param、mobilenet_v2.ncnn.bin、mobilenet_v2.pnnx.param这几个文件,说明 pnnx 装好了。
3. 可复制配置:ONNX 导出与两种转换方案
3.1 YOLOv8-pose 导出 ONNX
先装 ultralytics:
pip install ultralytics onnx onnxsim导出脚本:
from ultralytics import YOLO model = YOLO("yolov8s-pose.pt") success = model.export( format="onnx", opset=11, simplify=True, dynamic=False, imgsz=640 ) assert success导出后得到一个yolov8s-pose.onnx,输入是[1,3,640,640],输出形状是[1,116,8400]。这里的 116 = 1 类分数 + 64 DFL 回归参数 + 51 关键点预测参数(17 组 x,y,score)。
3.2 方案一:onnx2ncnn 直接转
ncnn 编译时如果勾选了NCNN_BUILD_TOOLS,会在build/tools/onnx下生成onnx2ncnn。命令:
./tools/onnx/onnx2ncnn yolov8s-pose.onnx yolov8s-pose.param yolov8s-pose.bin这一步通常不会报错,但会打印一堆 warning,核心就是scatterND不支持。接着做图优化:
./tools/ncnnoptimize yolov8s-pose.param yolov8s-pose.bin yolov8s-pose-opt.param yolov8s-pose-opt.bin 1最后一个参数1表示 fp16,0表示 fp32。速度上一般是 GPU fp16 < CPU fp32 < GPU fp32,端侧按硬件选。
转完之后用 ncnn 加载,大概率会在Extractor::input或forward阶段报错,或者输出全 0。原因就是scatterND没被正确转换。
3.3 方案二:pnnx 转 TorchScript
思路是先导出 TorchScript,再用 pnnx 转:
from ultralytics import YOLO model = YOLO("yolov8n-pose.pt") success = model.export(format="torchscript", simplify=True) assert success然后:
./pnnx yolov8s-pose.torchscript inputshape=[1,3,640,640]实测日志里会刷:
slice with step 3 is not supported select along batch axis 0 is not supported binaryop broadcast across batch axis 0 and 233 is not supportedpnnx 对 YOLOv8-pose 里的这些操作支持不到位,转出来的模型依旧不可用。所以方案二在 pose 模型上基本走不通,只能回到方案一 + 改结构。
4. 验证请求:改结构后转 ncnn 并跑通推理
4.1 只导出 backbone + neck
参考 triple-Mu 的 yolov8 分支做法,把 Detect/Pose 头去掉,只保留 backbone 和 neck。改完之后导出:
from ultralytics import YOLO model = YOLO("yolov8s-pose.pt") model.export( format="onnx", opset=11, simplify=True, dynamic=False, imgsz=640 )此时输出形状变成[1, 116, 8400],但里面不再有scatterND,因为后处理被剥离了。116 的含义不变:1 类分数 + 64 DFL + 51 关键点。
4.2 生成 bin 和 param
./tools/onnx/onnx2ncnn yolov8s-pose.onnx yolov8s-pose.param yolov8s-pose.bin ./tools/ncnnoptimize yolov8s-pose.param yolov8s-pose.bin yolov8s-pose-opt.param yolov8s-pose-opt.bin 1这次onnx2ncnn不会再报scatterND,输出干净。
4.3 ncnn 推理验证
写一个最小 C++ 验证程序,加载模型、喂一张 640x640 的图、打印输出维度:
#include <net.h> #include <opencv2/opencv.hpp> int main() { ncnn::Net net; net.opt.use_vulkan_compute = true; net.load_param("yolov8s-pose-opt.param"); net.load_model("yolov8s-pose-opt.bin"); cv::Mat img = cv::imread("test.jpg"); cv::resize(img, img, cv::Size(640, 640)); ncnn::Mat in = ncnn::Mat::from_pixels(img.data, ncnn::Mat::PIXEL_BGR2RGB, 640, 640); const float mean[3] = {0.f, 0.f, 0.f}; const float norm[3] = {1/255.f, 1/255.f, 1/255.f}; in.substract_mean_normalize(mean, norm); ncnn::Extractor ex = net.create_extractor(); ex.input("images", in); ncnn::Mat out; ex.extract("output0", out); printf("out dims = %d, w = %d, h = %d\n", out.dims, out.w, out.h); return 0; }如果打印出out dims = 2, w = 8400, h = 116,说明模型转换成功,剩下的就是后处理解码。
4.4 后处理解码
按 116 的排布顺序拆:前 1 个是类别分数,接着 64 个是 DFL 回归,最后 51 个是 17 组关键点。三个尺度 8/16/32 分别对应 80x80、40x40、20x20 的特征图,解码时注意 stride 和 anchor 中心点还原。DFL 部分要做 softmax 加权求和得到距离,再结合 anchor 中心算出框。关键点部分直接取 x、y、score,乘上 stride 还原到原图坐标。
5. 本篇常见错排查
报错一:scatterND not supported这是最典型的。原因就是直接转原始 YOLOv8-pose ONNX。解决方式是改模型结构,只导出 backbone + neck,后处理手写。
报错二:pnnx 转 TorchScript 时刷slice with step 3 is not supportedpnnx 对 pose 头里的切片操作支持不好。这条路径目前走不通,别在这上面耗时间,直接走改结构方案。
报错三:onnx2ncnn转完加载崩,输出 NaN检查是否做了ncnnoptimize,以及 fp16 标志位是否和硬件匹配。部分老 GPU 对 fp16 支持不完整,改成 fp32 试试。
报错四:输出维度对不上确认导出时imgsz=640、dynamic=False。如果开了 dynamic,ncnn 这边 shape 推断会乱。
报错五:pnnx 编译时找不到 libtorchTorch_INSTALL_DIR要指向 libtorch 根目录,不是torch.__path__本身。一般是torch.__path__的上一级。
报错六:ncnn 编译时NCNN_BUILD_TOOLS没勾不勾就不会生成onnx2ncnn和ncnnoptimize,转模型时找不到工具。cmake 时加上-DNCNN_BUILD_TOOLS=ON。
6. 接入与排障入口
模型转换跑通之后,如果你还要继续做端侧集成、写 JNI 层、或者把推理结果接到业务逻辑里,建议把 API Keys 和接入文档先过一遍,key 在 https://taotoken.net/api-keys 生成,文档在 https://taotoken.net/doc ,里面有完整的请求示例和参数说明。需要长期跑编码任务或者 Agent 的,可以看 Coding Plan:https://taotoken.net/coding-plan 。模型本身的说明和对话测试走 https://taotoken.net/models 就行。整条链路里最容易卡的就是算子支持和后处理对齐,把这两块啃下来,剩下的就是工程活了。