乐于分享
好东西不私藏

安卓端也来用PP-OCRv6!试试OnnxOCR的Android版

安卓端也来用PP-OCRv6!试试OnnxOCR的Android版

1、写在前面

读过前面文章的小伙伴一定知道我筹划 Android 版本的 OnnxOCR 已经有一段时间了,而且那时候是 PP-OCRv5 刚刚发布。但项目写着写着,v6 就发布了。以我对 Android 版本的定位,它最好能够让用户简单使用,支持多个模型在PC上可以,但是移动终端上还是有些繁琐,所以我最后决定只支持 PP-OCRv6

另外,移动端更在意体积和速度,tiny 模型本来就是为此而生;再把 v5 / v6 两套默认参数、字典和后处理长期并行维护,性价比低了些。C# 桌面端 OnnxOCRSharp 已经把 v6 流水线跑顺了,Android 端直接对齐这一套就挺不错。

前面两篇前奏也不是白写的:

  • 《OnnxOCRAndroid的前奏1》:用官方 MobileNet V2 示例,把 OnnxRuntime Android 的依赖引入、Session、预处理、资源释放摸了一遍
  • 《OpenCV 5 来了》:同样的分类任务换成 OpenCV 5 DNN,同时把官方 Maven 包 org.opencv:opencv:5.0.0 在真机上跑通

推理用 OnnxRuntime、图像处理用 OpenCV——这和 OnnxOCRSharp 的分工一致。今天这篇,就是把两边拼起来,正式开源 OnnxOCRAndroid

Demo 已经支持从相册选图,也支持直接拍照识别。核心引擎拆成了独立的 onnxocr-core 库模块,以后别的 App 也可以本地依赖它。

2、我的成果物

交付物
说明
onnxocr-core
Kotlin OCR 引擎库(AAR),默认 PP-OCRv6 tiny
app
Demo:相册 / 拍照 → 识别 → 画框 → 一键复制
模型资源
det.onnx
 + rec.onnx + 字典,打进 Demo assets
流水线
检测 → 排序 → 透视裁剪 → 批识别 → 置信度过滤

还是采用跟C#端一样的方式,先做出一个能在真机稳定跑通的离线 OCR的MVP,后续继续加能力(方向分类、NNAPI、发布到 Maven 之类),不断完善,不断优化,敏捷开发。

3、核心技术栈

层次
技术
说明
语言
Kotlin 1.9.20
与 Demo / 库统一
推理
onnxruntime-android:1.18.0
官方 Android AAR,CPU + 多线程
图像
org.opencv:opencv:5.0.0
OpenCV 5 官方 Maven,预处理 / 轮廓 / 透视变换
UI
AppCompat + Material
选图、拍照、结果展示
构建
AGP 8.5.0,compileSdk 34
minSdk 24

关键依赖在 onnxocr-core 里:

implementation("com.microsoft.onnxruntime:onnxruntime-android:1.18.0")
api("org.opencv:opencv:5.0.0")

这里有个设计:

  • OnnxRuntime 用 implementation:推理细节封在库里,业务 App 一般碰不到 OrtSession
  • OpenCV 用 api:对外接口是 TextSystem.run(Mat),调用方需要自己持有 Mat、做 bitmapToMat,所以类型必须透传出去

和 C# 端 Microsoft.ML.OnnxRuntime + OpenCvSharp4 是同一思路,只是 Android 的打包方式换成了 Gradle。

4、项目结构

onnxocr-android/
├── app/                         # Demo 应用
│   └── src/main/
│       ├── assets/models/ppocrv6_tiny/
│       │   ├── det/det.onnx
│       │   ├── rec/rec.onnx
│       │   └── ppocrv6_tiny_dict.txt
│       └── java/.../MainActivity.kt
└── onnxocr-core/                # OCR 核心类库
    └── src/main/java/com/linc/onnxocr/
        ├── config/              # OcrOptions(v6 默认参数)
        ├── detection/           # 检测 + DB 后处理
        ├── recognition/         # 识别 + CTC 解码
        ├── imaging/             # 裁剪、排序、嵌套框过滤
        ├── inference/           # OnnxSessionFactory
        ├── model/               # OcrResult / TextLine
        └── pipeline/            # TextSystem 编排

分层尽量对齐 OnnxOCRSharp:算法在 Core,Demo 只负责拿图、展示、复制。模型文件放在 app 的 assets 里——库保持瘦,模型按业务需要自行打包或下载。

5、整体流水线

和桌面端 v6 一样,默认是两阶段(检测 + 识别):

Bitmap / 拍照图
    ↓
decode(最长边≤1600,EXIF 校正)→ Mat
    ↓
TextSystem.run()
    ├─ RGBA/GRAY → BGR
    ├─ TextDetector(det.onnx)→ DbPostProcess
    ├─ BoxSorter(从上到下、从左到右)
    ├─ ImageCropper(透视裁剪;竖排高/宽≥1.5 则转 90°)
    ├─ TextRecognizer(rec.onnx,按宽度比批推理)→ CTC
    └─ dropScore 过滤
    ↓
OcrResult(文本 + 置信度 + 四边形框 + 总耗时)

对外真正要记住的类就两个:OcrOptions 和 TextSystem

6、让我们跑通 Demo

6.1 环境

  • Android Studio Hedgehog(2023.1.1)或更新
  • Kotlin 1.9.20 / AGP 8.5.0
  • 真机

源码地址见文末。打开工程根目录即可。

6.2 同步、编译、安装

  1. Android Studio → Open → 选择 onnxocr-android/
  2. 等待 Gradle Sync(首次会拉 onnxruntime-android 和 opencv:5.0.0
  3. 连接手机,开启 USB 调试,Run app

避坑(国内网络):Gradle 包装器下载慢的话,可以把 gradle-wrapper.properties 的 distributionUrl 换成国内镜像,例如腾讯云:

distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.5-bin.zip

6.3 首次启动复制模型到手机

Demo 首次启动会在后台:

  1. OpenCVLoader.initLocal() 初始化 OpenCV 5
  2. 把 assets 里的三个模型文件拷到 filesDir
  3. OcrOptions.createDefault(context) 指向这些绝对路径
  4. 创建 TextSystem

有个小细节:app 模块给 .onnx 关了压缩——

androidResources {
    noCompress += "onnx"
}

这样 AssetManager.openFd 能拿到准确长度,拷贝时才能判断「本地缓存是否过期」。模型升级后,体积对不上就会自动重拷。

6.4 开始识别吧

  1. 点「选择图片」→ 从相册选择 或 拍照
  2. 预览出现后点「开始识别」
  3. 左侧叠加红色检测框,右侧看置信度和文本
  4. 「复制全部」把结果丢进剪贴板

拍照走系统相机 + FileProvider,照片落在缓存目录,再走和相册同一套解码:最长边控制到 1600、读 EXIF 旋转。

7、核心代码

7.1 默认配置:对齐 pp-ocrv6

OcrOptions :主体参数对齐 OnnxOCRSharp 的 ApplyPpOcrV6Defaults,但长边软限制在手机上改成了 960(桌面 C# 默认是 4000):

dataclassOcrOptions(
val detModelPath: String,
val recModelPath: String,
val dictPath: String,
// PP-OCRv6: short side >= 736 (limit_type=min).
// Soft max-side cap for mobile (desktop C# uses 4000).
val detLimitSideLen: Int = 736,
val detLimitType: String = "min",
val detMaxSideLimit: Int = 960,
val detDbThresh: Float = 0.2f,
val detDbBoxThresh: Float = 0.4f,
val detDbUnclipRatio: Float = 1.4f,
val detDbMaxCandidates: Int = 3000,
val dropScore: Float = 0.5f,
val recBatchNum: Int = 6,
val recImageShape: String = "3, 48, 320",
val numThreads: Int = 4
)
参数
Android 默认
说明
detLimitTypemin
短边拉到目标长度
detLimitSideLen
736
PP-OCRv6 短边规则
detMaxSideLimit960
移动端软封顶,控内存
detDbThresh
0.2
概率图阈值
detDbBoxThresh
0.4
框分数阈值
detDbUnclipRatio
1.4
框膨胀
recImageShape3, 48, 320
识别高度固定 48
numThreads
4
ORT intra-op 线程数

createDefault(context) 只负责拼 filesDir 路径,算法参数全吃默认值——够大多数 Demo / 嵌入场景了。

7.2 TextSystem:编排器

funrun(image: Mat): OcrResult {
val bgr = toBgr(image)   // Utils.bitmapToMat 出来是 RGBA
try {
val boxes = detector.detect(bgr)
if (boxes.isEmpty()) return OcrResult(/* empty */)

val sortedBoxes = BoxSorter.sort(boxes)
val crops = sortedBoxes.map { ImageCropper.crop(bgr, it, options.detBoxType) }
try {
val recResults = recognizer.recognize(crops)
// score >= dropScore 才进结果
return OcrResult(lines = lines, elapsed = ...)
        } finally {
            crops.forEach { it.release() }
        }
    } finally {
if (bgr !== image) bgr.release()
    }
}

几点和桌面端一致、也和前奏里强调过的资源习惯一致:

  • 通道先转成 BGR,再喂检测——C# 端读图也是 BGR,不能随便改成 RGB,否则和训练预处理对不上
  • 裁剪出来的 Mat 必须 release(),OpenCV 不走 Java GC
  • TextSystem 实现 AutoCloseable,页面销毁时记得 close()

7.3 调用方只要这几行

val options = OcrOptions.createDefault(context)
val textSystem = TextSystem(context, options)

val mat = Mat()
Utils.bitmapToMat(bitmap, mat)
val result = textSystem.run(mat)

for (line in result.lines) {
    println("${line.text}${line.score}")
}

mat.release()
textSystem.close()

自定义模型时,把三个绝对路径传进 OcrOptions 即可,不必走 assets 拷贝。

7.4 Demo 侧的后台初始化

privatefuninitOpenCvAndOcr() {
    Thread {
val loaded = try {
            OpenCVLoader.initLocal()
        } catch (_: NoSuchMethodError) {
            OpenCVLoader.initDebug()
        }
// ...
        copyModelsIfNeeded()
val options = OcrOptions.createDefault(this)
        textSystem = TextSystem(this, options)
        runOnUiThread { tvStats.text = "OCR 引擎已就绪 (PP-OCRv6 tiny)" }
    }.start()
}

OpenCV 5 推荐 initLocal(),不再依赖当年的 OpenCV Manager。识别本身也扔到后台线程,避免卡 UI——手机端这点比桌面更敏感。

8、踩过的坑(尤其是 OpenCV 5)

8.1 boxPoints 写法不对,会「检测全空」

这是 Android 移植里最坑、也最值得单独写一节的地方。

一开始按桌面端的直觉,用 Geometry.boxPoints 再 MatOfPoint2f.toArray() 取四个角点。在 OpenCV 5 上会出现一种很阴间的现象:推理有输出、轮廓也找到了,但最终框数永远是 0

原因在 DbPostProcess

boxPoints 写出的是 4×2 的 CV_32FC1 Mat;再当成 MatOfPoint2f 去 toArray()total() 会变成 8,凭空多出四个 (0,0) 点,框分数被打穿,等于没检测到。

正确姿势是直接用 RotatedRect.points()

privatefunrotatedRectPoints(rect: RotatedRect): Array<Point> {
val pts = arrayOf(Point(), Point(), Point(), Point())
    rect.points(pts)
return pts
}

如果你也在 Android / OpenCV 5 上搬 DB 后处理,建议先拿一张简单中文图验证「至少能出框」,再做参数调优。

8.2 轮廓只用 EXTERNAL

findContours 用了 RETR_EXTERNAL,避免孔洞轮廓在手机端碎成多余小框。桌面端可以更宽松,移动端我更偏向少而稳。

8.3 Unclip 做了简化

C# 端优先走 Clipper 多边形膨胀,Android 这边用了更轻的几何扩展。效果在常见文档/菜单/截图场景够用;极端弯曲文本以后再加强也不迟。

9、来看看效果

不同机型差异会很大,我本地真机上的使用请看视频:

评论区给出你的意见,用陆光的话说,就是“追番,拜托啦!”。

10、开源地址

# 源码(Gitee)
https://gitee.com/lincyang/onnxocr-android

# 上游 C# 版
https://github.com/lincyang/OnnxOCRSharp

# 网盘下载APK
公众号聊天输入:onnxocr-android

工程打开即可编译;模型已随 Demo assets 提供。如果想直接下载APK,就对公众号发关键字"onnxocr-android"获取链接,直接安装即可。

欢迎关注公众号「程序员Linc」获取更多技术文章和项目更新!

参考

  1. onnxocr-android 源码:https://gitee.com/lincyang/onnxocr-android
  2. OnnxOCRSharp:https://github.com/lincyang/OnnxOCRSharp
  3. OnnxOCR:https://github.com/jingsongliujing/OnnxOCR
  4. PP-OCRv6 魔塔集合:https://www.modelscope.cn/collections/PaddlePaddle/PP-OCRv6
  5. ONNX Runtime Android:https://onnxruntime.ai/docs/tutorials/mobile/deploy-android.html
  6. OpenCV 5 Android Maven:org.opencv:opencv:5.0.0