尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

YOLOv10部署Android:NCNN转换、JNI集成与量化优化全指南

YOLOv10部署Android:NCNN转换、JNI集成与量化优化全指南 简介面向需要在Android端集成YOLOv10的移动端开发者这套资源给出了基于NCNN的完整部署方案内含Android源码、模型文件及项目说明覆盖实时目标检测场景特别适合具备一定Android和深度学习基础、希望快速上手的工程师。压缩包共666个文件大小42.47MB。文件以h/hpp头文件、cmake构建脚本和a静态库为主如libncnn.a、OpenCV相关库同时包含Java源码、XML配置、Gradle构建文件等从底层依赖到上层界面均有覆盖目录结构清晰便于查阅。目前已有760人学习下载说明该方案在移动端推理实践中有较普遍的参考需求。通过研读源码和说明文档使用者可掌握NCNN模型加载、图像前后处理及Android端性能调优的关键思路避免从零搭建工程时常见的坑点是一份能直接支撑项目落地的实用模板。1. 部署yolov10的ncnn模型到android先想清楚三件事部署yolov10的ncnn模型到android拆开看是一条端侧目标检测的完整交付链路yolov10.pt权重先转成onnx再经onnx2ncnn变成param和bin最后用CMake加JNI把ncnn集成进Android Studio工程在手机上做实时检测。和服务器端推理最大的差别在工程约束内存有限、CPU调度策略不同模型文件要么随App打包要么用户手动导入输出解码、NMS、结果绘制都得在Java和C两层之间来回倒。这条链路最适合准备往移动端迁移的算法工程师也适合接手实时检测模块的Android开发。压缩包里源码加模型加项目说明本质就是把链路固化成模板param和bin可以换成自己的权重JNI层输入输出格式不变App拿到检测框后在相机预览上绘制即可。动手前有三件事要定好导出时保留哪个检测头分支、Android端模型路径怎么兼容分区存储、号称免NMS的YOLOv10要不要保留NMS兜底。下面按转换、工程、解码、优化四条线展开。2. yolov10转ncnn选型理由与转换链路的三个节点2.1 YOLOv10的检测头双分支部署时只保留one2oneYOLOv10的架构和YOLOv8同属一代体系特殊在检测头分成one2one和one2many两条并行分支。训练时one2many分支提供充足正样本让模型学到丰富梯度one2one分支用匈牙利匹配做端到端推理目标就是推理阶段免NMS。这个设计在训练里是优势到部署环节就成了第一个要处理的点如果直接把整个模型导出为onnx两个分支都会被固化进计算图参数变多Android端还会拿到两组结构不同的输出。常见做法是导出时把模型forward限制为只走one2one分支让onnx只保留推理真正需要的那条路径。640×640输入下one2one分支输出的特征图尺寸是80×80、40×40和20×20三个尺度合计anchor数是8400。每个anchor携带84个值其中前4个是中心点x、中心点y、宽、高后80个是COCO类别分数。这个84和8400后面解码代码里会反复出现。提示拿到别人的yolov10权重先用netron打开onnx看一眼输出。如果看到两组输出或通道数接近168说明两个分支都还在图里部署模型应该只有84维输出也就是4个坐标加80个类别。2.2 移动端推理框架横评NCNN、TFLite、MNN怎么选把模型从PyTorch搬到Android通常候选是NCNN、TFLite、ONNX Runtime Mobile和MNN。YOLOv10社区里NCNN案例最多原因是它对YOLO系模型里常见的卷积、BN、SiLU、split、concat、transpose、reshape算子支持完整onnx2ncnn一条命令能转干净Android侧集成也最直接。框架模型格式Android集成方式GPU加速量化方式YOLO转换成熟度NCNNparam binCMake/jniLibsVulkanFP16 / INT8v5/v8/v10案例最多TFLitetflite官方依赖库NNAPIFP16 / INT8需先转ONNX再转TFLite算子易碎ONNX Runtime Mobileonnx官方依赖库NNAPI/EPFP16 / INT8通用但包体偏大MNNmnnCMake/jniLibsOpenCL/VulkanFP16 / INT8性能好但魔改项目多选NCNN还有一个现实理由官方预编译包直接面向Androidlibncnn.so体积小配合头文件和CMake的find_package即可使用不用自己编译第三方依赖。后面INT8量化时的ncnn2table、ncnn2int8等工具链也都在同一个代码库里维护。工程规模不大的团队NCNN这条链路最不容易卡壳。2.3 pt转onnxyolov10的yaml文件怎么创建与导出配置YOLOv10基于ultralytics框架第一步确保环境里有对应版本的ultralytics和onnx、onnxsim。yaml文件用来描述模型结构和类别数量yolov10 yaml文件怎么创建从官方仓库复制yolov10n.yaml到本地把nc字段改成自己数据集的类别数如果只是拿COCO权重测试用现成pt文件时框架会自动匹配内置配置。导出有两种方式命令行最快pip install ultralytics onnx onnxsim yolo export modelyolov10n.pt formatonnx opset13 simplify要稳定只导出one2one分支用Python脚本更可控import torch import torch.nn as nn from ultralytics import YOLO class DeployModel(nn.Module): def __init__(self, weightsyolov10n.pt): super().__init__() self.model YOLO(weights).model # DetectionModel def forward(self, x): with torch.no_grad(): y self.model(x) if isinstance(y, (list, tuple)): y y[0] # 推理图上取第一条输出 y y.permute(0, 2, 1) # [1, 84, 8400] - [1, 8400, 84] return y net DeployModel() net.eval() x torch.rand(1, 3, 640, 640) torch.onnx.export( net, x, yolov10n-deploy.onnx, input_names[input], output_names[output0], opset_version13, dynamic_axesNone )代码逻辑说明YOLO(weights).model拿到的是DetectionModel实例推理模式下forward返回一组原始预测取第一个就是one2one分支输出。permute把通道维挪到最后让Android端解码时每一行就是一个候选框的属性。export里dynamic_axes显式设None固定输入尺寸640×640否则onnx2ncnn会拒绝动态shapeinput_names和output_names要和后面JNI里extract使用的名字对齐。2.4 ubuntu下编译ncnn与onnx2ncnnpt转ncnn问题一次说清onnx2ncnn是ncnn官方工具ubuntu下需要先编译ncnn源码git clone https://github.com/Tencent/ncnn.git cd ncnn mkdir -p build cd build cmake -DCMAKE_BUILD_TYPERelease \ -DNCNN_VULKANON \ -DNCNN_BUILD_TOOLSON \ -DNCNN_BUILD_EXAMPLESOFF .. make -j$(nproc)编译参数说明-DNCNN_VULKANON给后续Android端GPU加速铺路保持和移动侧开关一致可以避免宏分歧-DNCNN_BUILD_TOOLSON才会生成tools目录下的onnx2ncnn。make完成后确认build/tools/onnx/onnx2ncnn存在再执行./tools/onnx/onnx2ncnn yolov10n-deploy.onnx yolov10n.param yolov10n.binpt转ncnn问题里最高频的报错是Unsupported layer。如果onnx2ncnn打印出不支持的算子但程序正常退出先打开param文件尾部被标记Unsupported的层在Android加载时会直接失败。YOLOv10的标准backbone结构经onnxsim simplify后剩余算子基本是ncnn原生支持的真正容易出问题的是导出onnx时没有开simplify导致一些冗余Transpose和Reshape组合让onnx2ncnn解析异常。转换成功后用netron确认输出节点名Android端extractor.extract里填的名字必须和它完全一致默认是output0。转换完的param文件开头是这样7767517 Input input 1 1 input 0640 0640 03 Convolution Conv_0 1 1 input conv_output 032 13 22 31 ...其中7767517是ncnn的魔数用来校验文件类型每一行描述一个算子的名字、输入输出blob和参数。Android端load_param失败时先看这个文件在拷贝过程中有没有被截断。3. android Studio工程里把ncnn跑起来源码集成与JNI封装3.1 android studio下载、设置中文与NDK版本选择Android Studio直接去官网下载稳定版Windows用户安装时勾选Android SDK组件。android studio怎么设置中文打开Settings → Plugins → Marketplace搜索Chinese (Simplified) Language Pack安装后重启IDE菜单和右键都会变中文。这个包只影响界面不参与编译。新建工程选Empty Views Activity模板minSdk建议24对应Android 7.0能覆盖绝大多数存量设备targetSdk按商店要求走当前建议34。NDK版本不追新直接用SDK Manager随SDK下载的默认NDK即可CMake版本和NDK保持同批发布三个组件版本不匹配时CMake报错很隐蔽查看Build Output会看到一堆找不到工具的提示。3.2 NCNN预编译包集成两种方式与一个对比表NCNN官方仓库的Releases页面提供预编译的android-vulkan压缩包解压后得到libs/arm64-v8a/libncnn.so和include头文件目录。集成方式有两种集成方式优点缺点适用阶段预编译so配置简单、编译快无法自定义算子、不能裁剪包体快速验证、业务上线前ncnn源码参与编译可加自定义算子、可按需裁剪首次构建时间长、NDK版本敏感需要魔改ncnn的正式项目yolov10模型只依赖常规卷积算子预编译包完全够用。把libncnn.so放进app/src/main/jniLibs/arm64-v8a头文件放进cpp/includeCMake里指定路径即可。正式发布时abiFilters只留arm64-v8a调试阶段加上x86_64以便在模拟器上运行否则跑模拟器会报找不到nativeLibrary。3.3 CMakeLists与build.gradle把ncnn链接进源码的最小配置app/src/main/cpp/CMakeLists.txtcmake_minimum_required(VERSION 3.10) project(yolov10_detector LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(ncnn_DIR ${CMAKE_SOURCE_DIR}/ncnn-android-vulkan/lib/cmake/ncnn) find_package(ncnn REQUIRED) add_library(yolov10_detector SHARED yolov10_detector_jni.cpp) target_include_directories(yolov10_detector PRIVATE ${ncnn_INCLUDE_DIR} ${CMAKE_SOURCE_DIR}/include) target_link_libraries(yolov10_detector ncnn)build.gradle中的externalNativeBuild配置android { compileSdk 34 defaultConfig { applicationId com.example.yolov10ncnn minSdk 24 targetSdk 34 externalNativeBuild { cmake { arguments -DANDROID_TOOLCHAINclang, -DANDROID_ARM_NEONON cppFlags -stdc17 -fopenmp } } ndk { abiFilters arm64-v8a, x86_64 } } externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt } } }参数说明find_package(ncnn REQUIRED)依赖set(ncnn_DIR)指向预编译包里的cmake目录-DANDROID_ARM_NEONON让ncnn算子走NEON指令集-fopenmp让卷积计算多线程执行。abiFilters中的x86_64只用于模拟器调试正式签名包删掉这一行减少so体积。3.4 封装JNIBitmap进、检测框出Java侧定义native接口public class YOLOv10Detector { static { System.loadLibrary(yolov10_detector); } public static class Box { public float x, y, w, h; public int label; public float score; } public native boolean init(String paramPath, String binPath, boolean useGpu); public native Box[] detect(Bitmap bitmap, float scoreThresh, float nmsThresh); }C侧init的核心是设置ncnn全局配置并加载两个模型文件static ncnn::Net g_net; bool init(JNIEnv* env, jobject, jstring param, jstring bin, jboolean useGpu) { const char* p env-GetStringUTFChars(param, nullptr); const char* b env-GetStringUTFChars(bin, nullptr); g_net.opt.use_vulkan_compute useGpu; g_net.opt.num_threads 4; bool ok (g_net.load_param(p) 0 g_net.load_model(b) 0); env-ReleaseStringUTFChars(param, p); env-ReleaseStringUTFChars(bin, b); return ok; }detect方法先做Bitmap到RGBA字节数组的转换再调ncnn::Mat::from_pixels_resize把图像送进640×640输入。这里的像素格式建议用PIXEL_RGBA2BGR和YOLO训练时BGR通道顺序保持一致省得推理结果因为通道翻转出现莫名其妙的低置信度。每次detect调用后要确认extractor对象析构否则连续推理几十帧后native内存会缓慢上涨。3.5 加载本地模型SAF与content://路径的处理Android 11之后外置存储访问限制很严格直接把content://com.android.externalstorage.documents这类URI传给C的fopen会立刻失败。正确姿势是用Intent.ACTION_OPEN_DOCUMENT让系统文件选择器返回一个可读的InputStream把模型文件copy到App私有缓存目录再把文件路径传给JNI。ncnn只认识普通文件路径不认识content provider的流式读取所以文件copy必须发生在Java层。做“加载本地模型”功能时这步没做对JNI里logcat会一直报load_model failed而且很难看出来是路径问题。4. yolov10输出解码与NMS从8400个候选框到屏幕上的框4.1 输出张量排布决定解码写法8400×84与84×8400onnx导出时是否做过permute决定了Android端拿到的ncnn::Mat排布也决定了解码循环怎么写。推荐导出时把输出整理成[1, 8400, 84]ncnn里对应c1、h8400、w84每一行是一个anchor84个值里前4个是xywh后80个是类别分数。解码函数struct Object { float x, y, w, h; int label; float score; }; void decode(const ncnn::Mat out, float score_thresh, std::vectorObject objects) { const int num_anchors out.h; // 640x640 输入时合计 8400 const int num_attrs out.w; // 84 for (int i 0; i num_anchors; i) { const float* ptr out.row(i); float cx ptr[0], cy ptr[1], w ptr[2], h ptr[3]; float best_score 0.f; int best_label -1; for (int j 4; j num_attrs; j) { if (ptr[j] best_score) { best_score ptr[j]; best_label j - 4; } } if (best_score score_thresh) continue; Object obj; obj.x cx - w * 0.5f; obj.y cy - h * 0.5f; obj.w w; obj.h h; obj.label best_label; obj.score best_score; objects.push_back(obj); } }逻辑说明坐标在这个阶段还原到640×640输入图空间。类别循环从j4开始跳过坐标best_score只记录置信度最高的类别因为部署模型里同一个anchor不应该同时属于多个类别。score_thresh低于0.2时会出现大量互相重叠的框这就是后面NMS要继续处理的原因。如果导出时没做permute输出是[1, 84, 8400]ncnn里表现为c84、h8400、w1解码就要先遍历通道取坐标再对每个anchor做类别循环代码复杂度高很多所以尽量在导出端解决。4.2 YOLOv10免NMS部署时NMS还是别删YOLOv10训练目标是端到端免NMSone2one分支在训练阶段通过匈牙利匹配学到稀疏的正样本分配推理输出本身的重复框就少。但ncnn端实际运行时置信度阈值放宽到0.25以下个别大目标仍可能被相邻anchor同时检出如果导出时保留了one2many分支那一组输出必须配NMS才能用。部署中最省心的方案是无论哪个版本都保留NMS把IoU阈值放宽到0.65到0.7既保留免NMS模型框少的特点又能滤掉极端情况下的重复框。static float intersection(const Object a, const Object b) { float ow std::min(a.x a.w, b.x b.w) - std::max(a.x, b.x); float oh std::min(a.y a.h, b.y b.h) - std::max(a.y, b.y); return (ow 0.f oh 0.f) ? ow * oh : 0.f; } static void nms(std::vectorObject objects, float nms_thresh) { std::sort(objects.begin(), objects.end(), [](const Object a, const Object b) { return a.score b.score; }); std::vectorfloat areas(objects.size()); for (size_t i 0; i objects.size(); i) areas[i] objects[i].w * objects[i].h; std::vectorint picked; for (size_t i 0; i objects.size(); i) { bool keep true; for (int j : picked) { float inter intersection(objects[i], objects[j]); float iou inter / (areas[i] areas[j] - inter); if (iou nms_thresh) { keep false; break; } } if (keep) picked.push_back(static_castint(i)); } std::vectorObject filtered; for (int idx : picked) filtered.push_back(objects[idx]); objects.swap(filtered); }逻辑说明按分数降序排列后从最高分的框开始只保留与已选框IoU小于阈值的候选框。nms_thresh越大保留下来的重叠框越多YOLOv10的端到端特性允许把它设到0.7YOLOv5习惯的0.45会误删相邻的真框。4.3 四个必调参数与letterbox坐标还原实际项目中影响结果最直接的四个参数参数初始值调参方向输入尺寸640×640小目标多时上探800或960帧率随像素数平方下降score_thresh0.25误检多往上调漏检多往0.1到0.15调nms_thresh0.65YOLOv10建议0.65到0.70.45会删掉相邻真框num_threads4大核心少于4个时6线程反而增加调度开销还有一个容易漏掉的细节前面from_pixels_resize直接缩放会破坏目标长宽比。工程做法是letterbox把原图等比例缩放后填充到640×640记录scale和pad偏移。解码后画框时原始Bitmap坐标要用x (x - pad_w) / scale、y (y - pad_h) / scale还原否则检测框在竖屏预览上会整体偏移。4.4 常见报错与排查顺序Android上跑yolov10 ncnn日志里出现 error report 时按加载、推理、绘制三个阶段排查。加载阶段load_param失败先确认param路径和文件开头是不是7767517load_model失败检查bin与param是否同一次转换生成常见情况是param里Input写了640×640bin里却是其他尺寸。推理阶段extract失败核对输出节点名netron打开onnx看是output0还是别的名字全图无框先打印out的h和w8400没出来是网络结构不完整8400出来了分数全0检查JNI预处理里normalize有没有做1/255。绘制阶段Bitmap全黑基本是CameraX回调的ImageProxy转Bitmap时YUV格式选错NV21和YV12最容易搞反。把JNI里每一步返回值都用__android_log_print打出来比在Java层一层层猜快很多。5. FP16与INT8量化yolov10的ncnn模型在android上的最后一档提速5.1 ncnnoptimize合并算子FP16存储与首轮提速onnx2ncnn转换出的param里还残留不少冗余算子结构比如推理时的批归一化已经能和卷积融合但计算图里可能还保留着。ncnnoptimize做常量折叠和算子合并顺便把权重存储格式改成FP16./tools/ncnnoptimize yolov10n.param yolov10n.bin yolov10n-opt.param yolov10n-opt.bin 1最后一个参数1表示FP16存储。FP16对精度影响很小在支持FP16的ARM芯片上内存带宽减半实际速度通常比FP32提升两到三成具体数字取决于手机的DDR频率和ncnn算子优化程度。Android端加载时不需要改任何代码param和bin换了lnet加载逻辑不变。5.2 INT8量化ncnn2table与校准集质量如果FP16还不够做INT8。准备几百张覆盖目标场景的图片逐行写入calib.list然后执行两步./tools/quantize/ncnn2table yolov10n-opt.param yolov10n-opt.bin calib.list yolov10n.table mean0,0,0 norm0.0039215686,0.0039215686,0.0039215686 shape640,640,3 pixelBGR thread4 ./tools/quantize/ncnn2int8 yolov10n-opt.param yolov10n-opt.bin yolov10n.table yolov10n-int8.param yolov10n-int8.bin参数说明mean和norm要和推理时预处理一致YOLOv10用1/255归一化所以norm填0.0039215686shape固定640×640pixelBGR对应训练时的通道顺序。校准完成后拿同一批测试图跑一遍mAP对比正常掉点在1到2个百分点内。如果掉点超过3先检查calib.list里的图片是否和实际场景重叠用纯城市道路图校准的模型到厂房检测零件量化误差会被场景差异放大。多保留几个量化版本运行时按场景加载这就是加载本地模型功能的价值。5.3 Vulkan加速GPU开关与回退策略Android端启用GPU计算只需要构造extractor前把use_vulkan_compute设为true但有三个前提预编译包带vulkan支持、手机GPU驱动正常、模型输入尺寸固定。Vulkan计算能明显降低持续推理时的CPU占用但在低端机上偶发GPU驱动问题稳妥做法是init时传控制开关初始化失败自动回退CPU推理。验证GPU与CPU结果一致性用同一张640×640图片分别跑两个版本导出检测框对比类别和IoU一致后再发版。项目说明文件里值得在顶部写清的信息是基准测试帧率、mAP、bin文件大小和各档阈值参数。后续换yolov10权重时重跑5.1和5.2两条命令把新bin丢进assets不用动任何Java代码。一个可复现的验证小技巧同一张图分别跑FP32和INT8两个bin导出两次检测框的IoU如果所有框的类别一致且平均IoU高于0.9说明量化质量在可接受范围内可以直接发布。本文还有配套的精品资源点击获取
返回列表