Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)
身份证识别:https://github.com/wenchaosong/OCR_identify
遇到一个需求,要用手机扫描纸质面单,获取面单上的手机号,最后决定用tesseract这个开源OCR库,移植到Android平台是tess-two
Android平台tess-two地址:https://github.com/tesseract-ocr
本文Demo地址:http://blog.csdn.net/mr_sk/article/details/79077271
评论里有人想要我训练的数字字库,这里贴出来(只训练了 黑体、微软雅黑、宋体 0-9的数字,其他字体识别率会降低)
数字字库地址:http://download.csdn.net/download/mr_sk/10186145 (现在上传资源好像不能免费下载了,至少要收两个积分….)
这篇博客主要是记录我的思路,大多是散乱的笔记,所以大家遇到报错什么的不要急,看看Log总能找到问题,接下来我也准备写一个library,直接封装好 手机号扫描、身份证扫描、邮箱扫描等,写好后我会更新
我遇到的坑(只想了解用法的可以跳过)
Tesseract虽然是个很强大的库,但直接使用的话,并不适用于连续识别的需求,因为tess-two对解析图像的清晰度和文字规范度有很高的要求,用相机随便获取的一张预览图扫出来错误率非常高(如果用电脑截图文字区域,识别很高),手写的就更不用说了,几乎全是乱码,而且识别速度很慢,一张200*300的图片都要好几秒
所以在没有优化的情况下,直接用tess-two 来作文字识别,只能是拍一张照,然后等待识别结果,比如识别文章、扫描身份证等,如果像我的需求,需要识别面单上的手机号,可能一分钟需要扫描几十个手机号,那就必须要达到毫秒级的解析速度,直接使用常规的方法肯定是不行的,那怎么办呢?
tess-two的识别算法当然是没办法处理了,那就得从其他方面去想办法
第一个:是在字库方面,官方的一个英文字库 30M,但是你面临的需求需要这么重量级的字库吗?比如我扫描手机号的功能,面单上都是黑体字,手机号只有纯数字, 就这么点识别范围去检索一个30M的字库,显然多了很多无用功
解决办法就是:
训练自己的字库,如果你需要毫秒级的扫描速度,那你的需求涉及的扫描内容 范围一定很小(前面说过,如果你要做文章识别之类的,那就用官方字库,拍一张照片,等几秒钟,完全是可以接受的),这样就可以根据需求范围内 常见的 ”字体“ 和 ”字符“来训练专门的字库,这样你就能使用一个轻量级的定制字库,极大的减少了解析时间,比如我手机号的数字子库,只有100KB,识别我处理后的图片,从官方字库的1.5-3秒,减少到了300-500ms
第二个: 就是在把图片交给tess-two解析之前,先进行简单的内容过滤,如上面所说的,即便是我把一张图片的解析速度压缩到了300-500ms,依然存在一个问题,那就是识别频率,要做连续扫描,相机肯定是一直开着的,那一秒钟几十帧的图片,你该解析哪一张呢?
每一张都解析的话,对性能是很大的消耗,也要考虑一些用低端机的用户,而且每次解析的时间不等,识别结果也很混乱,那就只有每次取一帧解析,拿到解析结果后,再去解析下一帧那么问题又来了:相机一秒几十帧,一打开相机,第一帧就开始解析了,这样下一次开始解析就在300-500ms之后了,如果用户在对准手机号的前一刻,正好开始了一帧画面的解析,那等到开始解析手机号,至少也在几百毫秒以后了,加上手机号本身的解析时间,从对准到拿到结果,随随便便就超过了1秒,加上每次识别速度不定,可能特殊情况耗时更久,这样必然会感到很明显的延迟,那该怎么处理呢?
解决办法就是:
在图片交给tess-two之前,先进行图片二级裁切,第一次裁切就是利用界面的扫描框,拿到需要扫描的区域,然后进行内容过滤,把明显不可能包含手机号的图像直接忽略,不进行解析,这个过程需要遍历图片的像素,用jni处理时间不超过10ms,即便是用java处理,也只有10-50ms,只要能忽略大部分的无用的图像,那就解决了这个延迟的问题,并且在过滤的同时,如果被判断为有用图片,那就能同时拿到需要解析的文字块,然后进行第二次裁切,拿到更小的图片,进一步提升解析速度至于过滤的方式,我写了针对手机号的过滤,在文章最下面的单行文本优化方案部分,有相似需求的可以看看,然后针对自己的需求,来写过滤算法
至于最后扫描的内容的提取,可以用正则公式来筛选关键信息如:手机号、网址、邮箱、身份证、银行卡号 等
Demo截图
图一
图二
图三
水印清除
图四
图五
图一:是扫描线没有对准手机号码,未捕捉到手机号的状态,这种状态下,每一帧都会在10-30ms之内被确定扫描线没有对准一个手机号而被过滤掉,不交给tess-two解析,直接放弃这一帧数据
图二:是扫描线对准了手机号,经过过滤算法后,捕捉到一个包含11位字符的蚊子块,基本确认存在手机号
图三:是 图二 状态下的识别结果
图四:是被水印干扰的手机号所得到的二值化图片
图五:是清除水印后取到的手机号区域(只适用于图五这种文字底部的干扰)
tess-two基本使用
这里是基本用法,我最早写的,效率不高但代码易读,是tess-two的使用方法,识别还是有明显延迟,优化方案我放在了文章后面的优化部分,Demo也更新了最新的优化方案,如果对这方面比较熟练,可以从后面开始看,这里由简入繁
集成很简单,build.gradle中加入:
compile ‘com.rmtheis:tess-two:6.0.0’
//后面我已经换到8.0.0,上传的demo是在6.0.0下运行的
compile ‘com.rmtheis:tess-two:8.0.0’
编译一下,框架的集成就ok了,不过tess-two的文字库是需要另外下载的,我们一般只需要中文和英文两种就可以了,特殊需求可以自己训练
字体库下载地址:https://github.com/tesseract-ocr/tessdata
英文:eng.traineddata
简体中文:chi_sim.traineddata
将这两个字体库文件,放到sd卡,路径必须为 **/tessdata/
路径为什么一定要为**/tessdata/呢?在TessBaseApi类的初始化方法中会检查你的文字库目录,代码如下
/**
* datapath是你传入的文字库路径,可以看到这里在传入的datapath后加了一个"tessdata"目录
* 然后验证了这个目录是否存在,如果不在,就会报错"数据目录必须包含tessdata目录"
*/
File tessdata = new File(datapath + "tessdata");
//tessdata是否存在且是个目录
if (!tessdata.exists() || !tessdata.isDirectory())
throw new IllegalArgumentException("Data path must contain subfolder tessdata!");
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
然后就是使用了,这里我的字体库文件都放在 “根目录/Download/tessdata“中
解析图片代码如下:
public class OcrUtil {
//字体库路径,此路径下必须包含tessdata文件夹,但不用把tessdata写上
static final String TESSBASE_PATH = Environment.getExternalStorageDirectory() + File.separator + "Download" + File.separator;
//英文
static final String ENGLISH_LANGUAGE = "eng";
//简体中文
static final String CHINESE_LANGUAGE = "chi_sim";
/**
* 识别英文
*
* @param bmp 需要识别的图片
* @param callBack 结果回调(携带一个String 参数即可)
*/
public static void ScanEnglish(final Bitmap bmp, final MyCallBack callBack) {
new Thread(new Runnable() {
@Override
public void run() {
TessBaseAPI baseApi = new TessBaseAPI();
//初始化OCR的字体数据,TESSBASE_PATH为路径,ENGLISH_LANGUAGE指明要用的字体库(不用加后缀)
if (baseApi.init(TESSBASE_PATH, ENGLISH_LANGUAGE)) {
//设置识别模式
baseApi.setPageSegMode(TessBaseAPI.PageSegMode.PSM_AUTO);
//设置要识别的图片
baseApi.setImage(bmp);
//开始识别
String result = baseApi.getUTF8Text();
baseApi.clear();
baseApi.end();
callBack.response(result);
}
}
}).start();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
好了,识别工具写好了,接下要做的就是,打开相机、获取预览图、裁切出需要的区域,然后交给tess-two识别,这里我直接吧SurfaceView封装了一下,自动打开相机开始预览,下面是扫描手机号的代码:
public class CameraView extends SurfaceView implements SurfaceHolder.Callback, Camera.PreviewCallback {
private final String TAG = "CameraView";
private SurfaceHolder mHolder;
private Camera mCamera;
private boolean isPreviewOn;
//默认预览尺寸
private int imageWidth = 1920;
private int imageHeight = 1080;
//帧率
private int frameRate = 30;
public CameraView(Context context) {
super(context);
init();
}
public CameraView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mHolder = getHolder();
//设置SurfaceView 的SurfaceHolder的回调函数
mHolder.addCallback(this);
mHolder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
//Surface创建时开启Camera
openCamera();
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
//设置Camera基本参数
if (mCamera != null)
initCameraParams();
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
try {
release();
} catch (Exception e) {
}
}
private boolean isScanning = false;
/**
* Camera帧数据回调用
*/
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
//识别中不处理其他帧数据
if (!isScanning) {
isScanning = true;
new Thread(new Runnable() {
@Override
public void run() {
try {
//获取Camera预览尺寸
Camera.Size size = camera.getParameters().getPreviewSize();
//将帧数据转为bitmap
YuvImage image = new YuvImage(data, ImageFormat.NV21, size.width, size.height, null);
if (image != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
//将帧数据转为图片(new Rect()是定义一个矩形提取区域,我这里是提取了整张图片,然后旋转90度后再才裁切出需要的区域,效率会较慢,实际使用的时候,照片默认横向的,可以直接计算逆向90°时,left、top的值,然后直接提取需要区域,提出来之后再压缩、旋转 速度会快一些)
image.compressToJpeg(new Rect(0, 0, size.width, size.height), 80, stream);
Bitmap bmp = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size());
//这里返回的照片默认横向的,先将图片旋转90度
bmp = rotateToDegrees(bmp, 90);
//然后裁切出需要的区域,具体区域要和UI布局中配合,这里取图片正中间,宽度取图片的一半,高度这里用的适配数据,可以自定义
bmp = bitmapCrop(bmp, bmp.getWidth() / 4, bmp.getHeight() / 2 - (int) getResources().getDimension(R.dimen.x25), bmp.getWidth() / 2, (int) getResources().getDimension(R.dimen.x50));
if (bmp == null)
return;
//将裁切的图片显示出来(测试用,需要为CameraView setTag(ImageView))
ImageView imageView = (ImageView) getTag();
imageView.setImageBitmap(bmp);
stream.close();
//开始识别
OcrUtil.ScanEnglish(bmp, new MyCallBack() {
@Override
public void response(String result) {
//这是区域内扫除的所有内容
Log.d("scantest", "扫描结果: " + result);
//检索结果中是否包含手机号
Log.d("scantest", "手机号码: " + getTelnum(result));
isScanning = false;
}
});
}
} catch (Exception ex) {
isScanning = false;
}
}).start();
}
}
/**
* 获取字符串中的手机号
*/
public String getTelnum(String sParam) {
if (sParam.length() <= 0)
return "";
Pattern pattern = Pattern.compile("(1|861)(3|5|8)\\d{9}$*");
Matcher matcher = pattern.matcher(sParam);
StringBuffer bf = new StringBuffer();
while (matcher.find()) {
bf.append(matcher.group()).append(",");
}
int len = bf.length();
if (len > 0) {
bf.deleteCharAt(len - 1);
}
return bf.toString();
}
/**
* Bitmap裁剪
*
* @param bitmap 原图
* @param width 宽
* @param height 高
*/
public static Bitmap bitmapCrop(Bitmap bitmap, int left, int top, int width, int height) {
if (null == bitmap || width <= 0 || height < 0) {
return null;
}
int widthOrg = bitmap.getWidth();
int heightOrg = bitmap.getHeight();
if (widthOrg >= width && heightOrg >= height) {
try {
bitmap = Bitmap.createBitmap(bitmap, left, top, width, height);
} catch (Exception e) {
return null;
}
}
return bitmap;
}
/**
* 图片旋转
*
* @param tmpBitmap
* @param degrees
* @return
*/
public static Bitmap rotateToDegrees(Bitmap tmpBitmap, float degrees) {
Matrix matrix = new Matrix();
matrix.reset();
matrix.setRotate(degrees);
return Bitmap.createBitmap(tmpBitmap, 0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight(), matrix,
true);
}
/**
* 摄像头配置
*/
public void initCameraParams() {
stopPreview();
//获取camera参数
Camera.Parameters camParams = mCamera.getParameters();
List<Camera.Size> sizes = camParams.getSupportedPreviewSizes();
//确定前面定义的预览宽高是camera支持的,不支持取就更大的
for (int i = 0; i < sizes.size(); i++) {
if ((sizes.get(i).width >= imageWidth && sizes.get(i).height >= imageHeight) || i == sizes.size() - 1) {
imageWidth = sizes.get(i).width;
imageHeight = sizes.get(i).height;
//
break;
}
}
//设置最终确定的预览大小
camParams.setPreviewSize(imageWidth, imageHeight);
//设置帧率
camParams.setPreviewFrameRate(frameRate);
//启用参数
mCamera.setParameters(camParams);
mCamera.setDisplayOrientation(90);
//开始预览
startPreview();
}
/**
* 开始预览
*/
public void startPreview() {
try {
mCamera.setPreviewCallback(this);
mCamera.setPreviewDisplay(mHolder);//set the surface to be used for live preview
mCamera.startPreview();
mCamera.autoFocus(autoFocusCB);
} catch (IOException e) {
mCamera.release();
mCamera = null;
}
}
/**
* 停止预览
*/
public void stopPreview() {
if (mCamera != null) {
mCamera.setPreviewCallback(null);
mCamera.stopPreview();
}
}
/**
* 打开指定摄像头
*/
public void openCamera() {
Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
for (int cameraId = 0; cameraId < Camera.getNumberOfCameras(); cameraId++) {
Camera.getCameraInfo(cameraId, cameraInfo);
if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) {
try {
mCamera = Camera.open(cameraId);
} catch (Exception e) {
if (mCamera != null) {
mCamera.release();
mCamera = null;
}
}
break;
}
}
}
/**
* 摄像头自动聚焦
*/
Camera.AutoFocusCallback autoFocusCB = new Camera.AutoFocusCallback() {
public void onAutoFocus(boolean success, Camera camera) {
postDelayed(doAutoFocus, 1000);
}
};
private Runnable doAutoFocus = new Runnable() {
public void run() {
if (mCamera != null) {
try {
mCamera.autoFocus(autoFocusCB);
}