简介:面向安卓开发者的文字识别应用项目包,涵盖从拍照、图像显示到提取文字的完整流程,适合需要快速集成离线识别功能的中初级开发者。压缩包内共808个文件,主要类型包括Java源码、XML布局与配置、构建脚本、机器学习模型文件(tflite、binarypb)、本地so库、JSON配置以及编译产物等,多种文件共同构成了一个可直接构建运行的安卓工程,整体大小68.12MB。已有161人学习下载,说明该项目对学习移动端文字识别有一定参考价值。项目可直接导入安卓开发环境运行,主程序MainActivity.java演示了相机调用、动态权限申请、位图处理及机器学习套件文本识别的完整关键步骤,离线模式下也能正常识别中文文本。资源中还包含模型文件、依赖配置和资源文件,便于理解文字识别应用的整体项目结构,也可作为二次开发的基础模板,用于毕业设计或实际产品中的拍照识别功能。
1. 安卓拍照 OCR 应用为什么值得用机器学习做:一次联调翻车说起
上个月帮朋友调一个 Android 端扫描名片的应用,拍照、抠图、切成二值图,再用传统模板匹配去读姓名和电话。结果十张名片里能正确读出来的不到四张,得名片的反光、倾斜、字体和背景一换,规则就全崩了。最后把识别层换成了机器学习 OCR——准确率肉眼可见地从“碰运气”变成“稳定可用”。这个项目标题说的正是这件事:安卓手机 APP 拍照并使用机器学习进行 OCR 文字识别。它解决的不是“能不能识别”,而是“在手机这种算力和拍照环境下,怎么把拍摄图像稳定地读成可编辑文本”。适合手里有一份 APK 或 Android Studio 工程要落地、正为识别率和工程集成发愁的开发者往下看。后面所有代码和参数都按一条可复现的主线展开:CameraX 拍照 → 图像预处理 → ML Kit 文本识别 → 结果解析。
2. 三种 OCR 引擎选型:ML Kit、Tesseract、PaddleOCR 在安卓端的边界与局限
拿到“拍照 + OCR”这类需求,第一反应通常是找现成识别库。但安卓端能选的引擎就那么几条路:Google 的 ML Kit 设备端文本识别、封装了 Tesseract 的 tess-two 或 Tesseract4Android、以及百度 PaddleOCR 的移动端方案。三者在模型体积、离线能力、中文识别效果和接入成本上差别非常大,选错了后面每一步都在给错误买单。
2.1 先区分“拍照—识别”链路与一次性批处理
很多人把 OCR 工程想成“给一张图,吐一行字”,实际落地时有两个完全不同的链路。
批量离线场景,比如扫描一份 PDF 后逐页识别,重点在准确率和多页稳定性,可以用 Tesseract 在服务端跑,也可以调在线 API。而“安卓手机 APP 拍照并使用机器学习进行 OCR 文字识别”这个标题,强调的是一次性拍照、实时回传结果的交互链路。这种场景对延迟敏感,用户举着手机等结果超过两三秒就会不耐烦;同时设备环境不可控,拍出来的图可能模糊、过暗、倾斜、反光。所以选型时要把“实时性”和“拍摄质量容错”排到最前面。
链路不同,引擎选择就完全不同。服务端 OCR 可以接受几百毫秒甚至秒级延迟和较大的模型;端侧 OCR 必须把模型压在几十 MB 以内,并且最好离线可用,不让用户等网络。给业务方讲清楚“你要的是哪种识别”,比直接推荐某个库更重要——这两类需求的验收标准都不一样。
2.2 为什么第一版建议直接上 ML Kit 的设备端文本识别
如果只是做一个能跑通的 MVP,我一般建议先不用考虑 Tesseract 和 PaddleOCR,直接上 ML Kit 的设备端文本识别(Text Recognition v2)。理由是它在安卓端的集成成本最低,识别稳定性和语言支持都是开箱即用的水平,尤其是对中英文混合文本的识别效果,明显好过裸装 Tesseract。
ML Kit 的设备端文本识别有两种模型:
- 标准模型(Standard):体积小,速度快,适合实时识别;
- 稀疏模型(Sparse):针对稀疏文本场景优化,比如识别名片、发票、路牌等文字不密集的图像,速度和准确率都有增益。
拍照 OCR 场景下,绝大多数是画面中文字区域占比不大的情况,比如一张名片、一页纸张、一块屏幕截图,所以稀疏模型往往是更好的选择。工程上只需要在TextRecognizerOptions.Builder里传模型类型,不用改其他代码。
需要说明的是,ML Kit 的设备端文本识别依赖会在首次初始化时加载识别模型,但模型不大,对主流机型内存和存储的占用都可接受,远小于 Tesseract 完整语言包动不动上百 MB 的体积。延迟方面,在 2023 年之后的中端机型上识别一帧 1080p 图像,耗时通常在 200~600 毫秒区间,这个量级适合拍照后的准实时反馈。
2.3 Tesseract 是备胎:自定义训练与离线字典的取舍
Tesseract 是老牌开源 OCR 引擎,安卓上常见封装是 Tesseract4Android(旧称 tess-two)。它能出现在这类项目里,主要原因是两个:完全离线、可自定义训练。
ML Kit 的设备端识别虽然也离线,但模型是 Google 训练好的,你改不了它的行为;Tesseract 则允许你针对特定字体、特定版式做微调训练,生成自定义的.traineddata语言包。对固定模板票据、统一字体印刷体这类高度受限场景,Tesseract 反而更合适。
但代价同样明显。一是精度,未做训练的 Tesseract 在中文场景下的识别效果远不如现代深度学习模型,字与字粘连、墨迹浓淡变化都会让结果惨不忍睹。二是预处理要求高,它需要比较干净的灰度图和足够大的文字区域,否则识别前图像增强的代码量会超过识别本身。三是接入成本,要在 Gradle 里引入 NDK 依赖,首次构建还可能因为下载预编译包慢而卡住。
所以我的判断是:如果你的需求是“通用文字识别,什么环境都可能拍”,Tesseract 不适合第一版;如果需求是“识别某一种固定样式单据,而且必须离线、可控”,Tesseract 才值得投入。
2.4 PaddleOCR 与开源模型的端侧适配成本
PaddleOCR 在服务端是很强的一线方案,中文识别准确率比上述两者都高,社区里的训练和部署资料也多。但它原本就不是为安卓端设计的。官方虽然提供了 PaddleLite 移动端部署示例,端侧模型也可以量化压缩到几十 MB,但要真正塞进一个安卓 APP,你需要处理模型转换(Paddle 模型转 PaddleLite 格式)、前后处理代码移植、动态 shape 设置、多线程调度等一系列工程问题。整个过程快则一两天,慢则一周,团队如果没有懂模型部署的人,风险不小。
它适合的安卓场景是:你对识别准确率有硬指标,且愿意为模型部署投入专门人力。对大多数做业务 APP 的团队来说,ML Kit 的准确率已经能满足需求,没必要在第一个版本就把技术难度拉高。后面如果业务数据确实暴露出 ML Kit 的短板,再研究 PaddleOCR 迁移也不迟——但请让这个决策发生在“有真实样本证明”之后,而不是前期“拍脑袋选最强模型”。
| 对比维度 | ML Kit 文本识别 | Tesseract | PaddleOCR 端侧 |
|---|---|---|---|
| 中文识别准确率 | 良好 | 一般,未训练时偏差大 | 优秀 |
| 离线可用 | 支持 | 支持 | 支持 |
| 模型体积 | 小 | 语言包普遍偏大 | 可量化后接受 |
| 集成难度 | 低,Android Studio 直接加依赖 | 中,需处理 NDK 依赖 | 高,需模型转换和部署 |
| 自定义训练 | 不支持 | 支持 | 支持 |
| 第一版推荐度 | 高 | 低 | 视团队能力而定 |
3. 用 CameraX 把“拍照”做成可供 OCR 的最低成本链路
很多工程问题不是出在识别模型,而是出在拍照那一环:拍出来的图本身模糊、过曝或者旋转了 90 度,后面识别再好也救不回来。这里需要一个稳定、可控的拍照链路。Android 上现在最省事的方案是 CameraX,而不是直接用 Camera2 裸写。
3.1 为什么要用 CameraX 而不是旧 Camera2 裸写
Camera2 的问题是生命周期管理太琐碎。你要自己处理相机权限、Session 配置、Surface 生命周期、旋转角度,任何一个环节出错,在低端机上就容易黑屏或闪退。CameraX 把这一整套抽成了ProcessCameraProvider+Preview+ImageCapture的结构,页面一打开就自动绑定到 LifecycleOwner,离开页面自动释放相机资源。
对于 OCR 这种“打开页面拍个照就走”的场景,CameraX 可以省掉大约三分之一的样板代码,而且它的ImageCapture输出图片时会自动带上 EXIF 方向信息,这对后面 OCR 很重要——如果拿不到原始方向,识别前要先猜图是不是歪的,很麻烦。
3.2 在 build.gradle 里加依赖与权限:完整片段
根目录build.gradle不用改,只要在模块的build.gradle(app 那个)里加依赖。注意 CameraX 的camera-camera2、camera-lifecycle、camera-view三个构件要统一版本号,升级时一起改,避免版本不匹配导致运行时找不到类。
dependencies { implementation "androidx.camera:camera-camera2:1.3.4" implementation "androidx.camera:camera-lifecycle:1.3.4" implementation "androidx.camera:camera-view:1.3.4" }版本号以你在 Android Studio 里实际能拉到的最新稳定版为准,不要盲目跟着旧教程用 1.0.x。1.0 到 1.3 之间 API 有调整,setTargetResolution的解析行为也变过,老代码直接搬过来可能编译过不了。
权限方面,AndroidManifest.xml里声明相机权限即可。如果拍照后要读取或保存到公共目录,还需要按安卓版本补充存储权限:
<uses-permission android:name="android.permission.CAMERA" /> <!-- 仅安卓 13 以下且需要保存公共目录时需要 --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />运行时权限申请用ActivityResultContracts.RequestPermission就行,注意在拒绝权限时给出引导说明,否则用户只看到黑屏,不知道怎么回事。
3.3 拍一帧图转成 Bitmap:核心代码与参数说明
一般做法是先绑定相机预览,然后设置ImageCapture的takePicture回调。这里我用OnImageCapturedCallback方式,直接拿到ImageProxy转Bitmap,省去先存文件再读文件的环节。
private lateinit var imageCapture: ImageCapture private fun bindCamera(lifecycleOwner: LifecycleOwner) { val preview = Preview.Builder() .setTargetResolution(Size(1920, 1080)) .build() imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .setTargetRotation(WindowManagerCompat.getDefaultDisplay().rotation) .setTargetResolution(Size(1920, 1080)) .build() val cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ val cameraProvider = cameraProviderFuture.get() val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, cameraSelector, preview, imageCapture ) }, ContextCompat.getMainExecutor(this)) } // UI 点击拍照后调用 imageCapture.takePicture( ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { val bitmap = imageProxyToBitmap(image) image.close() // 把 bitmap 交给后续 OCR 处理 runOcr(bitmap) } override fun onError(exception: ImageCaptureException) { Log.e("CameraX", "拍照失败: ${exception.message}") } } )这里有几个参数是有讲究的:
CAPTURE_MODE_MINIMIZE_LATENCY:优先保证快门响应快,适合手持拍摄;相对的是CAPTURE_MODE_MAXIMIZE_QUALITY,画质优先但快门延迟明显,手持容易糊。- 分辨率不是越高越好。OCR 场景下,
1920x1080已经足够,个别细字号场景再上4032x3024反而拖慢识别速度并增加内存压力。 - 方向问题交给
setTargetRotation。如果这里不设置,拍出来的横竖屏方向可能和用户看到的不一致,后面识别就得多写一段旋转逻辑。CameraX 会把旋转角写进 EXIF,你只需要在读取 Bitmap 时尊重 EXIF 方向即可。
imageProxyToBitmap是常见的ImageProxy到Bitmap的转换:
private fun imageProxyToBitmap(image: ImageProxy): Bitmap { val planeProxy = image.planes[0] val buffer = planeProxy.buffer val pixelStride = planeProxy.pixelStride val rowStride = planeProxy.rowStride val rowPadding = rowStride - pixelStride * image.width val bitmap = Bitmap.createBitmap( image.width + rowPadding / pixelStride, image.height, Bitmap.Config.ARGB_8888 ) bitmap.copyPixelsFromBuffer(buffer) return Bitmap.createBitmap(bitmap, 0, 0, image.width, image.height) }这段逻辑说明:CameraX 默认输出的 YUV 格式,planes[0]是亮度分量平面;如果直接把整个 buffer 按 ARGB 格式拷贝,会因为行字节对齐出现绿边或图像错位。所以要读取rowStride和pixelStride算出每行实际占用的字节数,再按实际尺寸裁剪。这个坑几乎每个第一次对接 CameraX 的人都会踩,不处理的话识别永远不对。
3.4 拍到“看不清”的图怎么办:先反推相机参数
拍照链路里最容易翻车的不是代码,而是“图糊了”。不是代码写错,而是光线和抖动导致的。
我常用两个缓解手段。第一个是手动对焦。CameraX 官方推荐用FocusMeteringAction,用户在预览界面点击某个位置时触发对焦,同时锁定曝光:
val meteringAction = FocusMeteringAction.Builder( point, MeteringPointFactory.METERING_POINT_AREA_MODE_ON ).apply { setAutoCancelDuration(3, TimeUnit.SECONDS) }.build() camera.cameraControl.startFocusAndMetering(meteringAction)第二个是在低光环境下提示用户或者自动补光。CameraControl.enableTorch(true)可以打开闪光灯当手电筒用,但要注意并不是所有机型都支持前后摄像头常亮手电,调用前最好查一下CameraInfo.hasFlashUnit(),否则部分机型会抛异常。
拍完一张图之后,建议立刻做一个“可识别性预检”:如果 Bitmap 的灰度直方图方差过小,说明对比度太低,大概率是欠曝或过曝;这种情况下在界面上给用户一个“请重拍”的提示,比强行把图送进 OCR 再输出一堆乱码要好得多。这个预检代码我放在下一章讲预处理时一起给。
4. 从拍照到 OCR 结果:图片旋转、缩放与 ML Kit 的对接流水线
拿到了 Bitmap,接下来的问题是怎么把它送进 ML Kit 的识别器。这里有几层细节容易疏忽:EXIF 旋转、图像缩放、模型初始化、结果解析。每一步不处理好,识别结果都会差一个档次。
4.1 正确创建 InputImage:EXIF 旋转是绝大多数乱码的根源
安卓手机拍出来的 Bitmap,其像素数据本身可能已经是旋转过的,也可能没有,取决于你用哪种方式读取。如果直接用ImageProxy转出来的 Bitmap,它是一张纯原始帧,没有应用 EXIF 方向,这时候直接送进 OCR,相当于把原图旋转后的内容当正立图去看。
正确的做法是拿到 Bitmap 后先用ExifInterface读取原始方向,并把旋转应用到像素上:
val exif = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { ExifInterface(bitmap) } else { @Suppress("DEPRECATION") ExifInterface(bitmap.path) } val orientation = exif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL ) val rotatedBitmap = when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f) ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f) ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f) else -> bitmap }这里有个容易迷糊的点:InputImage.fromBitmap(bitmap, rotationDegrees)的第二个参数不是“图片原本旋转了多少度”,而是“要把图片旋转多少度才能显示为正立”。如果你已经手动旋转过 Bitmap,这个参数传 0 就可以;如果你拿到的是未处理的原始帧,则需要把 EXIF 方向换算成对应的旋转角度传进去。两种方式殊途同归,但别同时都做,否则图片会被转两次,OCR 结果也会反向。
4.2 识别前要不要做预处理:缩放到 1600px 宽度是性价比最高的操作
很多人一提 OCR 就想到灰度化、二值化、降噪,实际从工程结果看,这些操作对 ML Kit 的识别效果提升远没有“把图片缩放到合适尺寸”大。ML Kit 内部有自己的图像归一化流程,过度预处理反而可能破坏特征。
真正有效的预处理是缩放和对比度增强,但不要用太激进的方式。我常用的做法如下:
fun preprocessForOcr(original: Bitmap): Bitmap { // 1. 控制宽度不超过 1600px,超出时等比缩小 val targetWidth = 1600 val scale = if (original.width > targetWidth) { targetWidth.toFloat() / original.width } else 1f val scaled = Bitmap.createScaledBitmap( original, (original.width * scale).toInt(), (original.height * scale).toInt(), true ) // 2. 对明显偏暗或偏亮的图做对比度拉伸(保守参数) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val colorMatrix = ColorMatrix().apply { setSaturation(1.2f) } val paint = Paint().apply { colorFilter = ColorMatrixColorFilter(colorMatrix) } val enhanced = Bitmap.createBitmap(scaled.width, scaled.height, Bitmap.Config.ARGB_8888) Canvas(enhanced).drawBitmap(scaled, 0f, 0f, paint) return enhanced } return scaled }为什么是 1600px?因为 ML Kit 的模型对输入尺寸有上限约束,过大图片不会带来更高的识别精度,只会增加内存和时间。1600px 宽度基本覆盖了手机拍摄文字的正常字号,同时让单帧推理时间保持可控。
灰度化和二值化我没有放在默认流程里。ML Kit 设备端模型是在自然图像上训练的,直接喂彩色图反而有更好的泛化能力;只有当你发现“黑底白字”或者“对比度极低”的图片识别失败时,再做二值化兜底。那属于踩坑章节讨论的内容,这里不展开。
4.3 用 ML Kit 文本识别 v2 跑通中英文:最小 Kotlin 代码
ML Kit 文本识别依赖需要单独加进 Gradle。同样注意,文本识别用到的构件是com.google.mlkit:text-recognition,如果要中文识别能力,还需要加对应的中文语言包com.google.mlkit:text-recognition-chinese。两个都加,才能保证中文和英文都能被识别出来。
// build.gradle 模块依赖 implementation "com.google.mlkit:text-recognition:16.0.0" implementation "com.google.mlkit:text-recognition-chinese:16.0.0" // 如果需要识别其他语言,再按需加对应语言包 implementation "com.google.mlkit:text-recognition-devanagari:16.0.0"版本号同样以你拉取到的实际版本为准。这里最容易被忽略的是中文语言包,只加基础依赖,中文识别会直接失败或者输出空文本。识别调用代码如下:
private val recognizer: TextRecognizer by lazy { TextRecognition.getClient( TextRecognizerOptions.Builder() .setLanguageHints(listOf("zh", "en")) .build() ) } fun runOcr(bitmap: Bitmap) { val inputImage = InputImage.fromBitmap(preprocessForOcr(bitmap), 0) recognizer.process(inputImage) .addOnSuccessListener { result -> parseResult(result) } .addOnFailureListener { e -> Log.e("OCR", "识别失败: ${e.message}") } }setLanguageHints的作用是告诉模型“优先按这些语言识别”。不设置也能跑,但遇到中英文混排时,语言识别的准确性会下降。这里的语言代码要按 ML Kit 的语言代码规范写,简体中文是zh,不要写成zh-CN——那是 Android 区域设置里的写法,OCR 模型不认。
建议把recognizer做成单例或lazy,避免每次拍一张照片都重新初始化一份模型。模型初始化开销大约在几百毫秒到一秒之间,如果放在拍照回调里,用户会明显感觉到卡顿。
4.4 结果解析策略:TextBlock、Line、Element 从粗到细
ML Kit 的识别结果不是“一整块文本”,而是三级结构:TextBlock(文本块)、Text.Line(行)、Text.Element(元素)。一个文本块对应视觉上的一块区域,比如名片上的姓名区、发票上的金额区;一个块里包含若干行文本;每行又由若干元素组成,元素基本对应单词或单个汉字。
工程上要按场景取值。如果只是要识别整页文本,把每个Line拼接起来即可;如果要提取结构化字段,比如识别发票号、身份证号,那就需要结合boundingBox做区域过滤。一个常见做法是按照文本块的中心点坐标排序,确保输出的文本顺序和视觉顺序一致:
private fun parseResult(result: Text): String { val lines = mutableListOf<Pair<Float, String>>() for (block in result.textBlocks) { for (line in block.lines) { val text = line.text val box = line.boundingBox if (box != null) { val centerY = box.centerY() lines.add(centerY to text) } } } // 按垂直位置排序,保持阅读顺序 lines.sortBy { it.first } return lines.joinToString("\n") { it.second } }注意boundingBox可能为null,所以要做空判断。按centerY排序适合大多数横版排版;竖排文本场景需要换成centerX排序,这个在避坑章节会展开讲。
5. 避坑:拍照 OCR 项目里最常见的 5 个翻车现场
这部分是我在类似项目里反复踩过的坑,每一条都按“现象 → 原因 → 解决”的顺序写。如果你照着前面的代码搭完,识别率还是不对劲,大概率命中的就是下面某一条。
5.1 中文识别不出或大量乱码:忘记引入中文语言包
现象:明明图片上是大段简体中文,识别结果要么为空,要么输出一行不知道什么语言的字符。更隐蔽的是,图片里中英文混排时,中文部分直接消失。
原因:ML Kit 文本识别的基础依赖基本只覆盖拉丁语系。中文识别需要显式引入text-recognition-chinese语言包,没有这个包,模型返回的语言假设里就没有中文。
解决:确认build.gradle里同时引入了com.google.mlkit:text-recognition和com.google.mlkit:text-recognition-chinese,并且在TextRecognizerOptions.Builder中配置setLanguageHints(listOf("zh", "en"))。改完后 clean 再 build,因为依赖变更偶尔会有增量编译不干净的问题。
5.2 竖排文字识别出来却是横读完的
现象:一张竖排诗词或竖版广告照片,识别后文本顺序是“从左到右按行跳读”,完全读不通。
原因:ML Kit 的默认输出顺序基于西方阅读习惯,按行、行内从左到右排列。竖排场景下,文本行实际是按列排列的,默认排序逻辑直接失效。
解决:解析结果时不要用行顺序拼接,而是对TextBlock的boundingBox位置做判断。如果多个文本块的右上角点 X 坐标接近、Y 坐标递增,说明是竖排,此时按centerX从右到左排序输出:
// 竖排检测:比较块的平均宽度与高度比 if (block.boundingBox.height() > block.boundingBox.width()) { // 按右边界 X 值从大到小排列 }这是比较粗暴的启发式判断,但实际够用。如果项目里大量出现竖排场景,建议在拍照后的预检阶段就提示用户“将相机横持”,横向构图对模型更友好。
5.3 手机发烫、内存暴涨,识别越跑越慢
现象:连续拍十几张照片后,界面开始卡顿,识别耗时从 300 毫秒涨到 1 秒以上,最后甚至 OOM 崩溃。
原因:Bitmap.createBitmap产生大量对象,识别结果持有的Bitmap没有被及时释放;加上累计的ImageProxy没有调用close(),YUV 缓冲被一直占着。安卓的内存回收对这种高频临时对象不友好,内存水位一高,GC 频繁触发,卡顿就来了。
解决:三个地方确认到位。
第一,ImageProxy在onCaptureSuccess里用完必须调close(),忘掉这个等于每次拍照泄漏一份不小的 buffer。第二,preprocessForOcr里生成的中间Bitmap用完后及时recycle()。第三,把识别逻辑放到Dispatchers.Default线程,避免占用主线程。识别完成后再用runOnUiThread更新界面。
scope.launch(Dispatchers.Default) { val result = recognizer.process(inputImage).await() withContext(Dispatchers.Main) { binding.resultTextView.text = result.text } }await()需要依赖kotlinx-coroutines-play-services,这是 Kotlin 协程和 Google API Task 之间的桥接库,加上这个依赖才能这样写。用它替代addOnSuccessListener,代码会清爽很多,也不需要一层层回调嵌套。
5.4 识别结果被“重影”毁掉:拍照抖动和长曝光
现象:识别出的文本单词间出现重复字母,中文字出现笔画叠加坏掉,明显是图像有重影。
原因:CAPTURE_MODE_MINIMIZE_LATENCY虽然快门快,但部分手机在弱光下会偷偷降低快门速度来保证亮度,导致手持抖动带来的运动模糊,像素上形成重影。
解决:弱光环境下改成CAPTURE_MODE_MAXIMIZE_QUALITY,或者手动增加曝光补偿,但都不能根治。真正有效的手段是告诉用户“请拿稳手机”的提示文案,并在识别前检测模糊度。检测方式不复杂,把图像缩到 64x64 灰度后计算拉普拉斯方差,数值低于阈值即判定为模糊。这个判断放进拍照回调里,如果模糊就让用户重拍,比事后识别失败更体面。
5.5 黑底白字和反色图片识别率骤降
现象:同一个 OCR 引擎,白底黑字识别得很好,黑底白字几乎全军覆没。如果画面上是深色背景的二维码旁边一块浅色文字,那块文字也经常读不出来。
原因:深度学习 OCR 模型的训练数据以自然图像为主,其中白底黑字的占比极高。黑底白字相当于把颜色通道反转,特征分布偏移,模型置信度下降,输出结果被大量过滤掉或拼接错乱。
解决:加一道“反色检测”逻辑。把 Bitmap 缩小到 16x16,统计像素亮度均值;如果均值低于 128,说明整体偏暗,可能是黑底白字,就把图像取反色后再送识别。OpenCV 的Core.bitwise_not最省事,如果不想引入 OpenCV,用ColorMatrix的负矩阵也能实现:
val negative = ColorMatrix( floatArrayOf( -1f, 0f, 0f, 0f, 255f, 0f, -1f, 0f, 0f, 255f, 0f, 0f, -1f, 0f, 255f, 0f, 0f, 0f, 1f, 0f ) )这个矩阵把 RGB 三个通道取负再加 255,实现反色。注意只对“整体偏暗且文字区域反差大”的图使用,正常照片强行反色反而会让识别变差。做一层均值判断再决定要不要反色,而不是无脑全部反色。
提示:OCR 识别从来没有“一个模型打天下”。ML Kit 对印刷体、屏幕字、清晰手写体表现不错,但遇到艺术字、严重透视畸变、超低分辨率小字,仍然会翻车。上线前准备一批业务真实样张做回归测试,比事后调参重要得多。
6. 把准确率从“能用人眼确认”推进到“可上线”:置信度过滤与回归验证
前面的代码能跑通,只是第一关。真正要把这个 OCR 功能交给用户,还需要处理三个问题:不可信结果的过滤、业务字段的定位、以及回归测试集的维护。
6.1 置信度过滤:只保留可信文本
ML Kit 文本识别的TextBlock和Text.Line都带一个可空的confidence属性,范围 0 到 1。实际工程里,低于 0.5 的行基本都是误识别产物,直接丢弃比展示出来更有价值。在解析结果时我习惯这样过滤:
private fun parseFilteredText(result: Text): String { val builder = StringBuilder() for (block in result.textBlocks) { for (line in block.lines) { val confidence = line.confidence ?: continue if (confidence < 0.5f) continue builder.append(line.text).append('\n') } } return builder.toString() }注意confidence为null的场景,比如某些离线模型不输出置信度,此时不能空指针,而是直接放行。过滤阈值不要定死在 0.5 以上。打印票据、扫描件这类清晰文本可以提高到 0.7;但手持照片、光线不佳的情况下,0.7 会把很多本来正确的行也滤掉。正确做法是先在回归集上跑一遍,看置信度分布再定阈值。
6.2 用固定样张做回归测试:记录每张样本的识别文本
这个习惯是我在一个发票识别项目里被逼出来的。当时开发阶段识别率很好,上线后用户传的图五花八门,识别率掉了一半。后来我把每类场景各收集了 20 张样张,写了一个小脚本,通过 adb 把样张推到应用沙箱目录,再自动触发识别并导出结果,之后每次改模型或改参数都跑一遍同一套图。
Android 上做这个测试不需要复杂的自动化框架。你只需要把一批测试图片放在固定目录,应用里写一个 Debug 入口,依序读图识别,把识别结果存成一个文本文件供比对。后期用一个小 Python 脚本计算字符级准确率,diff 出变化即可:
import re def char_accuracy(pred, truth): pred = re.sub(r'\s+', '', pred) truth = re.sub(r'\s+', '', truth) correct = sum(1 for p, t in zip(pred, truth) if p == t) return correct / max(len(truth), 1) # 读取两个文件,逐行比对对 OCR 项目来说,跑通是及格,回归稳定才是上线的前提。没有回归集,任何一次模型升级都是在赌。
6.3 留存一份“识别失败”测试集:让踩坑成为长期资产
最后建议你养成一个习惯,把识别失败的图片单独存一份,不要删。每一张失败图都是下一次优化的最佳切入点。遇到新版本,第一件事就是拿旧失败集跑一遍,看修复了多少、又新增了多少失败。如果新模型把旧问题解决了但引入了更多新问题,说明是性能回退,不能盲目升级。
我在做这类拍照 OCR 应用时最深的体会是:把“识别率”当成一个可观测的指标,而不是一个玄学。每次改动都量一次,问题会自己浮出来。希望这个思路能帮你在安卓拍照 OCR 的路上少走几段弯路。
本文还有配套的精品资源,点击获取