Android OCR 从源码包到可运行:Tesseract、PaddleOCR 与图像预处理实战 📅 发布时间:2026/9/12 11:03:25 👁 浏览次数: 简介一份面向安卓开发者的光学字符识别OCR完整源码包涵盖从Tesseract引擎接入、图像预处理到识别结果处理的全流程。压缩包共73个文件约30.53MB除17个class、12个so动态库、5个jar依赖、4个java源文件外还包含2个traineddata语言包中文与英文、可直接安装体验的APK以及完整的Eclipse/ADT工程配置。随包附有“本源码使用帮助.txt”与README对编译环境、依赖库安装、项目结构及关键代码点均有说明能显著降低上手门槛。源码内集成TessBaseAPI接口展示灰度化、二值化、直方图均衡化等预处理手段并给出识别结果回调与后处理逻辑进阶部分还可参考tess-two库与libs目录下的原生依赖了解性能优化与二次封装思路。目前已有114人学习下载适合希望深入掌握安卓平台OCR落地并借鉴工程化实现的初中高级开发者。1. 这个“最全 OCR 源码包”真正在解决什么问题拿到一个标注“最全”的 Android OCR 图像识别源码包时不要急着去搜tesseract ocr 怎么运行也不要以为解压出来就能直接编译。这类包里通常装着三块内容识别引擎本身、围绕 Bitmap 采集和图像预处理的外围代码以及一路迭代留下的一堆模型文件、so 库和测试图片。真正的难点在边界不同引擎对图片大小、颜色模式、语言包路径和 ABI 的要求完全不同你在某台真机上能跑通的调用顺序挪到 Android Studio 模拟器上很可能第一步就崩在 so 库加载。这篇文章把从源码包到可运行、再从“识别出文字”到“结果能进业务”这条链路上的技术点讲清楚适合刚接手 OCR 任务的 Android 工程师也适合想替换掉“能用但不可控”的旧方案的人。2. 动源码之前先把引擎选明白ML Kit、Tesseract 与 PaddleOCR 在 Android 上的边界2.1 设备端识别 API 与离线可裁剪开源引擎的差别先看一眼 build.gradle 里的依赖基本就能判断这份源码包的路线。搜“ocr 常用开源”时你看到的方案可分三类Google ML Kit 设备端识别、Tesseracttess-two / tesseract4android、PaddleOCR。ML Kit 的优点是接入快识别接口是标准异步回调模型由 Google Play 服务统一下发安装包里不用塞语言包“最全”的源码包里它通常出现在从相册或相机图片里取文字的入口处。但 ML Kit 设备端 API 依赖 GMS 环境在部分定制 ROM 上会退回“模型不可用”或直接识别失败。还有一个容易踩的点识别中文必须显式传ChineseTextRecognizerOptions如果代码里只写默认的TextRecognition.getClient()中文识别结果会变成一堆乱码但工程也能编译通过——这类问题不跑到真机上看输出根本发现不了。2.2 Tesseract 的 JNI 架构与语言数据参数怎么设很多人搜“tesseract ocr 怎么运行”其实问的是拿到 tess-two 之后从哪里开始调。答案就一句话先初始化TessBaseAPI再设置图像。最小调用是这样val api TessBaseAPI() val datapath context.filesDir.toString() File.separator tessdata File.separator if (api.init(datapath, chi_simeng, TessBaseAPI.OEM_LSTM_ONLY)) { api.pageSegMode TessBaseAPI.PageSegMode.PSM_SINGLE_BLOCK api.setImage(bitmap) val result api.utF8Text } api.end()这里有两个参数特别容易错。datapath的末尾必须带tessdata/目录名加斜杠而不是只传filesDirchi_simeng表示同时加载简体中文和英文语言包两个.traineddata文件缺一个init仍然可能返回 true但识别结果会是空白。pageSegMode决定引擎如何推断版面整段文字用PSM_SINGLE_BLOCK单行文本用PSM_SINGLE_LINE小票、截图、身份证这类固定版式适合按行切分动态变化的自然场景建议先跑默认分块再看输出。Tesseract 底层是 C 实现Android 端通过 JNI 调用。同一套源码能否运行取决于src/main/jniLibs下是否有arm64-v8a、armeabi-v7a的 so 文件。你只用 Android SDK 自带的 x86_64 模拟器调试时往往一进init()就抛UnsatisfiedLinkError这不是代码写错而是模拟器镜像里没有对应架构的 so。2.3 PaddleOCR 精度更高但模型文件与动态库依赖不可小看如果源码包里出现inference.pdmodel、inference.pdiparams和ppocr_keys.txt说明用的是 PaddleOCR 路线。它不是单个识别器而是“文本检测 方向分类 文本识别”三段流水线先找图片里哪些区域有字再判断文字方向是否需要旋转最后对每个检测框做字符识别。所以它的移植代码里通常会有det、rec两个模型目录结构比 Tesseract 复杂一截。PaddleOCR 的精度优势在弯曲文本、倾斜文本和自然场景照片上更明显但代价是模型常驻内存更高推理时间也明显长于 Tesseract。源码包如果同时放了arm64-v8a的 so 和 assets 下的模型目录集成时要做的是把整个模型目录复制到私有目录再通过 JNI 初始化只改 Java 层不重新编译 so 是无法换模型的。2.4 引擎选型对照表在“最全”源码里如何定主流程引擎离线能力包体影响中文精度集成成本ML Kit 设备端依赖 GMS 模型下发较小高低Tesseract完全离线语言包 10MB 起中稳定版式尚可中需处理 JNI 与 ABIPaddleOCR完全离线模型加 so 比较大高高需适配检测识别全流程我的建议是源码包里所谓“最全”往往是把这三条路都铺了一遍但产品只要一条主链路。日常取图中如果以中文截图、文档为主Tesseract 够用且最容易排查如果要做拍照单据、自然场景文字识别直接定 PaddleOCR省下在 Tesseract 上调参还提不上去的功夫。3. 从 zip 到可运行三种集成路径与最小代码3.1 用 ML Kit 设备端识别跑通第一张 Bitmap先建一个最小项目在模块的 build.gradle 里加依赖版本号按你当前 SDK 能拉到的稳定版本填dependencies { implementation(com.google.mlkit:text-recognition-chinese) }注意这里选的是chinese的 artifact它内部已经包含中英文识别能力不需要同时再引入text-recognition默认包。识别代码很短val recognizer TextRecognition.getClient( ChineseTextRecognizerOptions.Builder().build() ) fun recognize(bitmap: Bitmap) { val input InputImage.fromBitmap(bitmap, 0) recognizer.process(input) .addOnSuccessListener { visionText - val rawText visionText.text // 这里拿到的就是整张图识别出的纯文本 } .addOnFailureListener { e - Log.e(OcrDemo, recognition failed: ${e.message}) } }逻辑说明InputImage.fromBitmap的第二个参数是旋转角度相机拍摄的图片通常会带 EXIF 信息如果先转成 Bitmap 再传入角度要手动计算从相册选图时常见做法是把 EXIF 旋转到位后再交给识别器否则文本会歪着进引擎。process是异步方法回调在后台线程执行回调里更新 UI 需要再切回主线程。这里有个很小的工程点如果传进来的 Bitmap 太大ML Kit 在部分版本上会返回IMAGE_TOO_LARGE之类的失败。我的习惯是先判断长边超过 2048 就等比缩放识别速度也能明显下降但小字号文字的准确率并不会因此变差多少。3.2 Tesseract 集成语言包路径和初始化的最小代码Tesseract 每次调用都要走“初始化 设图 取文本 释放”四步因此把引擎封装成单例是常见做法object TesseractEngine { private lateinit var api: TessBaseAPI fun init(context: Context) { val datapath context.filesDir.toString() File.separator tessdata File.separator api TessBaseAPI() val ok api.init(datapath, chi_simeng, TessBaseAPI.OEM_LSTM_ONLY) if (ok) { api.pageSegMode TessBaseAPI.PageSegMode.PSM_SINGLE_BLOCK } } fun recognize(bitmap: Bitmap): String? { if (!this::api.isInitialized) return null api.setImage(bitmap) return api.utF8Text } }setImage接受 Bitmap 后会同步执行识别因此不能在主线程里调用。语言包不是放在assets/tessdata/chi_sim.traineddata就行init只认绝对路径所以首次启动要把 assets 里的训练数据复制到filesDir/tessdata。复制完成后先调用一次init如果返回 false常见原因是datapath拼错或目录不存在返回 true 但输出空串则优先检查语言包文件大小是否为 0。3.3 PaddleOCR 移植模型放 assets 还是 filesDir 的运行约定PaddleOCR 在 Android 端一般通过 JNI 暴露两个 Java 方法一个init一个predict。我把常见的封装方式写出来帮助你对源码包里的 Java 层有个整体印象class PaddleOcrEngine(private val context: Context) { private val nativeEngine: Long by lazy { NativeOcr.init(modelDir ocr, modelName ppocrv4) } fun predict(bitmap: Bitmap): ListOcrLine { val bitmapCopy bitmap.copy(Bitmap.Config.ARGB_8888, false) val lines NativeOcr.predict(nativeEngine, bitmapCopy) // 每行包含 text 和四个角点坐标 return lines.map { OcrLine(it.text) } } fun release() { NativeOcr.release(nativeEngine) } }工程约定是把inference.pdmodel、inference.pdiparams和ppocr_keys.txt一起放进assets/ocr/JNI 初始化时复制到filesDir/ocr因为一些底层的模型加载器不支持直接从 assets 读。源码包里如果已经有 assets 目录先确认文件是否齐全缺ppocr_keys.txt时初始化通常不会报错但识别结果会返回空列表这个错误很隐蔽。3.4 集成期常见的 4 个编译或运行错误源码包跨机器、跨系统传到另一个工程时首先炸的大部分不是算法问题。整理一份我见过很多次的高频故障UnsatisfiedLinkErrorso 文件与当前设备 ABI 不匹配。检查jniLibs/下有没有arm64-v8a并确认 app 的abiFilters没把需要的架构过滤掉。init返回 false 但日志无异常datapath结尾少了tessdata/或者chi_sim.traineddata文件实际不存在。日志出现could not create a primitive... no text detected这多半不是引擎坏了而是图像内容过暗、过模糊或背景噪声太强识别器找不到可识别的字符块。先去查预处理而不是换语言包。decodeFile返回 null图片路径是content://或分区存储路径不能直接当文件路径解析。这个问题在第五章展开说它隐藏得非常深。4. 图像预处理与识别参数把识别率从 60% 提到 95% 的调参路径4.1 灰度、二值化和降噪的先后顺序以及 OpenCV 参数依据把原图直接丢给 OCR 引擎是最常见的识别率杀手。尤其是 Tesseract它本身对彩色图片的鲁棒性弱光线不均、阴影、彩色背景都会让字符连接或断裂。我一般会在识别前加一段预处理顺序固定为灰度化、高斯模糊、自适应二值化。val src org.opencv.android.Utils.bitmapToMat(bitmap) val gray Mat() val binary Mat() Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY) Imgproc.GaussianBlur(gray, gray, Size(3.0, 3.0), 0.0) Imgproc.adaptiveThreshold( gray, binary, 255.0, Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY_INV, 15, 10.0 ) Imgproc.medianBlur(binary, binary, 3) Utils.matToBitmap(binary, processedBitmap)参数说明GaussianBlur的核大小用3x3是给普通屏幕截图用的经验值如果图片本身比较糊先别依赖识别算法里的内置降噪直接改成5x5反而可能把笔画的细节点抹掉。adaptiveThreshold是自适应阈值块大小15表示按 15x15 像素邻域计算阈值适合大多数中文文本文字笔画又细又密时改成11笔画粗大时用21。THRESH_BINARY_INV表示文字为白色、背景为黑色Tesseract 对黑底白字和白底黑字都能识别但二值化方向选错会让笔画粘连所以做完预处理后先用一张测试图把matToBitmap的结果保存到相册看一眼。4.2 识别区域与角度矫正避免整图硬扛源码包里最常见的误用是拿到 Bitmap 就直接整图识别。业务上几乎没有“整张图都要识别”的场景更多是只识别身份证号、银行卡号、发票代码那一小条区域。把图像裁到 ROI 再送识别的收益比调任何算法参数都大。val cropRect Rect(x, y, x width, y height) val cropBitmap Bitmap.createBitmap(bitmap, cropRect.left, cropRect.top, cropRect.width(), cropRect.height())对于 Tesseract除了裁剪 Bitmap还可以直接用api.setRect指定识别区域这样比createBitmap更省内存。要注意setRect的坐标是相对setImage传入的 Bitmap 而言的顺序必须在setImage之后调用。角度矫正是另一个被低估的环节。拍单据时图像旋转 90 度或 180 度很常见。不要急于训练方向分类器先做一个启发式判断拿到文本检测框后如果明显是“高大于宽”的单行文本就把局部区域旋转 90 度再识别。PaddleOCR 自带方向分类模型所以这个环节在 PaddleOCR 里是透明的但 Tesseract 没有方向判断必须靠代码做。4.3 线程数、内存与语言包加载识别引擎单例化Tesseract 的TessBaseAPI不是线程安全的常见做法是全局只保留一个实例用锁保护识别调用而不是每个任务新建一次。新建实例意味着重新加载语言包在低端机上每次初始化耗时可能超过 300ms连续识别多张图时会明显看到卡顿。Synchronized fun recognizeSafe(bitmap: Bitmap): String? { return try { TesseractEngine.recognize(bitmap) } catch (e: OutOfMemoryError) { // 对超大图先压缩再识别捕获 OOM 避免整个进程被杀 val scaled scaleToWidth(bitmap, 1440) TesseractEngine.recognize(scaled) } }线程数方面Tesseract 的SetVariable(tessedit_thread_count, 2)可以控制内部线程但并不是线程越多越快。识别这种计算密集型任务在四核以上的设备上2 到 4 个线程已经很接近速度极限超过 4 个反而可能因为内存带宽竞争变慢。PaddleOCR 则通过numThreads参数控制推理线程数CPU 推理建议设为核心数减一给 UI 线程留余量。内存治理有一个关键点识别完成后立刻把 Bitmap recycle并把引擎持有的中间 Mat 释放。源码包如果给你的是 Java 层封装里面一般没有主动释放的习惯你接入时最容易发现的问题就是连续识别 20 张图后进程增长几百 MB。5. 文件路径、FileProvider 与运行环境源码包里最容易忽略的权限层5.1 content:// URI 不能直接解码先用 ContentResolver 转输入流从相册选图或相机拍照返回的 Uri在高版本 Android 上不再是file:///storage/emulated/0/...这种路径而是一长串content://授权地址。很多人直接把这个字符串转成文件路径传给BitmapFactory.decodeFile拿到 null 就开始怀疑代码写错其实问题出在“它根本不是本地文件路径”。content://的含义是你的应用通过 ContentProvider 拿到了一个临时读权限使用它必须走 ContentResolver 打开输入流。源码包里常见做法是先把文件复制到应用缓存目录再转成 Bitmapval input contentResolver.openInputStream(uri) ?: return val cacheFile File(cacheDir, ocr_${System.currentTimeMillis()}.jpg) input.use { ins - cacheFile.outputStream().use { out - ins.copyTo(out) } } val bitmap BitmapFactory.decodeFile(cacheFile.absolutePath)这里copyTo之后缓存文件就在应用私有目录里了后续任何引擎都能直接读。相机拍照时如果源码是通过MediaStore.EXTRA_OUTPUT保存图片则必须在你自己的 FileProvider 中声明路径并给相机 App 加上FLAG_GRANT_READ_URI_PERMISSION否则onActivityResult里拿到 Uri 后一读取就是空流。源码包里偶尔会看到形如content://xxxx.fileprovider/external_path/...的路径这说明那个 App 把外部目录也暴露给了 FileProvider。在自己的工程里我只建议暴露cacheDir或filesDir下一小块不要把整个外部存储暴露出去。5.2 语言包和模型放 assets 还是 filesDir分区存储下的目录归属Android 11 开始分区存储让应用直接访问外部存储的路径受限。源码包里的文档如果还在让你用Environment.getExternalStorageDirectory()拼接tessdata路径放到新设备上虽然编译能过一运行就崩。结论很简单运行时需要的文件统一放私有目录。assets 只适合承载“安装包里的原始资源”引擎需要绝对路径读取那就首次启动时复制到filesDir。对于 Ocr 这种场景语言包和模型都属于必须放私有目录的文件。另外要注意filesDir/tessdata和cacheDir的区别Files 目录用于长期保存模型Cache 目录用于中间图片Cache 目录可以被系统在存储不足时清理模型放这里会导致之前复制好的语言包凭空消失。一个不算很明显但很常见的坑调试用的图片不要存到/storage/emulated/0/Android/data/包名/这种目录去读取。因为它属于应用外部专属目录不同 Android 版本对它的可见性规则变化很大统一用getExternalFilesDir()或直接filesDir都比依赖绝对路径稳。5.3 用 adb 与 logcat 验证运行环境是否就绪源码包跑起来之前先用一组命令确认运行环境。我把验证步骤固定为以下三件事# 检查语言包是否已经复制到应用私有目录 adb shell run-as com.example.ocr ls -l files/tessdata/chi_sim.traineddata # 检查模型文件和 so 库是否存在 adb shell run-as com.example.ocr ls files/ocr/ # 过滤内核和 Java 层崩溃日志 adb logcat -s Tesseract:E AndroidRuntime:E OcrDemo:Drun-as只能用于 debuggable 的 debug 包release 包访问不了私有目录这时可以临时在代码里打印目录清单或者在 Android Studio 的 App Inspector 里直接看文件结构。还有一个环节是靠adb shell top -b -n 1 | grep 包名看识别时的 CPU 占用如果识别过程中 CPU 占用率接近 100% 且持续很久说明图像没有做缩放预处理或者线程数设得过高。6. 识别结果的后处理从“识别出字”到“能被业务用”的关键一跳6.1 规范化文本全角半角、易混字符与正则抽取OCR 输出通常是“满眼是文字但没法直接用”。空格、换行、全角符号混在一起直接拿去做字段匹配会漏掉大量目标。我习惯在识别结果和业务系统之间加一层固定清洗val normalized rawText .replace(Regex(\\s), ) .replace(, :) .replace(, () .replace(, )) .trim() val target Regex([A-Z]{2}\\d{6,10}).find(normalized)?.value逻辑说明把连续的换行和空格压缩成单个空格是为了保证正则里的连续字符不会被意外的换行打断尤其是处理单号、卡号这类字段时一行被识别成两行会让匹配彻底失败。全角冒号转半角是因为 OCR 算法对中文全角符号和英文半角符号的识别不稳定同一个模板有时候输出有时候输出:统一转成半角后再按分隔符拆分字段解析才不会随机漏数据。6.2 一份最小回归样本集让调优不被误判带偏改 OCR 参数最容易出现的结果是换一张图变好了换三张图又变差。源码包里如果带了说明一般会附上测试图片如果没有你就要自己建一个最小回归集。我的做法是在app/src/androidTest/assets/ocr_samples/下放 10 到 20 张带标注的图片每张图配一个同名 JSON 文件里面记录期望识别出的关键字段。写一个参数化测试每次运行都会把识别结果和期望值对比输出准确率。Test fun ocrRegression() { val sample File(ocr_samples, invoice_01.jpg) val expected loadExpected(ocr_samples/invoice_01.json) val result TesseractEngine.recognize(sample.toBitmap()) val hit expected.all { key - result?.contains(key) true } assertTrue(关键字段未识别出来: \n$result, hit) }这个回归集的价值在调整预处理参数时体现得最明显。你改了二值化的块大小、换了语言包、或者把 Tesseract 换成 PaddleOCR跑一遍./gradlew connectedCheck五分钟后就能看到整体识别率是涨是跌而不是靠肉眼对比两三张图片下结论。保存回归样本时注意一条经验失败样本才是最有价值的样本把识别错的图命名成fail_*放进去后续任何改动都要确保这些失败样本至少不更差再追求高分样本的进一步提升。本文还有配套的精品资源点击获取