天天看點

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);
                }       

繼續閱讀