• 少年,不知道怎么在安卓中使用 PaddleOCR ?看我怎么把它二次封装成只需要两行代码即可使用


    前言

    什么是 PaddleOCR

    根据官方的介绍:

    Awesome multilingual OCR toolkits based on PaddlePaddle (practical ultra lightweight OCR system, support 80+ languages recognition, provide data annotation and synthesis tools, support training and deployment among server, mobile, embedded and IoT devices)

    PaddleOCR 是一个基于百度飞桨(Paddle)平台部署的 OCR 工具库,支持多设备、多平台、多语言、多场景的 OCR 识别。

    这里是一些官方的识别效果示例图:

    来源:PaddleOCR

    为什么要二次封装

    虽然 PaddleOCR 十分强大,但是部署使用较为繁琐,对于新手或者说不关心 PaddleOCR 实现,也不需要太多自定义参数的使用者来说十分不方便。

    对于部分使用者来说,他们想要的只是一个能够快速接入安卓项目使用的离线 OCR 识别库而已。

    因此,本文的目的就是想要将 PaddleOCR 二次封装为能够快速接入使用的安卓库。

    该库将基于官方的 android_demo 进行封装。

    怎么使用

    封装完成后使用极其简单,在导入依赖后,只需要两行代码即可使用:

    1. ocr.initModelSync(OcrConfig()) // 初始化 OCR
    2. val result = ocr.runSync(yourBitmap) // 开始识别
    3. 复制代码

    更多配置和详细使用流程请前往项目地址查看。

    项目地址

    paddleocr4android

    欢迎 star

    效果预览:

    封装思路

    分析官方 android_demo

    在开始封装之前,我们需要大致了解 android_demo 的运行逻辑。

    在 demo 中的 build.gradle 中有这么一段代码:

    1. def archives = [
    2. [
    3. 'src' : 'https://paddleocr.bj.bcebos.com/libs/paddle_lite_libs_v2_10.tar.gz',
    4. 'dest': 'PaddleLite'
    5. ],
    6. [
    7. 'src' : 'https://paddlelite-demo.bj.bcebos.com/libs/android/opencv-4.2.0-android-sdk.tar.gz',
    8. 'dest': 'OpenCV'
    9. ],
    10. [
    11. 'src' : 'https://paddleocr.bj.bcebos.com/PP-OCRv2/lite/ch_PP-OCRv2.tar.gz',
    12. 'dest' : 'src/main/assets/models'
    13. ],
    14. [
    15. 'src' : 'https://paddleocr.bj.bcebos.com/dygraph_v2.0/lite/ch_dict.tar.gz',
    16. 'dest' : 'src/main/assets/labels'
    17. ]
    18. ]
    19. task downloadAndExtractArchives(type: DefaultTask) {
    20. doFirst {
    21. println "Downloading and extracting archives including libs and models"
    22. }
    23. doLast {
    24. // Prepare cache folder for archives
    25. String cachePath = "cache"
    26. if (!file("${cachePath}").exists()) {
    27. mkdir "${cachePath}"
    28. }
    29. archives.eachWithIndex { archive, index ->
    30. MessageDigest messageDigest = MessageDigest.getInstance('MD5')
    31. messageDigest.update(archive.src.bytes)
    32. String cacheName = new BigInteger(1, messageDigest.digest()).toString(32)
    33. // Download the target archive if not exists
    34. boolean copyFiles = !file("${archive.dest}").exists()
    35. if (!file("${cachePath}/${cacheName}.tar.gz").exists()) {
    36. ant.get(src: archive.src, dest: file("${cachePath}/${cacheName}.tar.gz"))
    37. copyFiles = true; // force to copy files from the latest archive files
    38. }
    39. // Extract the target archive if its dest path does not exists
    40. if (copyFiles) {
    41. copy {
    42. from tarTree("${cachePath}/${cacheName}.tar.gz")
    43. into "${archive.dest}"
    44. }
    45. }
    46. }
    47. }
    48. }
    49. preBuild.dependsOn downloadAndExtractArchives
    50. 复制代码

    这段代码的作用很好理解,就是在每次编译前先检查文件,如果不存在则从指定地址下载 paddle_lite_libs 、 opencv 、 模型(ch_PP-OCRv2) 、 字典(ch_dict) 并解压后复制到指定文件夹。

    官方这么做的原因无非是为了减少仓库的体积,但是我们封装时不太需要考虑仓库体积问题,所以我们直接将下载好的所需依赖文件复制到项目中,并去除这段下载代码。

    继续查看 demo 中的主入口 - MainActivity 文件,这个文件写的比较复杂繁琐,不过没关系,我们挑重点的看。

    首先是这段代码:

    1. @Override
    2. protected void onResume() {
    3. super.onResume();
    4. // ......
    5. if (model_settingsChanged) {
    6. // ......
    7. // Reload model if configure has been changed
    8. loadModel();
    9. }
    10. }
    11. 复制代码

    这段代码会在 onResume 时检查参数设置是否改变,如果改变的话则加载模型:

    loadModel() 方法发送了一个 Handler message , 接收到 message 后做的处理为:

    1. if (predictor.isLoaded()) {
    2. predictor.releaseModel();
    3. }
    4. return predictor.init(MainActivity.this, modelPath, labelPath, cbOpencl.isChecked() ? 1 : 0, cpuThreadNum,
    5. cpuPowerMode,
    6. detLongSize, scoreThreshold);
    7. 复制代码

    代码很简单,检查是否已经加载了模型,如果已加载就重新加载一次,否则就初始化。

    再来看看识别时都做了什么,点击 开始识别 后最终会调用到这段代码:

    1. String run_mode = spRunMode.getSelectedItem().toString();
    2. int run_det = run_mode.contains("检测") ? 1 : 0;
    3. int run_cls = run_mode.contains("分类") ? 1 : 0;
    4. int run_rec = run_mode.contains("识别") ? 1 : 0;
    5. return predictor.isLoaded() && predictor.runModel(run_det, run_cls, run_rec);
    6. 复制代码

    代码也很简单,从下拉框中获取识别的类型,然后将识别类型传入 predictor.runModel 并返回识别结果(是否识别成功)。

    从上面的代码中可以看出来,这个代码的核心在于 Predictor 类:Predictor predictor = new Predictor();

    我们来看一下 Predictor 类都做了些什么,其他的都不看了,我们就看一下 加载模型(init) 和 开始识别(runModel)。

    首先是 init 方法:

    可以看到 init 有两个构造方法,第二个构造方法比第一个多了两个参数: detLongSize - 检测长度; scoreThreshold - 置信度阈值。

    在 init 中所做的事无非是按照给定的参数加载模型(Model)和字典(Label)。

    这里说一下,paddleOCR 返回的识别结果不是字符,而是一串字符索引,我们需要根据字典将索引转换为具体的字符。

    下面为 loadModel 方法的具体实现:

    loadModel 方法中,会先检查传入的模型路径是否为 / 开头,如果不是斜杆开头则认为是传入的 assets 路径(即模型文件打包进了安装包),此时需要将模型复制到内部储存中(copyDirectoryFromAssets),然后将模型路径以及其他参数写入配置信息(config)。

    加载字典代码如下:

    在加载字典时,会读取给定的字典文件,并按行分割后将其写入一个列表中(wordLabels)以备后用。

    wordLabels 列表的第一个值被恒定写入为 "black" ,这是因为识别失败时会返回索引 0 。

    需要注意的是,这里没有对字典文件路径做判断,而是直接将字典文件看作保存在 assets 文件夹中。

    最后来看一下开始识别的代码:

    在这段代码中,首先判断如果输入图像为空或尚未加载模型则直接返回 false 。

    然后对输入图像进行 “预热”。

    “预热” 完成后调用 paddlePredictor.runImage 开始识别。

    最后,使用 postprocess 对识别结果进行后处理,这里的后处理就是上文提到的,将文字索引按照字典转换为字符,所以这里就不再看源码了。

    最后,看看最核心的 runImage 方法:

    1. // ....
    2. public ArrayList<OcrResultModel> runImage(Bitmap originalImage, int max_size_len, int run_det, int run_cls, int run_rec) {
    3. Log.i("OCRPredictorNative", "begin to run image ");
    4. float[] rawResults = forward(nativePointer, originalImage, max_size_len, run_det, run_cls, run_rec);
    5. ArrayList<OcrResultModel> results = postprocess(rawResults);
    6. return results;
    7. }
    8. // ....
    9. protected native float[] forward(long pointer, Bitmap originalImage,int max_size_len, int run_det, int run_cls, int run_rec);
    10. // ......
    11. 复制代码

    可以看到,最终 runImage 方法是调用了 native 方法 forward

    其实不只是识别模型,包括上面说的加载模型等,最终都是调用的 native 方法。

    也就是说,我们需要连 C++ 代码一起修改。

    开始封装

    上面我们已经把 demo 的运行逻辑捋了一遍,下面就是开始封装。

    复制并修改文件

    首先,我们需要创建一个 Android Library,创建过程在这里不过多赘述,只是需要说一点,我们的包名设置为 com.equationl.paddleocr4android

    为什么要特意强调包名呢?因为在上面我们说过,paddleOCR 最终调用的是 native 方法,而通过 jni 调用 native 方法时,需要确保方法签名与调用者的包名一致。

    我们将 demo 中的所有帮助类复制进我们的 Library ,并修改包名,解决报红的地方:

    然后将所有 C/C++ 代码以及上面通过 build.gradle 下载的第三方依赖代码复制到指定位置:

    最后,记得修改所有调用的 C++ 代码的方法签名,例如,将原本的 init 函数

    Java_com_baidu_paddle_lite_demo_ocr_OCRPredictorNative_init

    修改为

    Java_com_equationl_paddleocr4android_Util_paddle_OCRPredictorNative_init

    二次封装调用代码

    首先,我们写一个数据类 OcrConfig 用于存放配置信息,避免按照原 demo 的写法每个方法都需要传一大堆参数:

    1. data class OcrConfig(
    2. /**
    3. * 模型路径(默认为 assets 目录下的预装模型)
    4. *
    5. * 如果该值以 "/" 开头则认为是自定义路径,程序会直接从该路径加载模型;
    6. * 否则认为该路径传入的是 assets 下的文件,则将其复制到 cache 目录下后加载
    7. *
    8. * */
    9. var modelPath:String = "models/ocr_v2_for_cpu",
    10. /**
    11. * label 词组列表路径(程序返回的识别结果是该词组列表的索引)
    12. * */
    13. var labelPath: String? = "labels/ppocr_keys_v1.txt",
    14. /**
    15. * 使用的CPU线程数
    16. * */
    17. var cpuThreadNum: Int = 4,
    18. /**
    19. * cpu power model
    20. * */
    21. var cpuPowerMode: CpuPowerMode = CpuPowerMode.LITE_POWER_HIGH,
    22. /**
    23. * Score Threshold
    24. * */
    25. var scoreThreshold: Float = 0.1f,
    26. var detLongSize: Int = 960,
    27. /**
    28. * 检测模型文件名
    29. * */
    30. var detModelFilename: String = "ch_ppocr_mobile_v2.0_det_opt.nb",
    31. /**
    32. * 识别模型文件名
    33. * */
    34. var recModelFilename: String = "ch_ppocr_mobile_v2.0_rec_opt.nb",
    35. /**
    36. * 分类模型文件名
    37. * */
    38. var clsModelFilename: String = "ch_ppocr_mobile_v2.0_cls_opt.nb",
    39. /**
    40. * 是否运行检测模型
    41. * */
    42. var isRunDet: Boolean = true,
    43. /**
    44. * 是否运行分类模型
    45. * */
    46. var isRunCls: Boolean = true,
    47. /**
    48. * 是否运行识别模型
    49. * */
    50. var isRunRec: Boolean = true,
    51. var isUseOpencl: Boolean = false,
    52. /**
    53. * 是否绘制文字位置
    54. *
    55. * 如果为 true, [OcrResult.imgWithBox] 返回的是在输入 Bitmap 上绘制出文本位置框的 Bitmap
    56. *
    57. * 否则,[OcrResult.imgWithBox] 将会直接返回输入 Bitmap
    58. * */
    59. var isDrwwTextPositionBox: Boolean = false
    60. )
    61. enum class CpuPowerMode {
    62. /**
    63. * HIGH(only big cores)
    64. * */
    65. LITE_POWER_HIGH,
    66. /**
    67. * LOW(only LITTLE cores)
    68. * */
    69. LITE_POWER_LOW,
    70. /**
    71. * FULL(all cores)
    72. * */
    73. LITE_POWER_FULL,
    74. /**
    75. * NO_BIND(depends on system)
    76. * */
    77. LITE_POWER_NO_BIND,
    78. /**
    79. * RAND_HIGH
    80. * */
    81. LITE_POWER_RAND_HIGH,
    82. /**
    83. * RAND_LOW
    84. * */
    85. LITE_POWER_RAND_LOW
    86. }
    87. 复制代码

    然后重新编写一个结果类,用于存放识别结果,原 demo 的识别结果有点混乱,不方便使用:

    1. data class OcrResult(
    2. /**
    3. * 简单识别结果
    4. * */
    5. val simpleText: String,
    6. /**
    7. * 识别耗时
    8. * */
    9. val inferenceTime: Float,
    10. /**
    11. * 框选出文字位置的图像
    12. * */
    13. val imgWithBox: Bitmap,
    14. /**
    15. * 原始识别结果
    16. * */
    17. val outputRawResult: ArrayList<OcrResultModel>,
    18. )
    19. 复制代码

    最后,编写入口类 OCR , 它的方法结构如下:

    核心方法就四个:

    1. initModelSync - 同步初始化识别引擎
    2. initModel - 异步初始化引擎
    3. runSync - 同步识别
    4. run - 异步识别

    其中,两个异步方法其实就是在同步方法上套了一个协程,并用回调函数返回结果,例如异步识别:

    1. /**
    2. * 开始运行识别模型(异步)
    3. *
    4. * @param bitmap 欲识别的图片
    5. * @param callback 识别结果回调
    6. * */
    7. @MainThread
    8. fun run(bitmap: Bitmap, callback: OcrRunCallback) {
    9. val coroutineScope = CoroutineScope(Dispatchers.IO)
    10. coroutineScope.launch(Dispatchers.IO) {
    11. runSync(bitmap).fold(
    12. {
    13. withCopntext(Dispatchers.Main) {
    14. callback.onSuccess(it)
    15. }
    16. },
    17. {
    18. withCopntext(Dispatchers.Main) {
    19. callback.onFail(it)
    20. }
    21. })
    22. }
    23. }
    24. 复制代码

    而同步方法的实现如下:

    1. /**
    2. * 开始运行识别模型(同步)
    3. *
    4. * @param bitmap 欲识别的图片
    5. * */
    6. @WorkerThread
    7. fun runSync(bitmap: Bitmap): Result {
    8. if (!predictor.isLoaded()) {
    9. return Result.failure(RunModelException("请先加载模型!"))
    10. }
    11. else {
    12. predictor.setInputImage(bitmap) // 载入图片
    13. runModel().fold({
    14. return if (it) {
    15. val ocrResult = OcrResult(
    16. predictor.outputResult(),
    17. predictor.inferenceTime(),
    18. predictor.outputImage(),
    19. predictor.outputRawResult()
    20. )
    21. Result.success(ocrResult)
    22. } else {
    23. Result.failure(RunModelException("请检查模型是否已成功加载!"))
    24. }
    25. }, {
    26. return Result.failure(it)
    27. })
    28. }
    29. }
    30. 复制代码

    最终调用的还是原 demo 中的 setInputImage()runModel() 方法,然后原 demo 最终会调用到 native 方法。

    只不过这里,我对识别结果进行了二次处理,将其处理完成后存入上面定义的 OcrResult 中。

    并且,这里的同步方法返回的是一个 Result 类型,包裹的内容是 OcrResult 类型。

    为了适配这个返回类型,我把原 demo 中的所有方法返回值均去掉了,并且在出错的地方直接改成抛出异常,并在这里 catch 住异常,并返回 Result.failure() :

    1. private fun runModel(): Result<Boolean> {
    2. return try {
    3. Result.success(predictor.isLoaded() && predictor.runModel(isRunDet, isRunCls, isRunRec))
    4. } catch (e: Throwable) {
    5. Result.failure(e)
    6. }
    7. }
    8. 复制代码

    最后,就是修改原 demo 的几个帮助类,使其能够适配我二次封装的需求即可。

    至此,所有封装全部完成!

    总结

    其实 PaddleOCR 部署并不算复杂,只是由于它的多平台特性,导致新手使用时会看的一脸懵逼,不知道到底该怎么去使用。

    最后,这个库我已经在我自己的项目 隐云图解制作 中使用了半年多了,目前没有发现有什么大问题,所以各位可以大胆的去尝试使用。

    当然,这个库只是为了方便快速接入使用 OCR 的开发者,如果你想要更多的自定义或扩展功能,还是得你自己去研究部署 PaddleOCR,如果你恰好有时间,并且认为这些功能其他人也能用的到的话,欢迎 PR 到这个库中。

  • 相关阅读:
    js之循环
    HCIA笔记-1 网络基础
    【漏洞复现-uwsgi-目录穿越】vulfocus/uwsgi-cve_2018_7490
    LeetCode --- 1957. Delete Characters to Make Fancy String 解题报告
    BCSP-玄子Share-Java框基础_工厂模式/代理模式
    Asp.Net Core: Swagger 与 Identity Server 4
    Flask 实现增改及分页查询的完整 Demo
    使用iPerf3测试局域网网络带宽
    git笔记
    【观察】天翼云政务大模型“慧泽”:推动政务服务再升级,加速智慧城市再进化...
  • 原文地址:https://blog.csdn.net/sinat_17133389/article/details/126931968