最近有个需求要把手机里的发票照片、合同截图自动提取成文本,第一反应是调云端OCR,但网络环境不稳定,而且数据还不能出本地。翻了一圈开源方案,最后用nihui的ncnn-android-ppocrv5把PaddleOCR v5的几个模型跑在了ncnn上。这篇文章把这一路的选型、接入、踩坑和最终效果整理一遍,给同样想在Android端做离线OCR的开发者当个参考。
先说结论:这套方案完全离线运行,不需要任何云端请求,图片经过检测、方向分类、文字识别三个模型后直接输出文本。ncnn在移动端的推理效率很高,PP-OCRv5的模型精度也够用,再加上项目本身封装得比较完整,从GitHub拉下来编译,改改入口就能跑通。整个过程中我踩得最深的坑其实不在模型,而在Android的图片获取路径上,后面会专门讲。
1. 移动端离线OCR选型:为什么最终是ncnn + PP-OCRv5
1.1 离线OCR有哪些可选方案
先说需求边界:我要做的是端侧离线识别,不能等服务器返回,也不允许把图片传出去。这就排除了一大堆云端方案。剩下能在Android本地跑的,主要就这几个:
| 方案 | 离线 | 中文效果 | 模型体积 | 二次开发成本 | 备注 |
|---|---|---|---|---|---|
| Tesseract | 支持 | 一般 | 较大 | 中等 | 传统算法,复杂版式差 |
| Google ML Kit | 云端为主 | 较好 | 小 | 低 | 离线不一定完整,依赖GMS |
| PaddleLite | 支持 | 很好 | 较大 | 较高 | 工具链偏重 |
| 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指令集优化做得很好。相比PaddleLite,ncnn的模型文件是.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++侧一次性执行,流程如下:
vector<TextBox> 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 31,NDK r23c,CMake 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-v8a:
defaultConfig { 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 configured | SDK里没装NDK | SDK Manager里安装对应版本 |
| c++_static not found | NDK版本不匹配 | 换r23c或指定stl类型 |
| OpenCV missing | cmake找不到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:// URI
Demo默认是用工程里的一张测试图,但真实应用肯定要从相册选图。我一开始图省事,直接把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官方模型到ONNX
Demo自带的模型已经能识别中英文,但如果你有特殊场景,比如只识别数字、识别特定字体,就需要自己替换模型。
首先从PaddleOCR项目获取模型。可以通过paddleocr命令或者官网下载,解压后会有inference模型目录,里面包含inference.pdmodel和inference.pdiparams。下一步要转成ONNX,我用的是paddle2onnx:
paddle2onnx --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_TOOLS=ON .. 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的设备上获得更快的推理速度,同时把体积砍半。如果某些设备不支持FP16,ncnn运行时也会自动回退到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 中低端机上的实测数据
我手上这台测试机是高通骁龙778G,8GB内存,arm64-v8a。对一张1080x1440的合同截图做全流程识别,耗时大概在350ms到450ms之间,内存峰值约220MB。首次加载模型要1秒多,之后识别很快。
如果只是识别单行文字,把检测框固定住,只跑识别模型,耗时能压缩到80ms以内。所以如果你做的是连续识别视频流这类场景,建议单独优化入口,不要每次都跑完整检测模型。
| 测试内容 | 耗时 | 内存峰值 |
|---|---|---|
| 首次初始化 | 约1.2s | 60MB |
| 完整识别1080x1440 | 350-450ms | 220MB |
| 单行文字识别 | 约80ms | 220MB |
6.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复制、结果排序这些外围代码都封装好,模型只是其中一环,真正决定体验的是整条链路稳不稳。