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

资讯详情

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

ncnn+PP-OCRv5:Android离线OCR部署实战

ncnn+PP-OCRv5:Android离线OCR部署实战 最近在给一个安卓项目加离线OCR能力目标很明确拍照或从相册选图识别出图片里的中英文文字。当时没有多犹豫直接锁定了 nihui/ncnn-android-ppocrv5 这个开源项目来做。原因很简单ncnn 在移动端推理框架里属于老牌选手而 PP-OCRv5PaddleOCR 的第5代检测识别模型在中英文识别尤其是中文场景下效果比 Tesseract 这类传统 OCR 好太多。这个仓库是一个可以直接拉下来编译的 Android 示例工程里面已经把 ncnn 编译好了、PP-OCRv5 的检测和识别模型也转成了 ncnn 格式还封装好了 JNI 接口。你要做的事情其实就是把它接入自己的 App处理好图片选择和权限再针对自己的业务场景微调几个检测参数。这篇文章我会把我实际部署的完整过程写出来包括环境准备、模型格式转换、Android 工程集成、图片 Uri 处理、参数调优以及一路上踩过的坑。适合刚接触 Android OCR、或者对 ncnn 部署有兴趣的同学照着走基本能跑通。1. 项目核心思路为什么是 ncnn PP-OCRv51.1 这套组合解决了什么问题OCR 在移动端有两种做法一种是联云端 API一种是在本地跑模型。联云端方案识别率确实好但离线不能用、有并发和费用问题还会把图片内容送出去很多企业内部项目根本接受不了。本地方案里传统做法是 Tesseract部署简单中文识别效果却不尽如人意复杂一点的中文场景基本没法看。nihui/ncnn-android-ppocrv5 走的是本地推理路线。它在端侧加载两个 ncnn 格式模型跑两阶段 OCR 流程。第一个模型做文本检测把图片里的文字行区域用框标出来第二个模型做文本识别把每个文字行区域内的内容识别成字符串。检测模型和识别模型都是 PP-OCRv5 系列的模型体积控制在十几 MB 这个量级在手机上跑一次完整识别大概在几百毫秒到一两秒之间实用性很高。这套方案最大的价值在于不需要自己折腾从 Paddle 到 ncnn 的转换链路也不用自己写 JNI 和 OpenCV 图像处理逻辑。项目里全都有直接拿来用就是。1.2 对比市面上其他方案我把常见方案都对比过一遍下面这张表可以很直观地看出差异方案部署难度中文识别效果离线App包体积影响适合场景Tesseract OCR低一般支持较小英文、印刷体简单识别云端API低好不支持无需要联网、对隐私无要求PaddleOCR服务端部署中好支持无服务端批量识别ncnn PP-OCRv5中好支持增大约40-50MBAndroid端离线识别ML Kit OCR低较好部分支持较大Google服务环境选 ncnn PP-OCRv5 不是因为别的选择不行而是在“端侧离线 中文效果好 工程化完善”这三个条件同时满足的情况下它基本是当前最优解。1.3 ncnn 的工程化优势ncnn 是腾讯优图实验室开源的移动端推理框架后来由 nihui 主导维护。它跟 TensorFlow Lite、ONNX Runtime Mobile 比最大的优势是专门为手机 CPU、ARM 架构做了深度优化支持 Vulkan GPU 加速而且没有太多运行时依赖编译产物非常干净。在这个项目里ncnn 承担的是推理引擎的角色。你输入图片给 ncnn它把图片数据喂给模型做前向计算然后把结果返回。整个过程在 App 进程内完成不涉及网络请求这也是它适合离线场景的根本原因。2. 部署前环境准备与模型格式转换2.1 需要提前装好的工具在动工程之前先把环境处理好。我用的是 Ubuntu 20.04 做模型转换Windows 上用 Android Studio 写工程这个组合比较常见。需要准备的东西有这些Android Studio新版可以直接从官网下载安装包不到 1GB装完在 SDK Manager 里勾选 NDK 和 CMakeAndroid SDK、NDK建议 NDK r23 或以上但要避免用最新版后文有坑CMake 3.10 以上Python 3.7 PaddleOCR 或 PaddleOCR 官方模型库用于导出 ONNXonnx2ncnn 工具在 ncnn 源码 build/tools/onnx 目录下Android Studio 的安装没什么特别之处一路下一步就行。需要留意的是首次启动后要下载 SDK 组件国内网络环境下可能需要开代理或者直接去 Android 开发者官网下载离线 SDK 包。2.2 模型转换链路Paddle → ONNX → ncnn仓库里默认已经内置了转好的 ncnn 模型如果你直接用原仓库跑可以跳过这一步。但如果你想换模型版本、或者后续想针对自己的场景重新训练模型这条转换链路就很重要了。PP-OCRv5 官方提供的是 Paddle 训练格式模型ncnn 不能直接加载 Paddle 模型需要做两步转换。第一步把 Paddle 模型导出为 ONNX。PaddleOCR 官方仓库提供了paddle2onnx工具导出命令大致是paddle2onnx \ --model_dir ./inference/ch_PP-OCRv5_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./ch_PP-OCRv5_det.onnx \ --opset_version 11识别的模型同理把det换成rec再执行一遍。第二步用onnx2ncnn把 ONNX 转成 ncnn 的 .param 和 .bin 文件。onnx2ncnn相当于 ncnn 的模型翻译官做各种算子的翻译和映射把 ONNX 里的规则翻译成 ncnn 自己的描述语言。./onnx2ncnn ch_PP-OCRv5_det.onnx ch_PP-OCRv5_det.param ch_PP-OCRv5_det.bin转换完成后把生成的 .param 和 .bin 文件放进 Android 工程的assets目录代码里用loadModel加载即可。提示模型转换过程中的算子兼容问题很常见尤其是自定义算子。遇到报错时优先考虑冻结模型的输入输出维度onnx2ncnn对静态 shape 的模型支持更好。2.3 转换过程中常见的坑转换链路看着简单实际操作时最容易出问题的是这几个地方。一个是 Paddle 的动态图导出和静态图导出问题。如果直接拿动态图模型转 ONNX部分算子会多出不必要的动态维度描述导致 onnx2ncnn 解析失败或生成错误 shape。建议先通过 Paddle 的paddle.jit.to_static或者在导出 infer 模型时打开--export相关参数把模型冻结成静态图再转。另一个是激活函数和归一化算子的融合问题。ONNX 里经常出现一些碎算子比如BatchNormalization后面挂Reluncnn 有融合优化但某些组合可能支持不完整。碰到这种情况要么改导出参数要么手动在 ONNX 里做算子简化onnxsim这个工具在这时候很有用跑一遍能大幅减少冗余算子。另外就是老生长谈的 shape 动态维问题。PP-OCRv5 检测模型要求输入图片宽高是 32 的倍数识别模型输入固定 48x320h x w。这个在 Android 端调用的时候要特别注意图片 resized 之后要检查是否对齐模型输入要求否则识别结果会莫名变差。3. 把 ncnn-android-ppocrv5 集成到自己的 App3.1 项目目录结构解析先把这个仓库 clone 下来git clone https://github.com/nihui/ncnn-android-ppocrv5.git目录结构很清晰核心部分集中在app/src/main下assets/ch_PP-OCRv5_det.param/.bin文本检测模型assets/ch_PP-OCRv5_rec.param/.bin文本识别模型cpp/ncnn_ocr.cppJNI 接口层负责加载模型、调用推理、返回结果java/.../MainActivity.java示例 UI负责选图和显示结果如果只是想先跑通 demo直接用 Android Studio 打开这个工程编译到手机上就能看到效果。界面很简单一个按钮选图一个按钮跑识别下面一个 TextView 显示文字结果。3.2 把代码搬进自己的工程实际项目里没人会把 demo 当生产代码用大部分情况是把 ncnn 推理能力抽出来嵌入到自己的业务 App。抽取的时候核心就三块cpp目录下的 JNI 代码、assets目录下的模型文件、以及构建配置。ncnn_ocr.cpp是整个工程最值钱的部分。它内部主要做了四件事加载 ncnn 模型并初始化打开可选的 Vulkan 加速接收 Java 层传下来的 Bitmap 或路径转成 ncnn 需要的 Mat 格式先跑检测模型拿到文字行坐标集合对每个文字行区域做裁剪、缩放、归一化再跑识别模型拼出最终字符串这些逻辑如果你完全自己写工作量不小。直接搬这个 cpp 是最好的选择它把 ncnn 的 C API 和 Java 层做了很好的隔离。你唯一要改的可能就是把自己的包名塞进 JNI 函数注册的地方以及封装一个更符合自己项目风格的OcrEngine单例。3.3 构建配置和 so 库app/build.gradle里有关键的 abiFilters 配置defaultConfig { ndk { abiFilters arm64-v8a } }这里我建议只保留arm64-v8a。现在市面上 99% 的手机都是 64 位处理器集成armeabi-v7a和x86只会把 APK 体积撑大没有实际意义。如果测试机是老设备再加armeabi-v7a也不迟。ncnn 的 so 库在这个项目里是预编译好的会随工程一起打包不需要你自己编译 ncnn。这个省了很多事因为 ncnn 从源码编译要下载很多依赖容易卡在各种网络问题上。3.4 Java 层封装示例调用识别不复杂核心代码大概长这样public class OcrEngine { static { System.loadLibrary(ncnn_ocr); } private long nativeHandle; public OcrEngine(String detParam, String detBin, String recParam, String recBin) { nativeHandle init(detParam, detBin, recParam, recBin); } public native long init(String detParam, String detBin, String recParam, String recBin); public native String recognize(long handle, Bitmap bitmap); // 使用完记得释放 public native void destroy(long handle); }注意assets里的模型路径要对上别姓脱了。加载模型的时机建议放在后台线程因为模型加载在低端机上耗时可能到几百毫秒放主线程会卡。4. 图片选择与 Android 11 文件访问那点事4.1 不要直接传 file:// 路径这块百分百是新人最容易踩的坑。Android 4.4 以后通过ACTION_GET_CONTENT或ACTION_OPEN_DOCUMENT选图返回的 Uri 都是content://开头不是file:///storage/...这种路径。很多老教程会教你先拿到文件路径再传给 native 层这在 10 以上的设备上完全行不通因为分区存储机制直接访问真实路径会抛FileNotFoundException或者权限被拒。我自己就在这上面折腾了很久。一开始图省事直接从 Uri 拼路径传给 JNI结果在 Android 12 的真机上直接报错输入图片读不出来OCR 返回空结果。后来改成在 Java 层把 Bitmap 解码好直接传给 native这个世界瞬间清净了。4.2 正确选图姿势选图用系统 API 就行不需要申请存储权限。现代方案是ActivityResultContracts.PickVisualMedia不需要在 Manifest 里声明任何存储权限ActivityResultLauncherString launcher registerForActivityResult( new ActivityResultContracts.GetContent(), uri - { if (uri ! null) { startOcr(uri); } }); launcher.launch(image/*);拿到 Uri 之后在 Java 层做解码ContentResolver resolver getContentResolver(); InputStream is resolver.openInputStream(uri); Bitmap bitmap BitmapFactory.decodeStream(is);这样拿到的 Bitmap 就可以直接传给 native 层。整个过程完全不碰文件路径完美避开content://权限和分区存储的各种破事。提示从相册选大图时BitmapFactory.decodeStream会按原始尺寸解码一张 4800 万像素的照片直接干几百 MB 内存App 必崩。一定要先用inSampleSize做采样压缩把长边控制在 2000 像素以内再传给 OCR。4.3 content:// Uri 的权限生命周期通过GetContent拿到的 Uri权限是临时的只存活到当前 Activity 所在的 task 结束。如果你只是选完图立刻识别那没问题。但如果你把 Uri 存下来下次启动再用或者传给其他组件就会发现权限失效了。要持久化读取权限需要手动调用getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);调这个方法之前Intent 里必须带上对应的 flag。这个逻辑在OpenDocument模式下支持GetContent模式则没有持久化权限这一说下次要重新选图。顺带说一句很多国内 ROM比如某些第三方文件管理器返回的 Uri 前缀五花八门什么content://com.tencent.wework.fileprovider、content://com.ss.android.uri.key这种都是不同 App 自定义的 FileProvider。别被前缀吓到你只要不直接拼路径直接通过ContentResolver去读 InputStream都能正常处理。4.4 Bitmap 预处理和 EXIF 方向手机拍出来的照片经常带 EXIF 旋转信息相册软件会读 EXIF 帮你把图显示正但BitmapFactory.decodeStream不会。这会导致识别时图片是旋转过的文本行检测框全部歪掉识别率断崖式下降。解决办法是在解析完 Bitmap 后读一下 EXIF 方向并做旋转ExifInterface exif new ExifInterface(inputStream); int orientation exif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); Matrix matrix new Matrix(); // 根据 orientation 值旋转 0/90/180/270 Bitmap rotated Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);另外在传给 native 之前最好也做一下长边缩放。PP-OCRv5 检测模型内部会把输入图像 resized但如果原图太大resize 过程会引入明显的比例失真加上压缩损失检测效果会下降。我习惯先把长边压到 1600-2000 像素识别速度和准确率都更稳定。5. 识别参数调优并不是装上就能识别好5.1 核心参数怎么调运行起来不代表效果就好参数调优才是真正决定 OCR 能不能用的环节。项目里 ncnn 的 OCR 检测部分有几个关键参数直接影响识别效果参数作用调优建议box_thresh检测框置信度阈值低于该值的检测框会被过滤默认 0.6文字密集的图可以降到 0.4unclip_ratio检测框向外扩展比例值越大检测框越大默认 2.0适合常规文字过大容易把背景纳入max_side_len图像最长边限制超过会缩放默认 960高分辨率图建议调大threads推理线程数4 比较均衡8 不一定更快use_vulkan是否启用 GPU 加速支持 Vulkan 的设备建议打开box_thresh是最容易影响识别结果的参数。默认值偏向保守遇到文字模糊或者背景复杂的情况检测框可能直接漏检。我之前处理一单证件照类图片字体偏小且有点反光默认阈值下能识别出来的文字寥寥无几把box_thresh调到 0.35 之后检测框数量明显增加最终识别内容也完整了很多。5.2 检测和识别的配合逻辑两阶段 OCR 模型是串联工作的检测模块负责找出所有“可能是文字”的位置识别模块负责把这些位置的文字读出来。这里有一个逻辑陷阱如果检测框生成质量不行识别模块再强也没用。检测框生成质量主要体现在两个维度框的完整性和框的纯净度。完整性是说一个文字行不要被拆成多个碎框纯净度是说框里不要夹杂太多背景干扰。unclip_ratio这个参数就是干这个事的。默认 2.0 是我在多种图片上试出来比较中庸的值如果图片里的文字是横幅、标题那种大字可以适当调大如果是表格里的密集小字调小会更好因为框扩太多容易把相邻文字行粘在一起。识别模块内部还有一些后处理逻辑比如根据置信度过滤输出、处理空白字符等这些一般不需要动。你唯一可能要调的是rec_thresh但这个建议保持默认识别置信度阈值调节效果远不如检测参数敏感。5.3 性能和内存优化OCR 在手机上跑最怕的是卡和内存暴涨。在集成后发现一个典型问题如果传一张 4000x3000 的原图直接进去识别过程中内存峰值能到 800MB 甚至 1GB低端机器直接回收。后来优化思路很明确图片在 Java 层先压缩到长边 1600 再传 native只保留arm64-v8a的 so初始化 ncnn 时开启use_vulkan让 GPU 分担部分计算识别完成后立即释放模型和 Mat避免长驻内存按这个方案优化后中端机识别一张普通图片的内存峰值控制在 200-300MB单次识别的耗时在 400ms 到 1.2s 之间日常使用完全能接受。注意开启 Vulkan 加速前先确认测试设备支持 Vulkan。绝大多数 2019 年之后的手机都没问题但部分低端机或模拟器不支持开启后直接崩。稳妥的做法是运行时检测ncnn.isSupportVulkan()支持才开。6. 我在实际部署中遇到的典型问题排查6.1 一直加载不出模型模型加载失败是最常见的。检查点依次是模型文件 .param 和 .bin 是否确实打包进了 APK 的 assets 目录assets 目录里的文件名是否和 Java 层传的名字完全一致大小写也算模型加载路径是否正确。用 Android Studio 的 APK Analyzer 打开构建产物直接检查 assets 目录能看到文件就没问题。还有一种隐蔽情况某些渠道打包插件会对 assets 做压缩或改名遇到这种情况要在打包配置里排除 assets 目录的处理规则。6.2 报错 “could not create a primitive”某些手机上运行时会看到这个名错误紧跟后面的内容一般是某些算子创建失败。这个坑本质是 ncnn 在某些 ARM 平台或旧 GPU 驱动下某个算子实现不可用OpenCV 部分图像处理也会出现类似问题。我当时查了很久最后发现是 Vulkan shader 编译的锅。部分国产 GPU 驱动的 shader 编译器和 ncnn 的某些算子不兼容。解决办法很简单在该设备上关闭 Vulkan 加速强制走 CPU 推理。牺牲点速度换来稳定性价比很高。6.3 返回结果 “no text detected” 或空字符串模型加载成功、代码没崩但结果为空。这种情况九成是图片预处理问题。逐项排查图片是否全白或全黑、模糊不可读图片是否旋转了 90/180/270 度传入的 Bitmap 是否已经释放图片分辨率是否过大检测框全被过滤box_thresh是否设置过高之前我拿一个从网上下载的 RGB 格式图片直接测试native 层按 RGBA 读结果通道错乱识别结果基本空。检查后修正了通道顺序问题就解决了。6.4 编译报错、NDK 版本冲突等构建问题用别人项目最怕编译不过。这个项目对 NDK 版本有一定容忍度但如果你用的 NDK 版本过新可能会在 CMake 阶段报类似 “Tag number over 30 is not supported” 的错。这个报错跟 protobuf 版本有关常见于 OpenCV 或 ncnn 内置的模型读取逻辑跟新版本 protobuf 冲突。建议使用 NDK r23 到 r25 之间的版本太新太老都容易有幺蛾子。在build.gradle里明确指定android { ndkVersion 25.2.9519653 }另一个常见问题是 C 标准库冲突。如果工程里还接了其他 native 库要确保所有库都使用相同的 STL 实现c_shared 或 c_static不然运行时会报 symbol not found。6.5 国产 ROM 的权限和路径问题在小米、华为等设备上测试时偶尔会出现选了图片但读不到数据的情况。这类问题基本可以归为 ROM 定制系统的文件访问差异尤其是相册 App 进程被杀、FileProvider 路径失效等。规避手段就是前面说的依赖ContentResolver.openInputStream读取不信任任何直接路径获取逻辑。另外国内 ROM 经常有“分离的存储权限”这种东西即使 Manifest 里声明了存储权限用户没在设置里手动打开也没用。最好的做法是根本不要申请存储权限走系统文件选择器就不会有这些权限问题。7. 几个可以继续扩展的方向如果你不想止步于现成 demo这几个方向我认为价值比较大。第一个是替换模型文件。PP-OCRv5 模型是通用场景的如果你要识别特定类型的内容比如发票、说明书、或者某种特殊字体可以考虑用 PaddleOCR 的微调训练训练好的模型按第一节的链路转成 ncnn 格式替换进来即可。我自己试过用少量业务数据微调后的识别模型准确率提升很明显。第二个是加文本方向分类。PP-OCRv5 的识别模型对 0 度和 180 度的识别做了优化但 90 度和 270 度的图还是需要先用分类模型调整方向。如果你需要识别的图片方向不固定可以额外接一个文本方向分类器在传给识别模型之前先做旋转归一化。第三个是识别结果的结构化输出。ncnn OCR 返回的是纯文本和每行文字行的坐标框。有了坐标框你可以自己实现版面分析比如把表格、段落、标题这些结构信息提取出来输出 JSON 格式这对做文档扫描类 App 很有用。第四个是接入大图的分块识别。如果图片分辨率特别大整图缩放送进去会丢掉小字细节。可以把图片按重叠分块的方式切成若干小图分别识别再拼接结果。这个方案我在做票据识别时验证过细节召回率提升很明显。回过来再聊一点实际体会。把 OCR 从云端搬到端侧最直观的价值是识别一张图不再有网络耗时也不会有隐私顾虑。而 ncnn PP-OCRv5 这个组合让我第一次觉得“端侧中文 OCR”达到了能落地的质量线。过程中模型转换、Uri 处理、参数调节这些细节说多了都是泪但只要按上面的链路走一遍基本能避开我踩过的所有坑。最后再分享一个小技巧如果你在开发阶段想快速验证模型效果不用每次都编译装 App可以在 Ubuntu 上直接用 ncnn 的 C 示例程序跑一张本地图片输出识别文本调试速度和方便程度都高很多。等确认模型没问题了再回头调 Android 工程能省下大量编译等待时间。
返回列表