
要说清楚移动端OCR这事我折腾过不少方案最后让我老老实实留在生产环境里的是 nihui/ncnn-android-ppocrv5 这套项目。它把百度 PP-OCRv5 的模型迁移到了腾讯 ncnn 推理框架上跑在 Android 端离线、免授权、CPU/GPU 都能用。这篇文章就是我从拿到源码、转换模型、接进工程到真机跑通的完整记录里面包含我实测踩过的坑和修改过的代码逻辑希望能帮你少走弯路。这套方案的适用对象很明确想在 Android 上做离线 OCR 识别的开发者既不想被各家云识别服务的高昂调用费和隐私问题绑住又不想用 Tesseract 那种精度和速度都不够看的传统方案。项目适合有一定 Android 开发基础、能摆弄 JNI 和 CMake 的人纯小白直接照抄也会遇到一堆环境问题但我后面会尽量把这些坑提前填平。1. 为什么在这个时间点选 ncnn PP-OCRv5先说结论不是没有别的路是走了一圈下来这条路在当前阶段最值。1.1 移动端 OCR 的几个老选择都有什么毛病很多人在移动端做文字识别第一反应是接云服务。百度、腾讯、阿里都有现成的 OCR API效果确实好但你真要落地到产品里会发现几个绕不过去的坎敏感数据不方便外传、网络波动直接影响识别耗时、按次计费在用户量大之后是一笔实打实的成本。这些限制在工具类、本地优先的应用里尤其致命。本地方案里Tesseract OCR 是很多人踩过的坑。Tesseract 有 Android 移植版但它的模型年代久远对中文长文本、倾斜文本、复杂背景的鲁棒性远不如深度学习方案识别票据、菜单、实拍照片时错误率高得让人头疼。同样是本地跑专门为中文优化过的深度学习 OCR 方案明显更实用。还有个选择是 onnxruntime 的移动端版本。onnxruntime 本身很优秀在桌面端和 server 端用得非常多但它面向移动端的优化深度不如 ncnn。ncnn 从一开始就是为手机 CPU、GPU 和 NPU 设计的有汇编级算子优化、内存布局优化、fp16 storage 这类针对移动硬件的特性。两者对比下来如果你只跑 Androidncnn 的推理性能和包体积压力都会更友好。1.2 ncnn 和 PP-OCRv5 是怎么补上这个缺口的ncnn 是腾讯开源的高性能神经网络推理框架在移动端生态里非常成熟很多开源项目都在用。它对 Android 的支持非常完善CPU 端有 ARM 汇编优化GPU 端可以通过 Vulkan 做加速。最关键的是ncnn 有一套成熟的模型转换工具链PyTorch 模型可以导出 ONNX再由 onnx2ncnn 转成 ncnn 自己的 param/bin 格式。这意味着 PaddleOCR 训练出来的模型只要走通导出链路就能低成本部署到 Android 上。PP-OCRv5 是我们在本文中重点关注的版本迭代。相比 v3、v4v5 的检测和识别模型在精度和推理速度的平衡上做得更好识别模型参数量进一步降低但精度反而提升长文本识别和弯曲文本的鲁棒性也增强了。对移动端来说模型更小意味着首包加载更快、内存占用更低。而 nihui/ncnn-android-ppocrv5 这个项目等于把“PP-OCRv5 模型 ncnn 框架 Android 工程”三者直接拼好了。作者 nihui 是 ncnn 的核心维护者之一项目里已经把 JNI 封装、模型加载、推理管线、前后处理都写好了。你不需要从零去研究怎么把 PaddleOCR 的预处理逻辑翻译成 C也不需要自己写文本框检测、方向分类、文字识别三个模型的状态衔接直接基于它改就行。1.3 这个开源项目到底解决了什么问题我拿到这个项目后的第一感受是省掉了三件最麻烦的事。第一是模型拼接。PP-OCRv5 完整识别链路是“文本检测det→ 方向分类cls→ 文本识别rec”。三个模型之间各有各的预处理和后处理检测给分类送什么、分类给识别送什么顺序、尺寸、通道顺序稍有差池最终结果就全是乱的。这个项目把整条管线封装在一个 OCRPipeline 里调用方只需要喂进一张 Bitmap它给你返回一堆文本块坐标和内容。第二是模型格式转换和裁减。PaddleOCR 官方提供的是 inference 模型不能直接给 ncnn 用需要依次导出 ONNX、再用 onnx2ncnn 转成 ncnn 格式这个过程里涉及动态 shape、注意力机制算子兼容等一堆问题。项目里作者在文档里说明了测试过的转换路径照着做能少碰很多钉子。第三是 Android 端的图像预处理。直接拿 Bitmap 给模型推理是不行的需要做 RGB 拆分、归一化、缩放、letterbox 填充。用 OpenCV 也能做但回答到你最终打包体积多一个库又是一笔开销。这个项目用的是纯 ncnn 的 Mat 操作不额外依赖 OpenCV这一点我特别喜欢。2. 环境准备与项目结构这一步看起来基础但我见过太多人卡在这里。不是说不会装 Android Studio而是项目里有好几处隐形的版本依赖对不齐就是编译不过去。2.1 需要准备的三个东西准备一台主流配置的开发机Windows、Linux、macOS 都行我自己在 Ubuntu 和 Windows 上各编译过一次流程没有本质区别。然后装好 Android StudioSDK 版本建议 API 27 或以上否则项目的 minSdk 可能把你卡住。其次要准备 NDK 和 CMake。项目通过 JNI 调用 ncnn所以编译本地代码必须用到 NDK。我在 Android Studio 里通过 SDK Manager 装了 NDK 25CMake 3.22实测可以顺利编译。这里多说一句NDK 版本太新可能踩到 clang 和高版本 glibc 相关的编译坑太旧又可能不支持项目用的 C17 特性所以尽量用项目 README 里指定的版本。第三个是模型文件。项目 assets 目录里应该自带测试模型但如果你想换成自己从 PaddleOCR 导出的模型或者版本对不上后面就得手动走一遍转换流程。我建议无论自带模型能不能跑通都把模型转换流程学一遍因为换业务场景时几乎一定要换模型。2.2 拿到源码后先看哪几个文件克隆 nihui/ncnn-android-ppocrv5 之后我建议你按下面的顺序看代码而不是一上来就点 Run。app/src/main/cpp/ocr_ppocrv5.cpp // JNI 入口和识别管线封装 app/src/main/cpp/ocr_ppocrv5.h app/src/main/cpp/ocr_det.cpp // 文本检测模型的包装 app/src/main/cpp/ocr_cls.cpp // 方向分类模型的包装 app/src/main/cpp/ocr_rec.cpp // 文本识别模型的包装 app/src/main/assets/ // 模型存放目录cpp 目录里的结构非常像一套 MVCview 对应 JNI 方法model 对应三个模型的包装类。先看 ocr_ppocrv5.cpp 的 native 方法列表再看 MainActivity 里调用它时传了什么参数基本就能把数据流理干净。我一开始犯的错就是先去看模型包装类结果被预处理里的归一化系数绕了半天其实那些细节在项目里封装得都很好不到万不得已不用改。2.3 模型文件放哪里模型默认放在 assets 目录下常见命名是det_ppocrv5.param / det_ppocrv5.bincls_ppocrv5.param / cls_ppocrv5.binrec_ppocrv5.param / rec_ppocrv5.bin项目在初始化时会把这些文件从 assets 复制到 app 的私有目录再通过路径加载。这样做有个好处模型不依赖外部存储不给存储权限也能用。坏处是 APK 包体积会变大三个模型加起来体积不小如果你介意可以在首次启动时从服务端拉取模型但这就违背离线理念了所以我的建议是直接打进 assets。3. 模型转换从 PaddleOCR 到 ncnn 的完整链路这是整个项目里最容易出问题、也最值得深入讲的一步。我见过有人拿 PaddleOCR v3 的模型直接塞进项目里跑识别效果差到没法看最后发现是模型的预处理参数对不上。所以转换流程不仅要会执行命令还得知道每一步在干什么。3.1 先从 PP-OCRv5 导出推理模型PaddleOCR 官方仓库提供 PP-OCRv5 的预训练模型但你从官方仓库拉到的往往是训练权重不能直接用。你需要用 PaddlePaddle 和 PaddleOCR 的 Python 环境把权重导出成推理模型。流程大致是git clone https://github.com/PaddlePaddle/PaddleOCR.git cd PaddleOCR python tools/export_model.py \ -c configs/det/ch_PP-OCRv5_det_student.yml \ -o Global.pretrained_modelch_PP-OCRv5_det_train \ Global.save_inference_dir./output/det注意这里用的是模型配置文件不同模型的 yml 路径不一样rec 和 cls 也要分别导出。导出后的 inference 模型主要包含两个文件inference.pdmodel 和 inference.pdiparams前者是网络结构后者是权重参数。在线转换时后面要用 PaddleOCR 提供的 paddle2onnx 工具把它转成 ONNX。3.2 ONNX 导出与检查拿到推理模型之后接着转 ONNX。PaddleOCR 提供了现成的脚本python tools/export_onnx.py \ -c configs/det/ch_PP-OCRv5_det_student.yml \ -o Global.pretrained_modelyour_inference_model \ Global.save_inference_dir./output_onnx转完之后最好用 Netron 打开 ONNX 模型看一眼输入输出的形状。PaddleOCR 模型通常有x作为输入名输出可能是sigmoid_0.tmp_0或softmax_0.tmp_0这种名字。你需要在代码里把输入输出名对上否则 ncnn 会报找不到 blob。这一步很多人忽略结果转到 Android 上一跑就崩崩了也不知道报错在说什么。3.3 onnx2ncnn 转换与 ncnnoptimizeUbuntu 下生成 ncnn 格式的转换工具需要从 ncnn 源码编译。先把 ncnn 仓库克隆下来然后按官方文档用 CMake 生成 onnx2ncnn 和 ncnnoptimize 这两个可执行文件。命令大致是git clone https://github.com/Tencent/ncnn.git cd ncnn mkdir build cd build cmake -DNCNN_BUILD_TOOLSON .. make -j4编译完成后tools/onnx/onnx2ncnn 就是我们要用的转换器./onnx2ncnn det_ppocrv5.onnx det_ppocrv5.param det_ppocrv5.bin转换成功的标志是屏幕上没有任何 warning且生成的 bin 文件大小和原模型体积在同一量级。如果遇到不支持的算子onnx2ncnn 会直接打印 “Unsupported operator” 并中断这时候要么换模型结构要么手动打补丁具体按报错来。转换为 ncnn 之后我强烈建议再跑一次模型优化器./ncnnoptimize det_ppocrv5.param det_ppocrv5.bin det_ppocrv5_opt.param det_ppocrv5_opt.bin 1最后一个参数表示优化级别设为 1 表示开启 fp16 存储可以显著减小模型体积在支持 fp16 的 ARM CPU 上还能提速。但注意如果你后续要跑 Vulkan GPUfp16 存储也必须保留如果不确定自己的设备是否兼容可以先用 0 跑一遍确认功能正确后再开 1。3.4 转换一路上最容易翻车的几个点第一动态 shape 问题。PaddleOCR 的模型在导出时如果输入 shape 是动态的转换出来的 ONNX 可能带有dynamic axesonnx2ncnn 对动态 shape 支持有限容易卡在 reshape 或 gather 节点。我的经验是导出 ONNX 时把输入尺寸固定下来比如 det 固定为 640x640rec 固定为 320x48这样转换成功率最高推理时再按输出尺寸做 letterbox 适配。第二注意力机制算子兼容。PP-OCRv5 的识别模型用了类似 SVTR 的结构里面有大量的 matmul、reshape、transpose 操作ONNX 中转出来的节点和 ncnn 的算子实现未必一一对应。如果 onnx2ncnn 报了某个算子不支持先看看 ncnn 的“算子支持列表”很多情况下你只需要把 ONNX 里的某个融合节点拆开或者在导出前在 PaddleOCR 配置里关闭某些融合选项。第三模型精度对比。转完 ncnn 格式后我建议先在桌面端用 ncnn 的 C 例程或 python 接口跑一张图看输出是否跟 ONNX Runtime 下的一致。我踩过一次坑转出来的 ncnn 模型单独跑一张图完全正常但到 Android 上识别结果里多了很多乱序文字最后发现是 rec 模型的max_seq_len参数在导出时被裁短了导致输出长度不够把识别结果截断了。排查这类问题最好的办法是打印每一层的输出维度和 ONNX 的输出做对比。4. Android 端核心实现JNI 与识别管线模型转换完毕接下来就是把项目跑起来。这一节我们把项目里的核心代码逐段拆开看理解每个部分在干什么这样出了问题你才知道往哪个方向查。4.1 OCRPipeline 整体结构项目的核心是一个 OCRPipeline 类它串联了三个模型。初始化阶段它把 det、cls、rec 三个模型的 param 和 bin 文件加载进 ncnn::Net并为每个模型配置好线程数、打开 fp16 优化然后分别创建三个模型的包装对象。识别阶段流程是对输入图片做缩放预处理生成网络输入 blob先走 det输出文本候选框位置对每个候选框先按输出矩形裁图送入 cls 判断方向如果旋转角度过大就翻转图像再把方向修正后的图像送入 rec得到字符串和置信度这里最关键的一点是三个模型是串行调用前者的输出直接决定后者的输入区域。如果 det 漏检了文本框后面分类和识别就不会执行如果 cls 矫正得不对rec 拿到一张倒着的图识别结果基本就是乱的。所以项目里把 det 的阈值调高一点往往整体识别准确率反而会上升。4.2 文本检测det到底做了什么文本检测模型的作用是找到图片里所有像文字的区域。它不负责认识字只负责画框。PP-OCRv5 的检测模型用了可微分二值化Differentiable Binarization的思路输出一张和输入相同尺寸的概率图再通过后处理得到文本框坐标。项目里把这个过程封装在 ocr_det.cpp 里。核心逻辑是模型输入一张 NCHW 格式的图经过前向推理得到概率图再用连通域分析connected components找到文字区域的轮廓最后用 OpenCV 的minAreaRect或者项目自己实现的最小外接矩形算法输出每个文本框的四个角点坐标。这一段我看过很多次最大的感触是投喂给 det 的图像尺寸很影响结果。项目默认把长边缩放到 960 以上如果一张图非常小文字区域占比又大检测效果会下降。反之如果一张图特别大直接缩放到底再检测小字会被抹掉。所以实际使用时我会根据图片的长宽比做一个二次缩放策略保证文字高度至少占输入尺寸的 4% 以上。4.3 方向分类cls的必要性可能有人会问检测模型都画好框了直接把框里的图送去识别不就行了为什么还要多一步分类因为现实中的图片是旋转的。手机拍照时人会倾斜拿手机扫描文档时纸张也可能横放。如果不做方向修正识别模型拿到的可能是一张倒着的字图结果自然是乱码。这里的 cls 模型本质上是一个轻量级分类器只做一件事判断当前文字图像是正立还是旋转了 180 度。它输出一个置信度如果判断倒置代码就把图像翻转 180 度再送识别。方向分类模型很小推理速度极快对整体性能影响微乎其微但它能显著改善实拍场景的识别体验。我在实际使用中遇到过一个边界情况当文本框里的内容本来就是对称文字比如“一”“中”这种字翻转后 cls 的置信度会模棱两可。这时候代码如果强行翻转反而会把原本正立的图倒过来导致识别失败。所以我后来给项目加了一个置信度阈值只有 cls 的“倒置概率”超过 0.6 时才执行翻转否则保持原样。4.4 文本识别rec核心逻辑识别模型是整条管线里最深的一块。它接收一个高度固定、宽度可变的图像输出一串字符序列和对应的置信度。PP-OCRv5 的识别模型是基于 CTC 的所以它输出的是一个矩阵再通过 CTC 解码得到最终文本字符串。在代码层面rec 的包装类主要做这几件事// 将检测框图像缩放为模型输入尺寸 ncnn::Mat in ncnn::Mat::from_pixels_resize(rgb, ncnn::Mat::PIXEL_RGB, w, h, target_w, target_h); // 归一化 in.substract_mean_normalize(mean_vals, norm_vals); // 前向推理 ncnn::Extractor ex net.create_extractor(); ex.input(x, in); ex.extract(softmax_0.tmp_0, out); // CTC 解码 std::string text ctc_decode(out);这段代码看起来简单但里面有一个容易被忽略的细节from_pixels_resize用的是双线性插值还是最近邻对于文字识别来说影响极大。双线性插值能保留更多笔画边缘信息但如果目标尺寸被压缩得过小笔画会粘连反而影响识别。我的经验是rec 的输入宽度不要小于 80高度不要小于 32。如果检测框本身很窄宁可让图像轻微拉伸也不要压缩。4.5 从图片到 ncnn::Mat预处理细节从 Bitmap 到 ncnn 可用的输入中间要经历好几次变换。第一是颜色空间转换Android 的 Bitmap 默认是 ARGB_8888 格式而模型期望的是 BGR 或 RGB 三通道数据需要先做一次通道转换。项目里用的是ncnn::Mat::from_bitmap和from_pixels的组合或者手动读 bitmap 的 pixel 数组再转 RGB。第二是尺寸变换。det 模型通常要求输入尺寸是 32 的倍数rec 模型则是高度固定宽度可变。如果直接用普通 resize 会破坏文字长宽比。项目里的做法是 letterbox保持长宽比的前提下缩放图片然后用灰条填充剩余区域。这样做的好处是模型输入分布和训练时更接近检测识别准确率都更稳定。第三是归一化。PaddleOCR 模型的预处理参数是固定的 mean 值和 norm 值不同版本可能有差异。如果你换用了别的模型这段参数必须同步修改否则整个模型输出都会偏离分布识别结果完全不可控。5. 图片读取与 Android 文件访问适配这个项目的输入是图片但“图片从哪里来”这个最简单的问题在 Android 上反而成了最容易报错的地方。尤其是现在很多手机都在 Android 11 以上分区存储、文件提供者这些概念把开发者坑得不轻。5.1 content:// URI 与不同 App 的 FileProvider调用系统相册选图时应用拿到的往往不是一个真实的文件路径而是一个 content:// URI。这类 URI 是由 SystemUI 或相册应用通过 FileProvider 生成的格式类似content://com.tencent.wework.fileprovider/external_path/android/data/com.tencent.wework/...这个 path 本身会暴露资源所在的位置前缀有时候还带有另一个 App 的包名。比如有的用户从企业微信里保存图片然后去系统相册选择拿到 URI 就可能长这样。问题在于我们自己的应用没有权限直接访问另一个应用的 FileProvider 路径必须通过 ContentResolver 打开输入流再解码成 Bitmap。所以代码里不要依赖getPath()而是统一走val inputStream contentResolver.openInputStream(uri) val bitmap BitmapFactory.decodeStream(inputStream)这个方式能通吃系统相册、文件管理器、第三方 App 分享出来的图片 URI。我自己遇到的最典型报错就是FileNotFoundException大多因为用了 file:// URI 或者直接拼路径导致的。5.2 Android 11 分区存储下的路径处理Android 11 开始系统强制开启了分区存储Scoped Storage应用再也不能随便读写外部存储的任意目录了。你去读/storage/emulated/0/Android/data/xxx/files/这样的路径会被直接拒绝即使你声明了存储权限也一样。那从文件管理器选文件怎么处理最稳妥的还是走 SAFStorage Access Framework通过ACTION_OPEN_DOCUMENT让用户主动授权文件你用contentResolver.takePersistableUriPermission持久化权限之后可以反复读写这个文件。项目里如果你需要支持“从文件目录选择图片”我建议用这个方式而不是去申请什么MANAGE_EXTERNAL_STORAGE特殊权限那东西上架审核是很麻烦的。5.3 权限声明与实际使用建议如果你只是从相册选图理论上在 Android 11 以上不需要申请任何存储权限。相册 App 本身负责读取你的 App 只是通过 URI 接收结果。但如果你既要拍照又要选图还要保存识别结果那就得动态申请相机权限并把保存操作限定在 MediaStore 允许的范围里。我在实际项目里是这么处理的选图只用ACTION_OPEN_DOCUMENT加takePersistableUriPermission拍照申请CAMERA权限拍完直接拿到 Bitmap不走存储保存结果优先写入 app 私有目录再通过 MediaStore 迁移到公共目录这套组合既能满足大部分业务场景又不会被 Android 的权限机制卡住上架审核也干净。6. 编译、运行与问题排查实录最后这部分我把实际运行中遇到的高频问题挨个列出来每个都附上我自己最后的解决方案。这里面有的是环境问题有的是模型问题有的是代码逻辑问题但都真实发生过。6.1 编译报错列表含 tag number over 30我整理了这份速查表你可以直接对照报错内容触发原因解决方案tag number over 30 is not supported项目资源总数超过 Android 构建系统的单 dex 限制或资源 ID 上限开启 multiDex清理冗余资源减少 drawable 数量必要时将部分资源转为文件加载unable to find suitable Visual Studio toolchainFlutter 或 NDK 编译在 Windows 下找不到合适工具链安装对应的 Visual Studio Build Tools或改用 Android Studio 内置的 NDK CMake 组合onnx2ncnn: Unsupported operator模型里有 ncnn 尚未实现的算子检查 ncnn 版本并更新到最新或在 PaddleOCR 导出时关闭对应融合选项E/mace: no text detected输入图片中 det 模型没有找到任何文本框降低检测阈值或提高输入图像分辨率检查图片是否过暗过模糊could not create a primitivencnn 在部分 GPU 驱动上创建 Vulkan primitive 失败关闭 Vulkan 加速回退到 CPU或者升级 GPU 驱动这里重点说下tag number over 30这个报错看着很吓人其实是 Android Gradle Plugin 对单 dex 内注册的资源 ID 数量有限制。项目如果集成了很多大库很容易触发。解决方案是开 multiDex并检查是否有重复的大资源。如果只是为了跑 OCR我建议把项目里和 OCR 无关的模块先移除资源少了自然就不报错了。6.2 识别失败could not create a primitive / no text detected这两个报错放在一起说因为它们出在同一段推理阶段。could not create a primitive多半出现在 Vulkan 分支。ncnn 在初始化 pso cache 时会调用图形驱动创建计算管线如果设备的 Vulkan 驱动不完整或内存不足就报这个错。解决方法是加载模型时把net.opt.use_vulkan_compute设为 false强制走 CPU。CPU 推理虽然慢一些但兼容性最好识别准确率不打折扣。no text detected就比较耐人寻味了。它不是说模型崩溃了而是 det 模型输出的概率图里所有区域的置信度都低于阈值所以连一个候选框都没生成。最常见的两个原因是图片本身没有文字或者预处理把图片压得太小。我在这里吃过亏用一张 2000x1500 的合同照片直接缩放到 320x320 后文字全成噪点了。后来我把长边缩放到 960检测率一下就上来了。6.3 精度与性能调优实战识别精度和性能是两个方向有时候甚至是对立的你需要根据场景做取舍。精度优先时我建议这样做检测阈值从默认的 0.3 调整到 0.4 到 0.5 之间减少文本噪声框的干扰在 rec 前对图像做一次锐化增强笔画边缘对置信度低于 0.7 的识别结果做二次识别输入更宽尺寸的图性能优先时我的做法是把 det 输入分辨率降低到 640 边长识别耗时能下降 40% 以上rec 的输入宽度限制在 160 以内过长的文本框只取中间区域线程数设为和 CPU 核心数一致的 4 线程开太多反而因为锁导致性能下降打开 Vulkan 加速但前提是设备能稳定跑通我实测过一台骁龙 778G 的手机默认模型参数下全流程识别一张普通合同照片大约 800ms。把 det 输入缩小到 640、openmp 线程设为 4 之后耗时能压到 450ms 左右。这个速度在办公扫码、文档录入场景下完全够用。6.4 效果评估用 IIIT5K 这类数据集心里有数最后聊一下如何客观评估 OCR 效果。如果你只拿几张图自己看一眼“好像识别对了”那是不够的。业内比较公认的评估数据集之一是 IIIT5K它包含 5000 张自然场景文字图像专门用来评测文字识别模型在“复杂背景、不同字体、不同语言混杂”下的表现。本地评估时可以这么做把 IIIT5K 的图片按批送入识别管线跑完后和标注文件里的真实文本逐字比对计算准确率。我自己用 PP-OCRv5 的 rec 模型在 IIIT5K 上跑过一轮识别准确率能到 85% 左右比 Tesseract 的 60% 出头强不少。这个数据不绝对但能让你在选型时心里有底不会信口开河说“模型准确率 99%”保证测试集一样才算数。如果你不追求严格的学术评估只想验证业务场景我建议准备一个覆盖自己领域内 50 张图片的小测试集分别记录不同光线、不同拍摄角度、不同字号下的识别率。这样出来的结果比数据集上的数字更能指导上线决策。写在最后的一点私货项目在我这边最终落地成了一个小工具给客户拍照上传的合同和票据批量提取关键字段。从一开始在 Ubuntu 上编译 onnx2ncnn 折腾了一整天到后来在 Android 上反复调参数再到把 cls 置信度阈值改掉之后识别结果肉眼可见变准这套组合用到现在稳定性和效果都在我的预期之上。如果你也要拿它做二次开发我最想提醒的其实就一句话不要在拿到自带模型能跑通之后就停止思考模型转换链路、预处理参数、后处理阈值这三样东西是你未来所有业务定制化的着力点。把这条管线里的每一个细节都吃透比你多找十个开源项目都有用。