遇到一個需求,要用手機掃描紙質面單,擷取面單上的手機号,最後決定用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截圖
圖一

圖二
圖三
水印清除
圖四
圖五
圖一:是掃描線沒有對準手機号碼,未捕捉到手機号的狀态,這種狀态下,每一幀都會在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);
}