天天看点

Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)

遇到一个需求,要用手机扫描纸质面单,获取面单上的手机号,最后决定用tesseract这个开源OCR库,移植到Android平台是tess-two

评论里有人想要我训练的数字字库,这里贴出来(只训练了 黑体、微软雅黑、宋体 0-9的数字,其他字体识别率会降低)

(现在上传资源好像不能免费下载了,至少要收两个积分….)

这篇博客主要是记录我的思路,大多是散乱的笔记,所以大家遇到报错什么的不要急,看看Log总能找到问题,接下来我也准备写一个library,直接封装好 手机号扫描、身份证扫描、邮箱扫描等,写好后我会更新

我遇到的坑(只想了解用法的可以跳过)

Tesseract虽然是个很强大的库,但直接使用的话,并不适用于连续识别的需求,因为tess-two对解析图像的清晰度和文字规范度有很高的要求,用相机随便获取的一张预览图扫出来错误率非常高(如果用电脑截图文字区域,识别很高),手写的就更不用说了,几乎全是乱码,而且识别速度很慢,一张200*300的图片都要好几秒

所以在没有优化的情况下,直接用tess-two 来作文字识别,只能是拍一张照,然后等待识别结果,比如识别文章、扫描身份证等,如果像我的需求,需要识别面单上的手机号,可能一分钟需要扫描几十个手机号,那就必须要达到毫秒级的解析速度,直接使用常规的方法肯定是不行的,那怎么办呢?

tess-two的识别算法当然是没办法处理了,那就得从其他方面去想办法

第一个:是在字库方面,官方的一个英文字库 30M,但是你面临的需求需要这么重量级的字库吗?比如我扫描手机号的功能,面单上都是黑体字,手机号只有纯数字, 就这么点识别范围去检索一个30M的字库,显然多了很多无用功 

解决办法就是: 

训练自己的字库,如果你需要毫秒级的扫描速度,那你的需求涉及的扫描内容 范围一定很小(前面说过,如果你要做文章识别之类的,那就用官方字库,拍一张照片,等几秒钟,完全是可以接受的),这样就可以根据需求范围内 常见的 ”字体“ 和 ”字符“来训练专门的字库,这样你就能使用一个轻量级的定制字库,极大的减少了解析时间,比如我手机号的数字子库,只有100KB,识别我处理后的图片,从官方字库的1.5-3秒,减少到了300-500ms

字库训练 详情参考​​javascript:void(0)​​

第二个: 就是在把图片交给tess-two解析之前,先进行简单的内容过滤,如上面所说的,即便是我把一张图片的解析速度压缩到了300-500ms,依然存在一个问题,那就是识别频率,要做连续扫描,相机肯定是一直开着的,那一秒钟几十帧的图片,你该解析哪一张呢? 

每一张都解析的话,对性能是很大的消耗,也要考虑一些用低端机的用户,而且每次解析的时间不等,识别结果也很混乱,那就只有每次取一帧解析,拿到解析结果后,再去解析下一帧

那么问题又来了:相机一秒几十帧,一打开相机,第一帧就开始解析了,这样下一次开始解析就在300-500ms之后了,如果用户在对准手机号的前一刻,正好开始了一帧画面的解析,那等到开始解析手机号,至少也在几百毫秒以后了,加上手机号本身的解析时间,从对准到拿到结果,随随便便就超过了1秒,加上每次识别速度不定,可能特殊情况耗时更久,这样必然会感到很明显的延迟,那该怎么处理呢? 

在图片交给tess-two之前,先进行图片二级裁切,第一次裁切就是利用界面的扫描框,拿到需要扫描的区域,然后进行内容过滤,把明显不可能包含手机号的图像直接忽略,不进行解析,这个过程需要遍历图片的像素,用jni处理时间不超过10ms,即便是用java处理,也只有10-50ms,只要能忽略大部分的无用的图像,那就解决了这个延迟的问题,并且在过滤的同时,如果被判断为有用图片,那就能同时拿到需要解析的文字块,然后进行第二次裁切,拿到更小的图片,进一步提升解析速度

至于过滤的方式,我写了针对手机号的过滤,在文章最下面的单行文本优化方案部分,有相似需求的可以看看,然后针对自己的需求,来写过滤算法

至于最后扫描的内容的提取,可以用正则公式来筛选关键信息如:手机号、网址、邮箱、身份证、银行卡号 等

Demo截图

图一

Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)

图二

Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)

图三

Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)

水印清除

图四

Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)

图五

Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)

图一:是扫描线没有对准手机号码,未捕捉到手机号的状态,这种状态下,每一帧都会在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();
    }
}      
  • 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
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);
                }       

继续阅读