国内 Android App 使用 Google ML Kit OCR 文字识别
在手机上做 OCR,常见的做法是拍照后把图片传到服务端识别,百度、腾讯、阿里、有道都有这类接口,也不算贵。但这种做法有三个绕不开的问题:必须联网、图片要上传到别人的服务器、识别一次算一次钱。
Google 的 ML Kit 提供了一套完全在本地跑的文字识别,模型和推理库都能跟着 APK 一起打包。也就是说用户装上应用之后,没网也能识别,图片不出手机,识别次数也没有限制。
不过这套东西在国内用有一个前提:必须选对依赖。ML Kit 的文字识别有两套坐标,一套的模型跟着 APK 走,另一套要从 Google Play 服务里下载。选错的话,代码在本机跑得好好的,换一台没装 Google 服务的手机就直接报错。这篇文章把这一步和其他几个容易踩的地方一起写清楚。
注意,下面写的是 Java 调用,不是 Flutter 或 React Native。
选对依赖
ML Kit 的依赖分两个 groupId,看名字很像,行为完全不同:
| groupId | 例子 | 模型在哪里 |
|---|---|---|
com.google.mlkit | text-recognition-chinese | 跟着 APK 打包,装完就能用 |
com.google.android.gms | play-services-mlkit-text-recognition-chinese | 首次使用时从 Google Play 服务下载 |
国内要用的显然是第一套。这里还有一点容易误解:com.google.mlkit 的包并不是「薄薄一层 API」,模型文件是真的在它的 AAR 里。我把两个包解开对比过:
play-services-mlkit-text-recognition-chinese:16.0.1的 AAR 只有 78 KB,里面除了一个ModuleDescriptor和一个描述文件什么都没有,模型是靠 Google Play 服务去下的;text-recognition-chinese:16.0.1的 AAR 有 2 MB,里面带着assets/mlkit-google-ocr-models/目录,中文和拉丁字母的模型都在里面。
所以看到网上说「ML Kit 需要 Google Play 服务」的说法,那说的是第二套依赖,跟我们要用的这套不是一回事。
顺带说一句,ML Kit 的文字识别在中文上只有两个选择:
text-recognition:只认拉丁字母,也就是英文、拼音这些;text-recognition-chinese:认简体中文 和 拉丁字母。
如果你的应用要认中文,装 text-recognition-chinese 就够了,不需要装两个(它的模型本身就带拉丁字母)。另外还有 text-recognition-japanese、text-recognition-korean、text-recognition-devanagari,按需再加。
安装
在 app/build.gradle 的 dependencies 里加两行:
dependencies {
// 简体中文 + 拉丁字母(英文)的文字识别,模型随 APK 打包
implementation 'com.google.mlkit:text-recognition-chinese:16.0.1'
// 提供 TextRecognizerOptions、ChineseTextRecognizerOptions 这些配置类
implementation 'com.google.mlkit:text-recognition:16.0.1'
}第二行是有坑的:ChineseTextRecognizerOptions 这个类不在 text-recognition-chinese 里,而在配套的 text-recognition 里。只加第一行的话,代码里写 new ChineseTextRecognizerOptions.Builder() 会直接编译不过,报「找不到符号」。我第一次就是只加了一行,在 com.google.mlkit.vision.text.chinese 这个包里翻了半天。
版本号建议两个包保持一致,都是 16.0.1。这两个包的镜像在 Google 的 Maven 仓库上,如果下载慢或失败,在项目根目录的 settings.gradle 里加一下阿里云的镜像:
dependencyResolutionManagement {
repositories {
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
google()
mavenCentral()
}
}com.google.mlkit 和它依赖的 com.google.android.gms 系列包,阿里云的 google 镜像里都有。
最简单的调用
先看一个最小可用的例子,把图片识别成文字:
// 创建识别器,整个应用创建一次就够了,后面复用
TextRecognizer recognizer = TextRecognition.getClient(
new ChineseTextRecognizerOptions.Builder().build());
// 从文件路径或 content Uri 创建输入图片
InputImage image = InputImage.fromFilePath(context, imageUri);
// 提交识别任务
recognizer.process(image)
.addOnSuccessListener(result -> {
// result 里包含了识别出的全部文字
Log.d("OCR", result.getText());
})
.addOnFailureListener(e -> {
Log.e("OCR", "识别失败", e);
});result.getText() 返回的是整段文字,块与块之间用换行拼接,适合直接丢给输入框或者朗读。
InputImage.fromFilePath() 支持 file:// 和 content:// 两种 Uri,相册选出来的 content://、相机写入 FileProvider 的 content:// 都能直接用。这一点省了不少事:直接传路径就不用自己处理 Uri 转流、转 Bitmap 了。
有一个容易忽略的细节:EXIF 里的旋转信息 fromFilePath() 会自己处理。手机拍的照片常常是横着存的,靠 EXIF 标记方向,这个方法读的时候会把方向正过来。但如果你是自己把图片解码成 Bitmap 再 InputImage.fromBitmap(bitmap, 0),第二个参数得手动传旋转角度,不然识别出来的文字位置和方向都是错的。
用完记得释放:
recognizer.close();TextRecognizer 内部持有模型和原生资源,重复创建会反复加载模型,既慢又占内存。在应用里持有一份就好(放在某个单例里),不要每次识别都 new 一个。
拿到结构化的结果:文字、行列和位置
只拿整段文字的话 getText() 就够,但 OCR 应用往往还需要位置信息,比如在图片上画出识别框、点击某一行朗读、把某一行单独丢给翻译。这就需要往下拆 Text 的结构。
ML Kit 的结果是一棵四层的树:
Text 整张图的识别结果
└─ TextBlock 一个文字块(一般对应一个段落或一列文字)
└─ Line 一行
└─ Element 一个「词」或连续的词组
└─ Symbol 单个字符上面每一层都有两个共同的能力:
getText():这一层识别出的文字;getBoundingBox():这一层在图片里的矩形范围,单位是图片的像素坐标。
Line 还多了两个属性:getConfidence() 是置信度,getAngle() 是这一行的倾斜角度。
下面遍历出每一行的文字和位置,result 就是识别成功后拿到的 Text:
List<Text.Line> lines = new ArrayList<>();
for (Text.TextBlock block : result.getTextBlocks()) {
for (Text.Line line : block.getLines()) {
// 跳过空行
if (line.getText().trim().isEmpty()) continue;
lines.add(line);
Rect box = line.getBoundingBox(); // 可能是 null,用之前判断一下
Log.d("OCR", line.getText() + " -> " + box);
}
}如果只想要「每行一项」的文字列表,把 line.getText() 收进 List 就行,和在线接口返回的那种逐行结果是同一形态。
要拆到字符级别(比如做文字选择、逐字校对)就继续往下遍历:
for (Text.TextBlock block : result.getTextBlocks()) {
for (Text.Line line : block.getLines()) {
for (Text.Element element : line.getElements()) {
for (Text.Symbol symbol : element.getSymbols()) {
// 单个字符
Log.d("OCR", symbol.getText() + " @ " + symbol.getBoundingBox());
}
}
}
}symbol.getText() 返回的是单个字符,中文也是按字拆的。
在图片上画出识别框
拿到了每一行的 Rect,就可以在图片上画框。这里有一个坐标系的问题:Rect 的坐标是原图像素的坐标,而 ImageView 显示的图片通常被缩放过,直接画会错位。
如果 ImageView 用的是 fitCenter(默认行为),可以做这样的换算:
/**
* 把原图坐标系的矩形换算成 View 坐标系的矩形。
*
* @param box 原图上的矩形
* @param imageW 原图宽
* @param imageH 原图高
* @param imageView 显示图片的 ImageView
*/
private static RectF mapBox(Rect box, int imageW, int imageH, ImageView imageView) {
float viewW = imageView.getWidth();
float viewH = imageView.getHeight();
// fitCenter:取较小的缩放比,保证整张图都能放下
float scale = Math.min(viewW / imageW, viewH / imageH);
float drawW = imageW * scale;
float drawH = imageH * scale;
// 居中后图片左上角在 View 里的偏移
float offsetX = (viewW - drawW) / 2f;
float offsetY = (viewH - drawH) / 2f;
return new RectF(
offsetX + box.left * scale,
offsetY + box.top * scale,
offsetX + box.right * scale,
offsetY + box.bottom * scale);
}然后在一个叠在 ImageView 上面的自定义 View 里把矩形画出来:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (Text.Line line : lines) {
Rect box = line.getBoundingBox();
if (box == null) continue;
RectF mapped = mapBox(box, imageWidth, imageHeight, imageView);
canvas.drawRect(mapped, boxPaint); // 边框
canvas.drawText(line.getText(), mapped.left, mapped.top - 6, textPaint); // 文字
}
}文字建议画在框的上方或下方,直接盖在框里会压住原图上的字。框的画笔用 Paint.Style.STROKE,线宽别超过 3dp,不然小图上会糊成一片。
完整示例
把上面这些拼起来,做成一个「选张图片 → 后台识别 → 显示文字和位置」的完整例子。识别本身是异步任务,为了代码好读,我把它包成一个同步方法,放在子线程里调用,这样调用方不用处理回调嵌套。
package com.example.ocr;
import android.content.Context;
import android.graphics.Rect;
import android.net.Uri;
import com.google.android.gms.tasks.Tasks;
import com.google.mlkit.vision.common.InputImage;
import com.google.mlkit.vision.text.Text;
import com.google.mlkit.vision.text.TextRecognition;
import com.google.mlkit.vision.text.TextRecognizer;
import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* ML Kit 离线文字识别的封装。
*
* 识别器(TextRecognizer)内部持有模型和原生资源,创建一次就一直复用,
* 所以这里做成单例;识别放在自己的工作线程里执行,结果通过回调返回。
*/
public class MlKitOcr {
/** 识别结果里的一行:文字 + 在图片里的位置。 */
public static class Line {
public final String text;
public final Rect box;
Line(String text, Rect box) {
this.text = text;
this.box = box;
}
}
/** 识别完成的回调,运行在调用线程(这里是工作线程)。 */
public interface Callback {
void onSuccess(List<Line> lines);
void onError(Exception e);
}
private static final long TIMEOUT_SECONDS = 60;
private static volatile TextRecognizer recognizer;
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
/** 单例识别器,第一次调用时创建(会加载模型,稍慢)。 */
private static TextRecognizer recognizer() {
TextRecognizer value = recognizer;
if (value == null) {
synchronized (MlKitOcr.class) {
value = recognizer;
if (value == null) {
value = TextRecognition.getClient(
new ChineseTextRecognizerOptions.Builder().build());
recognizer = value;
}
}
}
return value;
}
/**
* 识别图片里的文字。
*
* @param context 用来解析 Uri
* @param imageUri 图片地址,file:// 或 content:// 都可以
* @param callback 识别结果回调
*/
public static void recognize(Context context, Uri imageUri, Callback callback) {
final Context appContext = context.getApplicationContext();
EXECUTOR.execute(() -> {
try {
// 图片较大时可以先压一下再识别,见下面「可能遇到的问题」
InputImage image = InputImage.fromFilePath(appContext, imageUri);
// 把异步任务转成同步等待,超时时间防止极端情况下一直挂着
Text result = Tasks.await(recognizer().process(image),
TIMEOUT_SECONDS, TimeUnit.SECONDS);
callback.onSuccess(toLines(result));
} catch (ExecutionException e) {
// 真正的失败原因(比如缺运行环境)在 cause 里
Throwable cause = e.getCause() != null ? e.getCause() : e;
callback.onError(new Exception(cause.getMessage(), cause));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
callback.onError(e);
} catch (Exception e) {
// 图片读不出来、原生库加载失败等
callback.onError(e);
}
});
}
/** 把识别结果摊平成「每行一项」。 */
private static List<Line> toLines(Text text) {
List<Line> lines = new ArrayList<>();
for (Text.TextBlock block : text.getTextBlocks()) {
for (Text.Line line : block.getLines()) {
if (line.getText().trim().isEmpty()) continue;
lines.add(new Line(line.getText(), line.getBoundingBox()));
}
}
return lines;
}
/** 释放识别器,进程退出或需要重新加载模型时调用。 */
public static void release() {
synchronized (MlKitOcr.class) {
if (recognizer != null) {
recognizer.close();
recognizer = null;
}
}
}
}界面侧的用法(从一个按钮或相册回调里调用):
MlKitOcr.recognize(getContext(), imageUri, new MlKitOcr.Callback() {
@Override
public void onSuccess(List<MlKitOcr.Line> lines) {
// 结果回到主线程再刷新界面
runOnUiThread(() -> {
StringBuilder text = new StringBuilder();
for (MlKitOcr.Line line : lines) {
text.append(line.text).append('\n');
}
textView.setText(text.toString().trim());
// lines 里的 box 就是画框要用的坐标
overlayView.setLines(lines);
});
}
@Override
public void onError(Exception e) {
runOnUiThread(() -> Toast.makeText(getContext(),
"识别失败:" + e.getMessage(), Toast.LENGTH_LONG).show());
}
});如果项目里已经有 RxJava,用 Observable.create 包一层会更顺手;用 Kotlin 的话 kotlinx-coroutines-play-services 提供的 await() 也能把 Task 直接变成挂起函数,不用自己写线程池。上面的写法不依赖任何第三方库,纯 Java 就能跑。
打包:把模型打进 APK
用 com.google.mlkit 这套依赖的话,模型和原生库在构建时就合并进 APK 了,打包这一步不需要额外配置,也没有「首次运行时下载模型」的等待。构建完之后可以解压 APK 确认一下:
unzip -l app-debug.apk | grep -i mlkit会看到两类东西:
assets/mlkit-google-ocr-models/... 模型文件(30 个左右)
lib/arm64-v8a/libmlkit_google_ocr_pipeline.so 推理用的原生库代价是体积。按我自己的实测(只开了 arm64-v8a):
| 内容 | 大小 |
|---|---|
assets/mlkit-google-ocr-models/ | 约 2.4 MB |
lib/arm64-v8a/libmlkit_google_ocr_pipeline.so | 约 10.5 MB |
| APK 总大小变化 | 约 7 MB → 约 21 MB |
原生库占了大部分,而且它是按 ABI 分开的,armeabi-v7a、x86、x86_64 各有一份,都在 6~11 MB 之间。如果不做分架构打包,APK 会把四份全塞进去,体积直接翻倍。国内上架的应用大多只留 arm64-v8a(Android 7 之后的 64 位机器都是这个),在 build.gradle 里加:
android {
splits {
abi {
enable true
reset()
include 'arm64-v8a' // 只出 64 位 ARM
universalApk false // 不再生成包含全部架构的通用包
}
}
}开了这个之后产物名会变成 app-arm64-v8a-debug.apk。如果想同时保留一个「什么机器都能装」的包,把 universalApk 改成 true,它会另外生成一个通用包,但那个包会把所有 ABI 的原生库都打进去,体积就是四份之和。
另外可以用 R8 去掉用不到的代码,但对模型和原生库的体积没有影响——那是资源,不是代码。
可能遇到的问题
报错「MlKitException」,错误码看不懂
ML Kit 的失败会抛 MlKitException(com.google.mlkit.common.MlKitException),里面带一个 errorCode。排查的时候把这个错误码打出来,能省很多时间:
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof MlKitException) {
MlKitException exception = (MlKitException) cause;
// 错误码对应官方文档里的原因,比如模型加载失败、运行环境缺失
Log.e("OCR", "错误码 " + exception.getErrorCode(), exception);
}
}Tasks.await() 会把真正的异常包在 ExecutionException 里,直接看 e.getMessage() 往往只能看到一层壳,一定要拆到 getCause()。用 addOnFailureListener 的回调形式时就不用拆了,回调收到的已经是原始异常。
应用能编译,装上后识别失败
先确认用的是 com.google.mlkit 而不是 com.google.android.gms 那套。如果确认没错,再看设备上有没有 Google 的运行环境。
这一点需要说清楚,避免走弯路:模型确实在 APK 里、识别过程也确实不联网、不需要 Google Play 商店;但 ML Kit 的客户端代码仍然依赖 play-services-base、play-services-basement 这些运行时库(它们会被 text-recognition 自动带进来)。在一台完全没有 Google 组件的国内定制 ROM 上,识别会失败并抛出带错误码的 MlKitException(把错误码打出来的方法见上一节)。
如果你的目标机器完全没有 Google 组件,ML Kit 这条路走不通,得换纯本地的方案,比如 PaddleOCR 的 ncnn 版本、Tesseract(中文需要额外装语言包,效果一般)。这一点建议先在真机上验证再决定技术选型,别等做完了才发现跑不起来。
识别速度慢
几个可以试的方向,按收益排序:
- 复用识别器。每次识别都
getClient()会反复加载模型,第一次可能要一两秒。把它放在单例里,第一次识别之后就是复用的。 - 识别前先压图。
InputImage.fromFilePath()会读原图,相机拍的照片动辄 4000×3000,解码和推理都白花时间。可以先按最长边压到 2000 像素左右再交给 ML Kit:
// 先把图片按最长边压到 2000px,再交给识别器
Bitmap bitmap = decodeScaled(context, imageUri, 2000);
InputImage image = InputImage.fromBitmap(bitmap, 0);
// 识别结束后记得回收
bitmap.recycle();用 InputImage.fromBitmap() 时要自己处理 EXIF 方向(见前面),fromFilePath() 才帮你转。
- 别在主线程调用。识别是 CPU 密集的,放在主线程会卡界面,严重时还会被系统判定为 ANR。放到工作线程里(上面的例子用的是单线程池),结果再回主线程刷界面。
文字位置画出来是偏的
八成是坐标系没换算(原图像素 vs View 尺寸),或者图片被 fitCenter / centerCrop 缩放了。上面「在图片上画出识别框」那一段是按 fitCenter 写的:缩放比取 Math.min,图片居中,四周留白。如果 ImageView 用的是 centerCrop,改成缩放比取 Math.max、多出来的部分被裁掉——这时原图的左上角通常落在 View 外面,两个方向的偏移里至少有一个是负数(比如图比 View 宽就是要往左移),公式不用改,把 scale 换成 Math.max 就行,负号是算出来的,不用手写。
另外 getBoundingBox() 返回的矩形在个别行上可能是 null(比如识别到但定位失败),画之前判断一下。
中文识别不出来,或者英文夹杂着乱码
检查是不是装了 text-recognition 而不是 text-recognition-chinese。text-recognition 只有拉丁字母的模型,遇到中文会当成噪声,返回的文字要么是空的,要么是零星的字母。中文用 ChineseTextRecognizerOptions,它同时认中文和英文,不需要为英文单独加一个。
中文竖排或倾斜的文字识别不准
ML Kit 的文字识别是按行处理的,对竖排文字的切分不理想,倾斜太多也会影响。可以先用图像处理把图片转正(Line.getAngle() 能拿到倾斜角度),再重新识别一次。如果业务上确实有大量竖排中文的场景,ML Kit 不是一个好选择。
和在线接口怎么选
最后说一下我自己的取舍,供参考:
| 场景 | 建议 |
|---|---|
| 有网、对识别精度要求高、不在乎把图片传出去 | 用在线接口,中文场景下大厂的识别率还是更好 |
| 没网、网络不稳定、图片涉隐私 | 用 ML Kit 离线 |
| 识别量很大,不想按次付费 | 用 ML Kit 离线 |
| 机器完全没有 Google 组件 | 都别用 ML Kit,换 PaddleOCR 之类的纯本地方案 |
比较实用的做法是两套都留着:默认用离线的,省流量也保护隐私;识别结果明显不对时再让用户切到在线接口。两套的结果格式都统一成「每行一项」,界面层不需要区分是哪套识别的。
版权声明:本文为原创文章,版权归 Changbin's Blog 所有,允许非商业转载,转载请用链接注明出处。
本文地址:https://www.misterma.com/archives/970/
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。