资讯详情

Android端目标检测模型部署实战:从NCNN/TFLite/MNN踩坑到TorchScript成功落地

📅 2026/10/3 11:10:12 | 华诺云谱 👁 阅读
Android端目标检测模型部署实战:从NCNN/TFLite/MNN踩坑到TorchScript成功落地
Android端目标检测模型部署血泪史从NCNN/TFLite/MNN全军覆没到TorchScript终见光明先交代一下背景。前阵子接了个活儿要在Android手机上跑一个目标检测模型用来识别工业场景下的零件缺陷。模型是在PyTorch里训练好的输入是一张640x640的RGB图输出是检测框、类别和置信度。听起来不算复杂真正动手之后才发现算法训练只是万里长征第一步把模型塞进安卓端并跑起来才是噩梦的开始。我前前后后试了NCNN、TFLite、MNN三条主流路线全部翻车最后靠着TorchScript才把问题解决。这篇文章就把整个排查和落地过程完整记录下来包含每一步的报错信息、排查思路和最终方案给后面要在安卓端部署PyTorch目标检测模型的朋友当个参考。本文适合哪些人看模型训练完了但还没接触过端侧部署的算法工程师、想在Android Studio里集成LibTorch的客户端开发、以及那些在NCNN/TFLite/MNN之间反复横跳、已经被算子兼容问题折磨到怀疑人生的同学。1. 项目整体设计与部署路线为什么非要在安卓上跑目标检测1.1 业务场景与模型选型思考这个项目的核心诉求是离线推理。检测目标出现在工厂车间里网络环境非常不稳定甚至某些区域完全禁止联网所以云端方案的可行性很低。模型必须在手机本地完成前向计算实时性要求大概在200ms以内的单帧推理延迟。模型本身是在PyTorch 1.9环境下训练的结构是经典的YOLOv5s带有FPN和PANet结构。训练时为了追求精度用了一些自定义的Focus层和SiLU激活函数。这些细节放在服务端跑完全没问题但放到端侧部署时就会引发一连串的算子兼容性问题后面会详细讲。为什么一开始选了三种框架去做尝试而不是直接上TorchScript因为从移动端部署的主流路线来看NCNN、TFLite、MNN都提供了比较成熟的端侧推理引擎在模型量化、算子优化、内存占用方面都比直接用PyTorch Mobile更有优势。尤其是在CPU推理场景下这些框架会用ARM汇编级优化理论上性能会好不少。1.2 为什么先圈定了NCNN/TFLite/MNN这三条路当时的技术选型是基于以下逻辑NCNN腾讯开源针对手机端做了深度优化社区活跃对YOLO系列模型有大量现成案例网上随便一搜就能找到raspberry pi、Android部署YOLOv5的教程。TFLiteGoogle官方出品Android Studio内置支持生成.tflite文件之后可以直接用Task Library加载目标检测模型省去很多手写后处理的工作。MNN阿里开源主打轻量化和高性能官方博客给出了很多技术指标算子覆盖也比较全。三条路各有各的拥趸理论上随便选一条都能通。但实际踩下来我发现这些框架对PyTorch模型的兼容程度并没有想象中那么美好。如果你模型里只有Conv、BN、ReLU这种最基础的算子那么确实没什么问题一旦加入Focus、SiLU、自定义切片这些复杂结构转换器就会开始发脾气。2. 三条主流路线接连翻车NCNN/TFLite/MNN的踩坑实录2.1 NCNN模型转换报错与算子兼容地狱一开始走的NCNN路线。官方推荐的流程是先把PyTorch模型导出成ONNX再用ONNX2NCNN把ONNX模型转成ncnn.param和ncnn.bin这两个文件。导出ONNX这一步还算顺利。在Python端用torch.onnx.export把模型导出设置opset_version11输入是[1,3,640,640]的Tensor输出是YOLOv5的三个检测头的原始输出。问题出在ONNX转NCNN那一步。使用onnx2ncnn工具转换时报了一个非常经典的错误Reshape: Assertion total ! 0 failed后来查了半天发现是YOLOv5导出ONNX后带了一些动态Reshape操作NCNN的转换器在解析这些动态shape时直接崩溃。当时给的解决办法是修改YOLOv5的export.py脚本把detect层里的输出处理全部固定成静态shape然后重新导出。改完之后确实能转换了但紧接着又遇到SiLU算子不支持的问题。NCNN对SiLU的支持在旧版本里并不完善转换器会提示Unsupported operator: SiLU解决办法有两个一是把模型里的SiLU替换成1.0 / (1.0 exp(-x))这种公式展开形式再利用已有的算子组合去表示二是升级到新版NCNN2.0以上版本才开始有较好的支持。但即便解决了算子问题后面验证精度的时候又出现了坐标偏移检测框整体偏移了几十个像素排查起来非常耗时。NCNN这条路线在当时的版本下对YOLOv5的完整支持度不够好网上教程里的成功案例大多是基于修改过的简化模型完全照搬下来并不能直接运行最后还是选择放弃。2.2 TFLite量化后精度崩坏CPU推理耗时扛不住第二条路是TFLite。这条路从工具链上看是最省心的Android Studio里直接支持导入.tflite模型Task Library还能直接加载SSD、YOLO这类检测模型连后处理代码都帮你写好了。但问题出在模型转换那一步。PyTorch模型转TFLite的常规路径是PyTorch - ONNX - TF - TFLite中间经过了两轮之间的格式转换每转一次都会有一定的算子映射损耗。我实际试下来ONNX模型导入TF之后有一堆算子无法识别需要先转成TF2的SavedModel格式再调用TFLiteConverter转换中间还需要手动指定输入输出的签名。TFLiteConverter转出来的模型有两种选择float32全精度版和int8量化版。全精度版模型大小在100MB左右在低端安卓手机上单帧推理时间超过了600ms完全无法用于实时检测。量化版本模型大小压到了30MB但精度下降非常严重原本mAP 0.85的模型量化后只有0.61小目标的检出率几乎归零漏检极其严重。我试过用代表性数据集做校准也试过per-channel量化但效果都不理想。后来查资料发现YOLOv5的检测头输出对量化非常敏感尤其是最后的分类分支和回归分支经int8量化后数值精度不足导致输出置信度整体偏低检出的目标数少得可怜。TFLite的路子并不是完全走不通只是对于我这套模型来说需要用TensorFlow重新训练或者做更精细的量化感知训练工作量太大了性价比不高于是也放弃了。2.3 MNN模型转换成功却被诡异的输出结果卡住第三条路是MNN。MNN是我当时期望值最高的一条路因为阿里的技术博客里写了大量针对端侧推理的优化手段而且转换工具支持ONNX直转社区也有不少人分享YOLOv5转换MNN的案例。实际操作下来MNN的转换工具确实比NCNN稳定ONNX转MNN一次通过没有报错。MNN模型文件大小也很合适float32版本大概90MB加载速度还挺快。但真正跑到Android端验证推理结果时却发现了非常诡异的现象模型输出的检测框位置是正确的但所有类别的置信度都集中在0.01到0.1之间完全没有高分输出。这意味着模型的前向计算结果是部分正确的但在某个环节出现了数值异常。排查了很久最后发现问题出在输入数据的预处理上。MNN的Tensor数据格式要求是NHWC还是NCHW需要显式指定而默认情况下MNN会使用NHWC。我在喂数据的时候没有做格式转换导致输入的图像数据布局错误卷积核在计算时读到了错位的像素结果自然不对。修正之后检测结果回来了但推理延迟依然偏高。在骁龙765G上单帧推理时间在450ms左右虽然比TFLite好一些但距离实时检测还有很大差距。而且MNN对YOLOv5的anchor解码和NMS后处理没有内置支持需要自己在Android端写一大堆后处理逻辑调试起来非常痛苦。MNN这条路线差一点就成了但综合时间成本和性能指标还是没能达到上线要求。2.4 三条路走过的共性问题复盘NCNN、TFLite、MNN三条路线全军覆没之后我做了个复盘总结出以下共性问题模型格式转换的每一轮都在损耗信息ONNX作为中间格式的表达能力有限遇到自定义算子就会出问题。各家推理框架对目标检测模型的后处理支持不统一YOLO的decode、NMS这些逻辑在服务端是Python代码到了端侧需要重写成Java/C工作量大且容易出错。端侧推理的性能表现与模型的复杂度和框架的算子优化程度强相关不一定框架越热性能越好必须实测。每换一个框架都要重新解决一遍图片预处理、输入格式、输出解析等问题重复劳动太多。基于这个复盘我开始认真考虑直接用PyTorch官方的TorchScript路线不折腾格式转换从源头减少兼容性问题。3. 转战TorchScript三天从模型转换跑到安卓真机3.1 模型转换脚本的正确打开方式TorchScript是PyTorch官方推出的序列化格式可以理解成把Python模型“编译”成一个静态计算图然后在没有Python环境的地方运行。安卓端可以直接集成LibTorch库来加载TorchScript模型不需要额外转换格式。转换过程非常简单不需要ONNX作为中间格式。在Python端执行torch.jit.trace或者torch.jit.script把PyTorch模型导出为一个.pt文件。我使用的是trace方式因为模型结构是确定的不涉及数据相关的控制流。转换脚本如下import torch from models.experimental import attempt_load model attempt_load(weights/yolov5s.pt, map_locationcpu) model.eval() example_input torch.randn(1, 3, 640, 640) traced_model torch.jit.trace(model, example_input, strictFalse) traced_model.save(yolov5s_traced.pt)需要注意的是strictFalse这个参数很关键。YOLOv5模型里有部分前向逻辑不是完全可trace的不加这个参数会报错。另外trace的时候输入尺寸必须固定我用的就是640x640后面在Android端推理时输入尺寸也必须保持一致否则模型会跑不起来。转换完成后我对比了TorchScript模型和原始PyTorch模型的输出发现数值完全一致没有任何算子兼容问题。这一刻心里真的松了一口气走了那么多弯路结果最靠谱的还是PyTorch官方的方案。3.2 Android端TorchScript部署的完整步骤Android端集成LibTorch的流程比想象中要简单。在build.gradle里添加依赖dependencies { implementation org.pytorch:pytorch_android_lite:1.10.0 implementation org.pytorch:pytorch_android_torchvision_lite:1.10.0 }然后导入.pt模型文件到项目的assets目录。加载模型的代码如下val module Module.load(assetFilePath(context, yolov5s_traced.pt))assetFilePath是一个辅助函数用来获取assets目录下文件的绝对路径。这里有个坑LibTorch加载模型时要求传入的是文件路径而不是AssetManager直接读取的字节流所以需要先把文件解压到应用的外部存储或者内部存储。具体代码如下fun assetFilePath(context: Context, assetName: String): String { val file File(context.filesDir, assetName) if (!file.exists()) { val inputStream context.assets.open(assetName) val outputStream FileOutputStream(file) inputStream.copyTo(outputStream) inputStream.close() outputStream.close() } return file.absolutePath }模型加载完之后就是输入数据的处理。从相机或者图片中拿到Bitmap需要缩放到640x640然后转成Float数组按RGB顺序排列再做归一化处理。这部分代码需要特别注意因为PyTorch模型的输入是NCHW格式也就是channel维度在第二维。Android端拿到的是HWC格式的Bitmap需要手动转换val bitmap Bitmap.createScaledBitmap(originalBitmap, 640, 640, true) val pixels IntArray(640 * 640) bitmap.getPixels(pixels, 0, 640, 0, 0, 640, 640) val inputTensor FloatArray(3 * 640 * 640) for (i in pixels.indices) { val r (pixels[i] shr 16) and 0xFF val g (pixels[i] shr 8) and 0xFF val b pixels[i] and 0xFF inputTensor[i] r / 255.0f inputTensor[640 * 640 i] g / 255.0f inputTensor[2 * 640 * 640 i] b / 255.0f }然后封装成Tensorval tensor Tensor.fromBlob(inputTensor, longArrayOf(1, 3, 640, 640)) val output module.forward(IValue.from(tensor)).toTensor()拿到输出之后按照yolov5的decode逻辑解析出检测框、类别和置信度。TorchScript版本的输出格式比较直观三个检测头拼接成一个大的Tensorshape是[1, 25200, 85]其中25200是三个尺度的anchor数量总和85是5框坐标置信度 80类别数。直接遍历所有候选框做sigmoid、阈值过滤、NMS即可。3.3 性能调优从弱鸡推理到能用的小手段第一次跑通TorchScript推理时性能其实也不理想。在骁龙765G上单帧推理需要大概380ms后来通过以下几个手段逐步优化到了180ms左右达到了实时检测的标准启用Pytorch的ANeuralNetworkAPI。在加载Module时可以通过Module.load方法传入一个额外的参数启用NNAPI加速。实测在支持的设备上能获得30%到50%的性能提升。使用fp16半精度模型。在转换模型时使用model.half()把权重和输入都转成半精度浮点数再trace。这样模型文件大小直接减半推理速度也更快。但fp16模型在部分不支持半精度加速的机型上会变慢甚至出错需要一个兜底策略。限制CPU核心绑定。在Android里通过Process.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND)提高当前线程优先级并在Pytorch的threads设置中开启多线程推理System.setProperty(pytorch.num_threads, 4)减少图像缩放的性能开销。Bitmap.createScaledBitmap在每帧调用时会产生大量GC改用复用Bitmap的方式避免频繁分配内存。这些优化做下来TorchScript方案的推理性能已经接近NCNN和MNN优化后的水平而且整个过程不需要处理算子兼容问题也不需要重写后处理逻辑整体开发效率提升了一个量级。4. 部署过程中最折磨人的五个问题与排查方法4.1 content://路径解析把文件路径搞砸了在集成过程中我踩过一个非常典型的问题从手机相册选取图片后拿到的是content://开头的URI而不是file://路径。直接用这个URI去加载图片会抛出FileNotFoundException。这个问题在Android开发里非常常见因为Android 7.0之后对文件访问权限做了重大改动应用之间共享文件必须通过FileProvider而FileProvider返回的就是content:// URI。解决方法是把content:// URI转换成实际的文件路径或者直接用ContentResolver读取输入流val contentResolver context.contentResolver val inputStream contentResolver.openInputStream(uri) val bitmap BitmapFactory.decodeStream(inputStream)这里注意不要试图用uri.path.toString()去拼接文件路径很多网上的代码会教你这样处理但这种方式在绝大多数情况下都是错的非常容易踩坑。4.2 LibTorch库集成与AS版本、NDK的兼容坑Android Studio加载LibTorch时一定要确认NDK版本和LibTorch的预编译库匹配。我用的AS版本是北极狐默认安装的NDK是22.x而LibTorch lite 1.10的预编译库是使用NDK 21编译的直接使用会报如下错误java.lang.UnsatisfiedLinkError: dlopen failed: library libtorch.so not found这个问题的排查方向比较隐晦。LibTorch库在加载时依赖一些额外的native so文件如果NDK版本过高可能导致这些so文件无法被正确链接。解决办法是在build.gradle中显式指定NDK版本android { ndkVersion 21.4.7075529 }或者是把android.experimental.enableNewDsl相关的配置关闭多试几个NDK版本找到一个稳定的组合。我当时用NDK 21之后问题彻底解决。4.3 输入Tensor维度错误logits全变NaN另一个折磨人的问题是模型输出的所有值都是NaN。排查过程非常曲折一开始怀疑是模型转换的问题后来发现是Tensor创建时维度指定错了。我在创建输入Tensor时误把Tensor的shape写成了longArrayOf(3, 640, 640)而模型的输入要求是longArrayOf(1, 3, 640, 640)。少了batch维度模型内部计算时索引越界导致输出变成了NaN。这个问题看起来简单但实际排查的时候很容易忽略。因为代码不会报错只有输出结果异常。建议在使用模块前先用小数据在Python端验证一下模型的输入输出shape再在Android端手动打印输入Tensor的shape确保两边完全一致。4.4 实时检测卡顿与内存抖动排查跑通之后进一步做实时检测时发现App存在明显的卡顿现象尤其是从相册选择多张图片连续检测时内存跳得非常厉害。用Android Profiler定位后发现每次推理完之后的NMS操作会在Java层产生大量临时对象频繁触发GC。原来的实现是在Java/Kotlin层写了一个通用的NMS遍历所有候选框频繁调用RectF构造器。后来我把NMS逻辑改到C层实现通过JNI调用极大减少了Java层的对象创建。同时把所有候选框数组做复用不每次new新数组内存抖动问题得到了明显缓解。4.5 模型文件过大打不进APK的解决思路原始模型文件大概90MB左右直接放进assets目录导致APK体积飙升到100MB以上而且Android Studio打包时还会默认对assets目录中的文件做压缩增加运行时解压开销。我的做法是采用fp16半精度转换把模型文件压缩到50MB左右。再进一步用torch.utils.mobile_optimizer做优化该工具会合并一些算子并删除冗余的计算节点能再压缩10%到20%的体积。如果模型体积还是太大可以考虑把模型文件放到SD卡或者允许用户在设置界面手动下载模型而不是打进APK。在项目初期直接把90MB模型打进APK不是不行但用户安装体验会非常差下载速度慢占存储空间大审核时也可能被限制。合理的方案是先打包一个轻量模型保证应用可安装然后引导用户下载完整的核心模型。结尾个人踩坑后的一点体会最后再分享一个小建议。如果你手里的PyTorch模型结构比较复杂包含自定义算子和多输出头建议优先考虑TorchScript而不是先去转ONNX再转NCNN或者TFLite。我在那次排坑过程中绕了很多远路每隔一个转换器就会遇到新的问题而TorchScript直接省掉了中间环节。NCNN/TFLite/MNN的算子优化确实做得不错但那是建立在模型结构足够规范的前提下。端侧部署的时间成本往往比预期高得多留够冗余工具链越短可控性越强。如果你现在正好卡在部署这一步希望这份记录能帮你少走一些弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑