拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于ncnn与PP-OCRv5的Android端离线OCR实战指南

最近有个需求要把手机里的发票照片、合同截图自动提取成文本第一反应是调云端OCR但网络环境不稳定而且数据还不能出本地。翻了一圈开源方案最后用nihui的ncnn-android-ppocrv5把PaddleOCR v5的几个模型跑在了ncnn上。这篇文章把这一路的选型、接入、踩坑和最终效果整理一遍给同样想在Android端做离线OCR的开发者当个参考。先说结论这套方案完全离线运行不需要任何云端请求图片经过检测、方向分类、文字识别三个模型后直接输出文本。ncnn在移动端的推理效率很高PP-OCRv5的模型精度也够用再加上项目本身封装得比较完整从GitHub拉下来编译改改入口就能跑通。整个过程中我踩得最深的坑其实不在模型而在Android的图片获取路径上后面会专门讲。1. 移动端离线OCR选型为什么最终是ncnn PP-OCRv51.1 离线OCR有哪些可选方案先说需求边界我要做的是端侧离线识别不能等服务器返回也不允许把图片传出去。这就排除了一大堆云端方案。剩下能在Android本地跑的主要就这几个方案离线中文效果模型体积二次开发成本备注Tesseract支持一般较大中等传统算法复杂版式差Google ML Kit云端为主较好小低离线不一定完整依赖GMSPaddleLite支持很好较大较高工具链偏重ncnn PP-OCRv5支持很好较小中等本文方案推理效率高Tesseract我最早试过tesseract4android也能跑但对中文的识别效果特别依赖二值化参数稍微有点背景纹理或者艺术字体就直接废了。PaddleLite本身不错但当时为了跑一个demo要处理模型格式转换、算子裁剪和一堆配置对只是“想尽快看到效果”的人来说太重。Google ML Kit离线能力又不完整而且对没有GMS的设备不友好。所以最后转向了ncnn这种通用推理框架配合PaddleOCR的模型。1.2 为什么PaddleOCR v5值得关注PP-OCRv5相比早期版本在检测和识别上都做了不少优化。检测模型对倾斜文字、弯曲文字的召回率更高识别模型对中文长文本的稳定性也更好。最明显的变化是模型的输入输出结构比以前更干净转成ncnn格式之后不需要写太多预处理逻辑。PaddleOCR官方一直是开源且允许商用的这一点对做产品很重要。模型本身自带中文、英文、数字的识别能力训练数据覆盖了常见的印刷体和部分手写体。用在票据、截图、书籍扫描这些场景准确率比Tesseract高出不止一个档次。1.3 用ncnn跑OCR和直接用PaddleLite的差别ncnn是专为手机端设计的推理框架体积小ARM指令集优化做得很好。相比PaddleLitencnn的模型文件是.param和.bin可以直接打开看网络结构排查问题时比二进制格式方便太多。而且ncnn的内存管理做得精细多模型加载时也能共享部分底层资源。nihui维护的这个ncnn-android-ppocrv5项目属于“拿来就能跑”的类型。它把PaddleOCR的检测、方向分类、识别三个模型都预先转换成了ncnn格式并且封装好了Android层调用接口。我只需要关注图片从哪来、结果怎么展示不用重写底层推理流程。这一点很关键因为OCR不是单模型任务而是一条完整流水线自己从零拼装容易漏掉细节。2. ncnn-android-ppocrv5里到底有什么检测、方向分类、识别三段流水线2.1 项目目录和模型文件一览从GitHub拉下来之后项目结构大概是这样的ncnn-android-ppocrv5/ ├── app/ │ ├── src/main/ │ │ ├── assets/ │ │ │ ├── det.param │ │ │ ├── det.bin │ │ │ ├── cls.param │ │ │ ├── cls.bin │ │ │ ├── rec.param │ │ │ └── rec.bin │ │ ├── java/ │ │ │ └── com/nihui/ppocrv5/ │ │ │ ├── MainActivity.java │ │ │ └── OCR.java │ │ └── cpp/ │ │ ├── ocr.cpp │ │ ├── ocr.h │ │ └── CMakeLists.txt │ └── build.gradle ├── third_party/ │ ├── ncnn/ │ └── opencv-mobile/ └── README.mdassets里的六个文件就是三个模型每个模型拆成param和bin两部分。param描述网络结构bin存放权重。det负责找文本位置cls负责判断方向rec负责把文字内容读出来。2.2 识别流水线检测框裁剪、方向纠正、文字解码整条流水线在C侧一次性执行流程如下vectorTextBox boxes detector(rgb); // 1. 检测文本区域 for (auto box : boxes) { Mat cropped cropAndWarp(rgb, box); // 2. 按照四边形抠图 bool reverse classifier(cropped); // 3. 判断是否颠倒 if (reverse) rotate180(cropped); // 4. 纠正方向 string text recognizer(cropped); // 5. 识别文字 }模型本身是按“普通横排文字”训练的如果图片里存在旋转180度的文字识别率会急剧下降。方向分类模型就是用来解决这个问题的它只输出一个二分类结果正向还是倒置。抠出来的文本框先过一遍分类再决定要不要旋转。文字识别模型最后会输出一串字符序列C层还做了置信度过滤和空格处理。最终返回给Java层的是一行字符串或者多行字符串按原始位置从上到下排序。2.3 JNI层与Java层的分工Java层只负责最外层的交互比如选择图片、转Bitmap、调用native方法、展示结果。核心推理都在native层。OCR.java里大致会有类似这样的声明public class OCR { private long mNativePtr; static { System.loadLibrary(ppocr); } public native boolean init(); public native String recognize(Bitmap bitmap); }native层初始化时同时加载det、cls、rec三个模型。识别时先把Bitmap转成ncnn::Mat然后依次跑三个网络。这样的好处是模型只需要加载一次不会每次识别都重新读文件。3. Android Studio接入实操NDK、CMake与首次编译避坑3.1 Android Studio、SDK、NDK版本选择我当时用的环境是Android Studio最新稳定版SDK Platform 31NDK r23cCMake 3.22.1。如果你用太新的NDK比如r26或者r27项目里老的C写法偶尔会报编译错误。这不是说项目不能适配新NDK而是首次跑通阶段没必要给自己加难度。Android SDK和NDK下载路径都要避免中文或者空格。Windows上尤其注意C:\Program Files没问题但如果项目路径带了中文CMake生成阶段会有各种奇怪问题。Linux/macOS相对好一点。3.2 Gradle与CMake配置要点项目根目录的build.gradle里要确认abiFilters一般只保留armeabi-v7a和arm64-v8adefaultConfig { ndk { abiFilters armeabi-v7a, arm64-v8a } }CMakeLists.txt里核心就是引入ncnn和opencv-mobile。第三库目录可以是项目的third_party也可以用环境变量指定。比如set(ncnn_DIR ${CMAKE_SOURCE_DIR}/third_party/ncnn/${ANDROID_ABI}/lib/cmake/ncnn) find_package(ncnn REQUIRED) set(OpenCV_DIR ${CMAKE_SOURCE_DIR}/third_party/opencv-mobile/${ANDROID_ABI}/sdk/native/jni) find_package(OpenCV REQUIRED)opencv-mobile是精简版OpenCV只保留Android端常用模块体积比标准版小很多。这个项目里主要用Mat、resize、warpAffine这些基础功能够用了。3.3 编译常见报错清单第一次编译大概率会遇到几个报错我把最常见的列一下报错信息原因解决办法NDK not configuredSDK里没装NDKSDK Manager里安装对应版本c_static not foundNDK版本不匹配换r23c或指定stl类型OpenCV missingcmake找不到OpenCV检查OpenCV_DIR路径std::__ndk1 not found链接器版本和编译头不一致清理build后重新编译还有一个容易忽略的点首次编译需要下载ncnn预编译库或者本地源码编译。如果直接从GitHub拉项目务必要把子模块也拉下来git clone --recursive https://github.com/nihui/ncnn-android-ppocrv5.git如果漏了子模块third_party目录是空的cmake阶段就会失败。4. 相册选图到识别成功处理content:// URI和FileProvider的正确姿势4.1 一张相册图片带来的崩溃content:// URIDemo默认是用工程里的一张测试图但真实应用肯定要从相册选图。我一开始图省事直接把onActivityResult里拿到的data.data转成String路径传给native识别结果一跑就崩。原因很简单Android相册返回的Uri是content://开头不是file://。这种Uri对应的是ContentProvider管理的一个抽象数据流底层文件可能在任何位置。Java层用ContentResolver能读但C/C的fopen不认识这种Uri更不可能直接按路径打开。4.2 把URI可靠地变成文件复制到cache目录最稳妥的办法是把Uri对应的输入流复制到自己的cache目录再拿到绝对路径传给native层。Kotlin写法private fun copyUriToCache(uri: Uri): String { val input contentResolver.openInputStream(uri) ?: throw IOException(cannot open input stream) val file File(cacheDir, ocr_input_${System.currentTimeMillis()}.jpg) file.outputStream().use { output - input.copyTo(output) } return file.absolutePath }然后在onActivityResult里调用override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode RESULT_OK requestCode REQ_PICK_IMAGE) { val uri data?.data ?: return val path copyUriToCache(uri) val text ocr.recognize(path) textView.text text } }选择cache目录而不是getExternalFilesDir是因为cache目录不需要申请存储权限。Android 13开始读取媒体文件需要单独的READ_MEDIA_IMAGES权限但通过系统相册选择器返回的Uri已经带有了临时读取授权复制到cache目录可以完全绕开权限申请。4.3 拍照、FileProvider和Android/data的分区限制如果是拍照后识别直接调用Camera返回的Uri也是content://同样走复制流程。不过拍照前要先用FileProvider生成content:// Uri不能直接把file://路径传给相机。另外网上有些老教程会教你拼这种路径/storage/emulated/0/Android/data/com.example/files/xxx.jpgAndroid 11开始对Android/data目录加了限制第三方应用不能直接访问这个目录下的文件即使你有存储权限也不行。如果你的代码尝试直接访问这个路径大概率会抛FileNotFoundException。正确的做法永远是通过ContentResolver打开InputStream然后复制到自己的可控目录不要依赖任何绝对路径。5. 模型替换与ONNX转ncnn把PaddleOCR v5模型变成Android能跑的格式5.1 从PaddleOCR官方模型到ONNXDemo自带的模型已经能识别中英文但如果你有特殊场景比如只识别数字、识别特定字体就需要自己替换模型。首先从PaddleOCR项目获取模型。可以通过paddleocr命令或者官网下载解压后会有inference模型目录里面包含inference.pdmodel和inference.pdiparams。下一步要转成ONNX我用的是paddle2onnxpaddle2onnx --model_dir ./inference/det \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file det.onnx \ --opset_version 12注意opset_version不要太高ncnn对太新的算子支持可能滞后。12是一个比较稳的版本。5.2 onnx2ncnn转换与模型优化在Ubuntu上先编译ncnn工具链git clone https://github.com/Tencent/ncnn.git cd ncnn mkdir build cd build cmake -DNCNN_BUILD_TOOLSON .. make -j4编译完成后在build/tools/onnx目录下会有onnx2ncnn工具。转换命令./onnx2ncnn det.onnx det.param det.bin转换成功后建议再用ncnnoptimize优化一次减少模型体积和推理耗时./ncnnoptimize det.param det.bin det_opt.param det_opt.bin 6553665536这个参数代表保存FP16权重可以在支持FP16的设备上获得更快的推理速度同时把体积砍半。如果某些设备不支持FP16ncnn运行时也会自动回退到FP32。5.3 替换Assets模型并修改代码把优化后的模型文件复制到app/src/main/assets目录保持原来的命名比如直接覆盖det.param和det.bin。然后在OCR.java里检查模型初始化时的路径确保加载的是这个assets目录下的文件。要注意一个细节PaddleOCR模型的输入图像格式要求是BGR而且每个像素要归一化到0~1范围。如果直接送入0~255的RGB数据识别结果会差很多。demo代码里已经在预处理阶段做了转换但如果你是自己写推理逻辑一定不要漏掉这一步。6. 实测数据与高频问题排查速度、内存和“no text detected”6.1 中低端机上的实测数据我手上这台测试机是高通骁龙778G8GB内存arm64-v8a。对一张1080x1440的合同截图做全流程识别耗时大概在350ms到450ms之间内存峰值约220MB。首次加载模型要1秒多之后识别很快。如果只是识别单行文字把检测框固定住只跑识别模型耗时能压缩到80ms以内。所以如果你做的是连续识别视频流这类场景建议单独优化入口不要每次都跑完整检测模型。测试内容耗时内存峰值首次初始化约1.2s60MB完整识别1080x1440350-450ms220MB单行文字识别约80ms220MB6.2 方向分类模型的隐藏作用我试过关掉cls模型只跑det和rec结果识别准确率明显下降。原因是Photoshop导出或者手机拍照时部分文本框会被旋转90度或者180度。如果四边形检测能给出足够准确的顶点坐标可以通过透视变换纠正90度倾斜但180度倒置只能靠方向分类模型判断。方向分类模型虽然小但非常重要。它只输出正或倒两个结果推理耗时几乎可以忽略不计但在竖排文本、扫码件拍摄场景下能救回不少准确率。6.3 高频报错与调优经验我遇到最频繁的报错有这些“no text detected”图片里文字太小、模型加载失败、输入图片模糊。先确认模型路径正确再检查图片分辨率建议把长边压到1200像素以内。“could not create a primitive”往往是模型输入尺寸和代码里的固定尺寸不匹配。PP-OCR模型输入是动态shape但ncnn需要固定一个推理尺寸项目里默认用了一个合适的值改成自己的模型后要重新适配。识别结果乱序这是后处理问题。检测框需要按位置排序一般按左上角Y坐标排序同一行内再按X坐标排序。调优方面最有用的就是线程数。在Java层初始化时可以通过ncnn::Option设置opt.num_threads 4;4线程在大多数手机上是最甜的平衡点。线程太多会触发CPU过热降频反而变慢线程太少又浪费多核性能。最后一个小细节图片太大会导致检测阶段耗时暴涨甚至内存溢出。我一般会在调用识别前做一次等比缩放宽度超过1280就缩到1280这样速度和精度都稳定很多。如果你也需要落地这个项目建议把图片处理、URI复制、结果排序这些外围代码都封装好模型只是其中一环真正决定体验的是整条链路稳不稳。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门