身份证识别: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

字库训练 详情参考https://www.cnblogs.com/cnlian/p/5765871.html


第二个: 就是在把图片交给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);
} catch (Exception e) {
}
}
}
}; /**
* 释放
*/
public void release() {
if (isPreviewOn && mCamera != null) {
isPreviewOn = false;
mCamera.setPreviewCallback(null);
mCamera.stopPreview();
mCamera.release();
mCamera = null;
}
} }
  • 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
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210
  • 211
  • 212
  • 213
  • 214
  • 215
  • 216
  • 217
  • 218
  • 219
  • 220
  • 221
  • 222
  • 223
  • 224
  • 225
  • 226
  • 227
  • 228
  • 229
  • 230
  • 231
  • 232
  • 233
  • 234
  • 235
  • 236
  • 237
  • 238
  • 239
  • 240
  • 241
  • 242
  • 243
  • 244
  • 245
  • 246
  • 247
  • 248
  • 249
  • 250
  • 251
  • 252
  • 253
  • 254
  • 255
  • 256
  • 257
  • 258
  • 259
  • 260
  • 261
  • 262
  • 263
  • 264
  • 265
  • 266
  • 267
  • 268
  • 269
  • 270
  • 271
  • 272
  • 273
  • 274
  • 275
  • 276

布局文件:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal">
<!--相机预览窗口,上面设置的预览大小是1080x1920,为保证比例,记得Activity的style不要加ActionBar,保证全屏显示-->
<test.com.ocrtest.CameraView
android:id="@+id/main_camera"
android:layout_width="match_parent"
android:layout_height="match_parent" /> <!--扫描框,和上面裁切规则一样,宽度为屏幕的一半,高度对应上面的x50(1080P分辨率下为168px)-->
<TextView
android:layout_width="@dimen/x160"
android:layout_height="@dimen/x50"
android:layout_centerInParent="true"
android:background="@drawable/fillet_gray_border_btn" /> <!--显示被裁切出的图片,需要setTag到CameraView中,详见上面CameraView代码-->
<ImageView
android:id="@+id/main_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true" /> </RelativeLayout>
  • 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

更新

对于文字识别这块,我之后还尝试了几种方案,这里列举一下

1、tess-two

适用场景:小区域连续扫描解析 (比如识别手机号、单词 等)

优点:免费开源、本地解析、英文数字识别率可观

缺点:识别速度慢、需要做大量优化(下面我会贴出我针对自己的项目做出的一些优化,避免解析大部分无意义的画面,二值化提高识别率等)


2、各个平台的OCR API,比如百度、腾讯、合合信息 等

适用场景:识别频率不高、需要识别大图(比如拍一张照,点确认,拿到结果,就OK了 像身份证 银行卡识别)

优点:识别率高

缺点: 收费(费用不高)、解析速度太依赖网络质量、无本地解析SDK,需要上传图片然后获取解析结果,因为不能每一帧都上传解析,所以不能用作连续扫描 
我之前尝试过百度ocr,方案是给用户一个按钮,用户点击之后,取相机最近的一帧照片上传给百度,然后跳过其他帧,等待用户下一次点击解析按钮。通过压缩裁切图片,我已经把图片压缩到10+kb、在网速良好的情况下,解析速度能达到0.5秒,但如果网速不好,体验急剧下降


3、有一些平台提供本地Ocr解析的SDK(比如合合信息)

适用场景:大部分需求都能实现

优点: 解析速度快、识别率高

缺点: 费用奇高 -_-(企业合作级别的费用)


对于tess-two的进一步优化(这里针对我的需求,只识别单行手机号):

文章开头说过了,提高效率最重要的就是训练出为自己需求量身定做的字库,我需要识别的面单上的手机号,全部是黑体的数字,那我就针对“黑体 数字”来训练我的字库,我训练出来的字库大小100+KB,识别优化后的手机号图片,只要300-500ms,再过滤掉大部分无意义图像,就可以实现连续扫描,而官方的包识别至少1.5-3秒,如果再无法过滤无意义图像,那识别一个手机号10秒钟能搞定你就谢天谢地了

训练方法在文章开头有链接,至于训练用的模板图片,文章最下面的优化代码中,把最终取到的图像保存下来去训练就好了

对于把图片交给tess-two之前的优化 
主要包括:减小图片的尺寸大小、二值化图片使文字黑白分明、判断图片内容是否无意义

1、裁切图片

根据上面文章的代码,是先把一帧的数据转为图片,然后旋转90°,然后根据扫描框在界面上的位置,裁切出需要的区域,如下

ByteArrayOutputStream stream = new ByteArrayOutputStream();
//将帧数据转为图片
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));
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

那么问题就在裁切和旋转的时候了,假如相机一帧的像素是1920*1080,而我需要的只是扫描框内的一点内容,把一整张图片都提取出来,加上旋转这张大图片,然后再裁切,无疑浪费了很多时间

解决办法:

直接计算逆向90°情况下的提取区域 
上边裁切范围是:屏幕正中间、宽度为屏幕的一半、高度为R.dimen.x50 的一个矩形 
那么矩形的位置就取图片正中间 
left=bmp.getWidth() / 4 
top=bmp.getHeight()/ 2 - R.dimen.x25 
width=bmp.getWidth() / 2 
height= R.dimen.x50

如果逆向90°,长宽倒转,矩形的位置就变成 
left=bmp.getWidth()/ 2 - R.dimen.x25 (原来的top) 
top=bmp.getHeight()/4 (原来的right) 
width=R.dimen.x50 (原来的height) 
height= bmp.getHeight()/2 (原来的width)

ByteArrayOutputStream stream = new ByteArrayOutputStream(); 
image.compressToJpeg(new Rect(left , top, left+width, top+height), 80, 
stream); 
Bitmap bmp = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size()); 
这样直接提取需要的区域,就节省了整张图片旋转和第二次裁切的时间

2、旋转、二值化 图片,过滤无用内容

接下来的旋转和二值化,是纯像素算法,如果能放在jni中实现更好,经过我测试效率会快好几倍(Java大概10-50ms,Jni基本在10ms以下,虽然几十毫秒的时间差,跟tess-two的解析时间比,效果不明显),这里还是用Java来表现逻辑

上面已经直接提取出了需要解析的矩形区域,接下来只需要旋转一张像素小了很多倍的图片 还是上面文章中的方法
rotateToDegrees(bmp, 90)

旋转之后,就是一张方向正确的识别区域了,现在需要做的就是二值化,将图片变为黑白两色,提高识别率(因为要遍历所有的像素,为了节省时间,在二值化的同时,同步进行无用内容过滤

无用内容过滤: 
如文章开头介绍,在相机打开之后,每一秒都有几十帧数据,什么时候解析呢?这里我做出了一些过滤

(下面的过滤算法,只适用于和我的需求类似的场景(扫描手机号、单行文本))

怎么过滤呢?先来想想场景,什么样的图片可以认为图中可能有手机号呢?

第一:手机号完整的在矩形区域内,不会有超出矩形区域的部分,也就是说手机号部分不会有贴边的像素

第二:如果要扫描手机号,肯定会将手机号至少填充扫描框的50%高度(这个比例自己掌握,看你的扫描距离,我后来减到了10%,捕捉手机号依然很准确) 
有了这两个条件,就有了判断标准,图片中必须有 上下左右没有贴边,且高度大于50%的有色区域,才能初步判断图中可能存在手机号码

然后我就实现方式,我的思路是: 
这里实现一个单行文字捕捉,首先准备 left、top、right、bottom 四个变量,就是最终需要的单行文字区域

1、先黑白化图片,这个过程需要遍历像素,在遍历期间,同时来做过滤,这里遍历是一行一行的,所以在第一次遍历中,能判断文字行数:比如在遍历某一行的像素时,只要发现一个黑色像素,说明这一行不是空行,那就记录一下这里已经有文字占了一行像素,下一行如果还是找到黑色像素,那就把当前记录的文字加一行像素高度,直到某一行全部是白色像素,说明这一行文字结束了,下面再有黑色像素就算是第二行文字了

2、如果第一行像素就发现了黑色像素点,说明这行文字是贴着文字上边缘的,八成是只露出了一半的文字,肯定不是解析对象,那就不用记录他,直到遇到一行全是白色像素,表示这行贴边的文字结束了,接下来的文字就要开始记录了(没错,如果有一条竖着的黑线,从上贯穿到下,那这个图片肯定被认为全是贴边文字,直接过滤掉,我的识别环境不会有这个情况,所以没有做更细致的过滤,需要判断这种情况的,自己写算法 -_-)

3、每一行文字记录结束都跟上一行文字比较,选高度更高的一行文字留下,其他的跳过(前面说了这里是单行识别,只选没有贴边的文字最高的一行),等遍历结束,最高的一行的top 和 bottom留下,就得到的解析对象的上下边缘

4、需要留意的是,上一个过滤贴边文字的条件,只过滤了超出上边缘的文字,那超出下边缘的文字呢?很简单,每行文字记录完成后才会和上一行比较,就是说每次遇到一整行白色像素的空白行时,才会更新top和bottom,如果最后一行贴边了那就不会再遇到空白行,自动就放弃了

下面在代码中解释细节

  /**
* 转为二值图像 并判断图像中是否可能有手机号
*
* @param bmp 原图bitmap
* @param tmp 二值化阈值 超出阈值的像素置为白色,否则为黑色
* @return
*/
public Bitmap convertToBMW(final Bitmap bmp, int tmp) {
int width = bmp.getWidth(); // 获取位图的宽
int height = bmp.getHeight(); // 获取位图的高
int[] pixels = new int[width * height]; // 通过位图的大小创建像素点数组
bmp.getPixels(pixels, 0, width, 0, 0, width, height);//得到图片的所有像素 int lineHeight = 0;//当前记录的一行文字已经累计的高度,每次遇到一行有黑色像素点时 +1 //目标行,每遇到一个黑色像素,就会+1,本行就不会在记录lineHeight,下一行在遇到黑色像素,就继续+1,保证每行lineHeight最多 +1 一次
int row = 0; //当前记录的一行文字是否超出边缘(如果第一行就发现黑色像素,就为true了,直到遇到空白行,还原false)
boolean isOutOfRect= false; //最终捕捉到的单行文字在图片中的矩形区域
int left = 0;
int top = 0;
int right = 0;
int bottom = 0; int alpha = 0xFF << 24;
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
int grey = pixels[width * i + j];
// 分离三原色
alpha = ((grey & 0xFF000000) >> 24);
int red = ((grey & 0x00FF0000) >> 16);
int green = ((grey & 0x0000FF00) >> 8);
int blue = (grey & 0x000000FF);
if (red > tmp) {
red = 255;
} else {
red = 0;
}
if (blue > tmp) {
blue = 255;
} else {
blue = 0;
}
if (green > tmp) {
green = 255;
} else {
green = 0;
}
pixels[width * i + j] = alpha << 24 | red << 16 | green << 8
| blue; //这里是二值化化的判断,if里是白色,else里是黑色
if (pixels[width * i + j] == -1 || (i == height - 1 && j == width - 1)) {
//将当前像素赋值为白色
pixels[width * i + j] = -1; /**
lineHeight>0 : 如果当前记录行的文字高度大于0
row == i : 当前是不是目标行,每行第一次发现黑色像素就会+1,所以只有当前行还没出现黑色像素时,才会 == i
j == width - 1 : 当前像素是不是本行的最后一个像素点
综上所述,这里的判断条件为 : 已经捕捉到一行文字,而且这一行已经结束了还没发现黑色像素,这行文字该结束了
*/
if (lineHeight > 0 && row == i && j == width - 1) {
//这行文字是不是超出边缘的文字,如果是,直接跳过,开始记录下一行
if (!isOutOfBorder) {
//跟上一行的文字高度比较,记录下高度更高的一行文字的top 和 bottom
int h = bottom - top;
if (lineHeight > h) {
//这里我把top 和 bottom 都加了1/4的行高,为了有一点留白,其实加不加无所谓
top = i - lineHeight - (lineHeight / 4);
bottom = i - 1 + (lineHeight / 4);
}
}
//这行文字既然已经结束了,下一行文字肯定不是超出边缘的了
isOutOfRect= false;
//上一行文字已经处理完成,行高归0,开始记录下一行
lineHeight = 0;
}
} else {
//这里是黑色像素,将当前像素点赋值为黑色
pixels[width * i + j] = -16777216;
//如果当前行 = 目标行(遇到这行第一个黑色像素就会+1,到下一行才会相等)
if (i >= row) {
//如果当前的黑色像素 位于第一行像素 或 最后一行像素,那就是超出边缘的文字
if (i == 0 || i == height - 1)
isOutOfRect= true;
//行高+1
lineHeight++;
//目标行转移到下一行
row = i + 1;
}
}
}
} /** 如果通过第一次过滤后,没有找到一行有意义的文字,或者找到了,文字高度占比还不到解析图片的20%,
那这张图片八成是无意义的图片,不用解析,直接下一帧(当你对着墙或者什么无聊的东西扫描的时候,
这里就会直接结束,不会浪费时间去做文字识别)
*/
if (bottom - top < height * 0.2f) {
isScanning = false;
return null;
} /**
到这里,上面的筛选已经通过了,我们已经定位到了一行目标文字的 top 和 bottom
接下来就要定位left 和 right 了
还是需要遍历一次,不过只需要 top-bottom 正中间的一行像素,思路同上,通过文字间距
来将这一行文字分成横向的几个文字块,至于区分条件,就看文字间的间隔,超过正常宽度就
算是一个文字块的结束,至于正常的文字间隔就要按需求而定了,比如这里扫描手机号,手机
号是11位的,那两个数字之间的距离说破天也不会超过图片宽度的 1/11,那我就定为1/11 那问题又来了,如果刚好手机号在这块图像右边的上半部分,下半部分是在手机号左边的无用文字,
只是因为高度重叠,上面取行高时被当成了一行,那这里只取top-bottom正中间的一条像素,
遍历到手机号所在的右边一半时,不是只能找到空白像素?
这就没办法了,只取一条像素行,一是为了减少耗时,二是让我的脑细胞少死一点,你要扫描手机号,
还非要把手机号完美躲开正中间,那我就不管了.....
*/ //文字间隔,每次遇到白色像素点+1,每次遇到黑色像素点归0,当space > 宽度的1/11时,就算超过正常文字间距了
int space = 0; //当前文字块宽度,每当遇黑色像素点时,更新宽度,space 超过宽度的1/11时,归0,文字块结束
int textWidth = 0;
//当前文字开始X坐标,文字块宽度 = 结束点 - startX
int startX = 0;
//遍历top-bottom 正中间一行像素
for (int j = 0; j < width; j++) {
//如果是白色像素
if (pixels[width * (top + (bottom - top) / 2) + j] == -1) {
/**
如果已经捕捉到了文字块,而且space > width / 11 或者已经遍历结束了,
那这个文字块的宽度就取到了
*/
if (textWidth > 0 && (space > width / 11 || j == width - 1)) {
//同高度一样,比较上一个文字块的宽度,留下最大的一个 top 和 bottom
if (textWidth > right - left) {
//这里取left 和 right 一样加了一个space/2的留白
left = j - space - textWidth - (space / 2);
right = j - 1;
}
//既然当前文字块已经结束,就把参数重置,继续捕捉下一个文字块
space = 0;
startX = 0;
}
space++;
} else {
//这里是黑色像素
//记录文字块的开始X坐标
if (startX == 0)
startX = j;
//文字块当前宽度
textWidth = j - startX;
//文字间隔归0
space = 0;
}
} //如果最终捕捉到的文字块,宽度还不到图片宽度的30%,同样跳过,八成不是手机号,就不要浪费时间识别了
if (right - left < width * 0.3f) {
isScanning = false;
return null;
} /**
到这里 已经捕捉到了一个很可能是手机号码的文字块,区域就是 left、top、right、bottom
把这个区域的像素,取出来放到一个新的像素数组
*/
int targetWidth = right - left;
int targetHeight = bottom - top;
int[] targetPixels = new int[targetWidth * targetHeight];
int index = 0;
for (int i = top; i < bottom; i++) {
for (int j = left; j < right; j++) {
if (index < targetPixels.length)
targetPixels[index] = pixels[width * i + j];
index++;
}
}
//销毁之前的图片
bmp.recycle(); // 新建图片
final Bitmap newBmp = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888);
//把捕捉到的图块,写进新的bitmap中
newBmp.setPixels(targetPixels, 0, targetWidth, 0, 0, targetWidth, targetHeight);
//将裁切的图片显示出来(测试用,需要为CameraView setTag(ImageView))
//主线程 {
// @Override
// public void run() {
// ImageView imageView = (ImageView) getTag();
// imageView.setVisibility(View.VISIBLE);
// imageView.setImageBitmap(newBmp);
// }
//}; //这里可以把这块图像保存到本地,可以做个按钮,点击时把saveBmp=true,就可以采集一张,采集几张之后,拿去做tesseract 训练,训练出适合自己需求的字库,才是提高效率的关键
// if (saveBmp) {
// saveBmp = false;
// ImageUtils.saveBitmap(scanBmp, System.currentTimeMillis() + ".jpg");
// }
//返回需要交给tess-two识别的内容
return newBmp;
}
  • 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
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210

更新

图1:捕捉到有 11 位字符的文字块,取到文字块的精准位置,交给tess-two解析

图2:捕捉到有 12 位字符的文字块,不符合手机号码特征,则不进行位置获取和内容识别,直接跳过

之前的算法还有一些缺陷,会有少数不符合手机号特征的文字块也被捕捉到了,我又换了一种算法,可以捕捉到文字块的精准位置,和包含多少个字符(字符数量不符合特征就也可以过滤掉,如上图2),且有一定的抗干扰能力(效果一般,主要解决我遇到的水印问题) 
这里先封装一个工具类,直接调用catchPhoneRect(bitmp,imageView)方法,即可获取一个只包含手机号的精准bitmap,如果返回null,表示没有发现符合手机号特征的文字块(这里捕获时,是先取图片中间一行的像素来初步判断手机号位置,所以UI上需要一条中间线类辅助扫描,如上图1) 
我遇到的水印问题:有些面单上的手机号,会被一种免单编号的水印遮住底边,手机号还是能看清楚,但是少数数字的底部被水印连在了一起,导致tesseract 无法识别 
这里解决办法就是:通过递归算法,获取每一个字符的精准位置,在获取位置的过程中,如果发现宽度或高度延伸到了不合理的范围,即视为被水印干扰的字符,先跳过这个字符,继续捕捉下一个,直到捕捉到一个没有发现干扰的字符,就可以确定这个文字块中每个字符的正确宽高,这时从头再遍历一次,根据正确的宽高范围来清除水印部分像素

public class TesseractUtil {
private static TesseractUtil mTesseractUtil = null;
private float proportion = 0.5f; private TesseractUtil() { } public static TesseractUtil getInstance() {
if (mTesseractUtil == null)
synchronized (TesseractUtil.class) {
if (mTesseractUtil == null)
mTesseractUtil = new TesseractUtil();
}
return mTesseractUtil;
} /**
* 调整阈值
*
* @param pro 调整比例
*/
public int adjustThresh(float pro) {
this.proportion += pro; if (proportion > 1f)
proportion = 1f;
if (proportion < 0)
proportion = 0;
return (int) (proportion * 100);
} /**
* 识别数字
*
* @param bmp 需要识别的图片
* @param callBack 结果回调
*/
public void scanNumber(final Bitmap bmp, final SimpleCallBack callBack) {
if (checkTesseract()) {
TessBaseAPI baseApi = new TessBaseAPI();
//初始化OCR的字体数据(Constants.BASE_PATH为字库所在路径,Constants.NUMBER_LANGUAGE为字库文件名(不加后缀))
if (baseApi.init(Constants.BASE_PATH, Constants.NUMBER_LANGUAGE)) {
//设置识别模式(单行识别)
baseApi.setPageSegMode(TessBaseAPI.PageSegMode.PSM_SINGLE_LINE);
//设置要识别的图片
baseApi.setImage(bmp);
baseApi.setVariable(TessBaseAPI.VAR_SAVE_BLOB_CHOICES, TessBaseAPI.VAR_TRUE);
//开始识别
String result = baseApi.getUTF8Text();
baseApi.clear();
baseApi.end();
bmp.recycle();
callBack.response(result);
} }
} private void showImage(final Bitmap bmp, final ImageView imageView) {
//显示当前图片处理进度(测试用)
MainThread.getInstance().
execute(new Runnable() {
@Override
public void run() {
imageView.setVisibility(View.VISIBLE);
imageView.setImageBitmap(bmp);
}
});
} //白色色值
private final int PX_WHITE = -1;
//黑色色值
private final int PX_BLACK = -16777216;
//占位色值(这个算法加入了排除干扰的模式,如果在捕捉一个文字的位置时,发现文字的宽度或者高度超出了正常高度,则很有可能这里被水印之类的干扰了,那就把超出正常的范围像素色值变成-2,颜色和白色很接近,会被当作背景色,相当于清除了干扰,不直接变成-1是为了在其他数字被误判为干扰水印时,可以还原)
private final int PX_UNKNOW = -2; /**
* 转为二值图像 并判断图像中是否可能有手机号
*
* @param bmp 原图bitmap
* @param imageView 显示当前图片处理进度,测试用
* @return
*/
public Bitmap catchPhoneRect(final Bitmap bmp, ImageView imageView) {
int width = bmp.getWidth(); // 获取位图的宽
int height = bmp.getHeight(); // 获取位图的高
int[] pixels = new int[width * height]; // 通过位图的大小创建像素点数组
bmp.getPixels(pixels, 0, width, 0, 0, width, height);
int left = width;
int top = height;
int right = 0;
int bottom = 0; //计算阈值
measureThresh(pixels, width, height); /**
* 二值化
* */
binarization(pixels, width, height); int space = 0;
int textWidth = 0;
int startX = 0;
int centerY = height / 2 - 1;
int textLength = 0;
int textStartX = 0; /**
* 遍历中间一行像素,粗略捕捉手机号
* 在扫描框中定义了一条中心线,如果每次扫描使用中心线来对准手机号,那么捕捉手机号的速度和准确度都有了很大的提高
* 实现逻辑:先对从帧数据中裁切好的图片进行二值化,然后取最中间一行的像素遍历,初步判断是否可能含有手机号
* 即遍历这一行时,每次遇到一段连续黑色像素,就记录一次textLength++(没一段黑色像素代表一个笔画的宽度),手机号都是11位数字
* 由此可以得知,合理的笔画范围是 最少:11111111111 ,拦腰遍历,会得到11个笔画宽度,textLength=11
* 最多:00000000000 ,拦腰遍历,会得到22个笔画宽度,textLength=22
* 也就是说,最中间一行遍历完成如果 textLength>11 && textLength<22 表示有一定可能有手机号存在(同时得到文字块的left、right),否则一定不存在,直接跳过,解析下一帧
* */
for (int j = 0; j < width; j++) { if (pixels[width * centerY + j] == PX_WHITE) {
//白色像素,如果发现了连续黑色像素,到这里出现第一个白色像素,那么一个笔画宽度就确认了,textLength++
if (space == 1)
textLength++;
if (textWidth > 0 && startX > 0 && startX < height - 1 && (space > width / 10 || j == width - 1)) {
//如果捕捉到的合理的比划截面,就更新left、right ,如果出现了多个合理的文字块,就取宽度最大的
if (textLength > 10 && textLength < 22)
if (textWidth > right - left) { left = j - space - textWidth - (space / 2);
if (left < 0)
left = 0; right = j - 1 - (space / 2);
if (right > width)
right = width - 1;
textStartX = startX;
}
textLength = 0;
space = 0;
startX = 0;
}
space++;
} else {
//一段连续黑色像素的开始坐标
if (startX == 0)
startX = j;
//文字块的宽度
textWidth = j - startX; space = 0;
} } //如果宽度占比过小,直接跳过
if (right - left < width * 0.3f) {
if (imageView != null) {
bmp.setPixels(pixels, 0, width, 0, 0, width, height);
//将裁切的图片显示出来
showImage(bmp, imageView);
} else
bmp.recycle();
return null;
} /**
*粗略计算文字高度
*这里先粗略取一块高度,确定包含文字,现在已经得知了文字块宽度,那么合理的字符宽度就是 (right - left) / 11,数字通常高度更大,这里就算宽度的1.5倍,然后为了确保包含文字,在中间线的上下各加一个文字高度
*接下来就要捕捉文字块的具体信息了,包括精准的宽度、高度 以及 字符数量
*/
top = (int) (centerY - (right - left) / 11 * 1.5);
bottom = (int) (centerY + (right - left) / 11 * 1.5);
if (top < 0)
top = 0;
if (bottom > height)
bottom = height - 1; /**
* 判断区域中有几个字符
* */
//已经使用过的像素标记
int[] usedPixels = new int[width * height];
int[] textRect = new int[]{right, bottom, 0, 0};
//当前捕捉文字的rect
int[] charRect = new int[]{textStartX, centerY, 0, centerY};
//在文字块中捕捉到的字符个数
int charCount = 0;
//是否发现干扰
boolean hasStain = false;
startX = left;
int charMaxWidth = (right - left) / 11;
int charMaxHeight = (int) ((right - left) / 11 * 1.5);
int charWidth = 0;//捕获到一个完整字符后得到标准的字符宽度
boolean isInterfereClearing = false;
//循环获取每一个字符的宽高位置
while (true) {
//当前字符的宽高是否正常
boolean isNormal = false;
//是否已经清除干扰,如果在捕捉字符宽高的过程中,发现有水印干扰,会调用clearInterfere()方法,将超出宽高的像素部分置为-2,然后继续捕捉下一个字符
if (!isInterfereClearing){
//如果被水印干扰,在进行递归算法的过程中,高度或宽度会超出正常范围,这里会返回false,如果是没有水印的干净文字块,不会触发else(方法实现在下面)
isNormal = catchCharRect(pixels, usedPixels, charRect, width, height, charMaxWidth, charMaxHeight, charRect[0], charRect[1]);
}else
isNormal = clearInterfere(pixels, usedPixels, charRect, width, height, charWidth, charWidth, charRect[0], charRect[1]);
//记录已经捕捉的字符数量
charCount++; if (!isNormal) {
//如果第一个字符发现干扰,这里记录有干扰,然后捕捉下一个字符,如果还是有干扰,继续捕捉下一个,直到找到一个正常的字符,那就可以得到一个字符的精准宽度,和高度,然后根据这个正确的宽高,从头再遍历一次,这个就可以把超出这个宽高的像素置为-2
hasStain = true;
if (charWidth != 0) {
usedPixels = new int[width * height];
charRect = new int[]{textStartX, centerY, 0, centerY};
charCount = 0;
isInterfereClearing = true;
}
} else {
//到这里就表示:发现了干扰,而且已经捕捉到一个正确的字符宽高,可以开始清除水印了
if (hasStain && !isInterfereClearing) {
//把位置还原,重新开始遍历,这次一边获取宽高,一边清除水印
usedPixels = new int[width * height];
charWidth = charRect[3] - charRect[1];
charRect = new int[]{textStartX, centerY, 0, centerY};
charCount = 0;
isInterfereClearing = true;
continue;
} else {
if (charWidth == 0) {
charWidth = charRect[3] - charRect[1];
}
//如果没有发现干扰,直接更新文字块的精准位置
if (textRect[0] > charRect[0])
textRect[0] = charRect[0]; if (textRect[1] > charRect[1])
textRect[1] = charRect[1]; if (textRect[2] < charRect[2])
textRect[2] = charRect[2]; if (textRect[3] < charRect[3])
textRect[3] = charRect[3];
}
}
//是否找到下一个字符
boolean isFoundChar = false;
if (!hasStain || isInterfereClearing) {
//获取下一个字符的rect开始点(如果上一个字符的宽高正常这里就可以直接找到下一个字符的起始坐标)
for (int x = charRect[2] + 1; x <= right; x++)
if (pixels[width * centerY + x] != PX_WHITE) {
isFoundChar = true;
charRect[0] = x;
charRect[1] = centerY;
charRect[2] = 0;
charRect[3] = 0;
break;
}
} else {
//如果发现干扰,那么可能还没有得到合理的宽高,还是用开头获取笔画的方式,寻找下一个字符开始捕获的坐标,
for (int x = left; x <= right; x++)
if (pixels[width * centerY + x] != PX_WHITE && pixels[width * centerY + x - 1] == PX_WHITE) {
if (x <= startX)
continue;
startX = x;
isFoundChar = true;
charRect[0] = x;
charRect[1] = centerY;
charRect[2] = x;
charRect[3] = centerY;
break;
}
}
if (!isFoundChar) {
break;
}
} //得到文字块的精准位置
left = textRect[0];
top = textRect[1];
right = textRect[2];
bottom = textRect[3]; //如果高度合理,且捕捉到的字符数量为11个,那么基本可以确定,这是一个11位的文字块,否则跳过,解析下一帧
if (bottom - top > (right - left) / 5 || bottom - top == 0 || charCount != 11) {
if (imageView != null) {
bmp.setPixels(pixels, 0, width, 0, 0, width, height);
//将裁切的图片显示出来
showImage(bmp, imageView);
} else
bmp.recycle();
return null;
} /**
* 将最终捕捉到的手机号区域像素提取到新的数组
* */
int targetWidth = right - left;
int targetHeight = bottom - top;
int[] targetPixels = new int[targetWidth * targetHeight];
int index = 0; for (int i = top; i < bottom; i++) {
for (int j = left; j < right; j++) {
if (index < targetPixels.length) {
if (pixels[width * i + j] == PX_WHITE)
targetPixels[index] = PX_WHITE;
else
targetPixels[index] = PX_BLACK;
}
index++;
}
} bmp.recycle();
// 新建图片
final Bitmap newBmp = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888);
newBmp.setPixels(targetPixels, 0, targetWidth, 0, 0, targetWidth, targetHeight);
//将裁切的图片显示出来
if (imageView != null )
showImage(newBmp, imageView); return newBmp;
} private final int MOVE_LEFT = 0;
private final int MOVE_TOP = 1;
private final int MOVE_RIGHT = 2;
private final int MOVE_BOTTOM = 3; /**
* 捕捉字符
* 这里用递归的算法,从字符的第一个黑色像素,开始,分别进行上下左右的捕捉,如果相邻的像素,也是黑色,就可以扩大这个字符的定位,以此类推,最后得到的就是字符的准确宽高
* 这里之所以没有用递归,而是用循环,是因为递归嵌套的层级太多,会导致 栈溢出
*/
private boolean catchCharRect(int[] pixels, int[] used, int[] charRect, int width, int height, int maxWidth, int maxHeight, int x, int y) {
int nowX = x;
int nowY = y;
//记录动作()
Stack<Integer> stepStack = new Stack<>(); while (true) {
if (used[width * nowY + nowX] == 0) {
used[width * nowY + nowX] = -1;
if (charRect[0] > nowX)
charRect[0] = nowX; if (charRect[1] > nowY)
charRect[1] = nowY; if (charRect[2] < nowX)
charRect[2] = nowX; if (charRect[3] < nowY)
charRect[3] = nowY; if (charRect[2] - charRect[0] > maxWidth) {
return false;
} if (charRect[3] - charRect[1] > maxHeight) {
return false;
} if (nowX == 0 || nowX >= width - 1 || nowY == 0 || nowY >= height - 1) {
return false;
} } //当前像素的左边是否还有黑色像素点
int leftX = nowX - 1;
if (leftX >= 0 && pixels[width * nowY + leftX] != PX_WHITE && used[width * nowY + leftX] == 0) {
nowX = leftX;
stepStack.push(MOVE_LEFT);
continue;
} //当前像素的上边是否还有黑色像素点
int topY = nowY - 1;
if (topY >= 0 && pixels[width * topY + nowX] != PX_WHITE && used[width * topY + nowX] == 0) {
nowY = topY;
stepStack.push(MOVE_TOP);
continue;
} //当前像素的右边是否还有黑色像素点
int rightX = nowX + 1;
if (rightX < width && pixels[width * nowY + rightX] != PX_WHITE && used[width * nowY + rightX] == 0) {
nowX = rightX;
stepStack.push(MOVE_RIGHT);
continue;
} //当前像素的下边是否还有黑色像素点
int bottomY = nowY + 1;
if (bottomY < height && pixels[width * bottomY + nowX] != PX_WHITE && used[width * bottomY + nowX] == 0) {
nowY = bottomY;
stepStack.push(MOVE_BOTTOM);
continue;
} //用循环模拟递归,当一个像素的周围,没有发现未记录的黑色像素,就可以退回上一步,最终效果就和递归一样了,而且不会引起栈溢出
if (stepStack.size() > 0) {
int step = stepStack.pop();
switch (step) {
case MOVE_LEFT:
nowX++;
break;
case MOVE_RIGHT:
nowX--;
break;
case MOVE_TOP:
nowY++;
break;
case MOVE_BOTTOM:
nowY--;
break;
}
} else {
break;
}
}
if (charRect[2] - charRect[0] == 0 || charRect[3] - charRect[1] == 0) {
return false;
}
return true;
} /**
* 清除干扰
* 和catchCharRect()方法原理一样,但是多了一个逻辑,即超出正常范围的黑色像素,会被当作干扰,置为-2,这一步会导致有些被干扰连在一起的多个字符都被清空,所以在捕捉其他字符时,当发现没有超出范围,又被置为-2的像素,就还原为黑色,这样最终就能实现大部分的水印被清除(只针对我遇到的文字底部的水印)
*/
private final int WAIT_HANDLE = 0;//待处理像素
private final int HANDLED = -1;//已处理像素
private final int HANDLING = -2;//处理过但未处理完成的像素 /**
* 清除干扰
*/
private boolean clearInterfere(int[] pixels, int[] used, int[] charRect, int width, int height, int maxWidth, int maxHeight, int x, int y) {
int nowX = x;
int nowY = y;
//记录动作
Stack<Integer> stepStack = new Stack<>();
boolean needReset = true;
while (true) {
if (used[width * nowY + nowX] == WAIT_HANDLE) {
used[width * nowY + nowX] = HANDLED; if (charRect[2] - charRect[0] <= maxWidth && charRect[3] - charRect[1] <= maxHeight) {
if (charRect[0] > nowX)
charRect[0] = nowX; if (charRect[1] > nowY)
charRect[1] = nowY; if (charRect[2] < nowX)
charRect[2] = nowX; if (charRect[3] < nowY)
charRect[3] = nowY;
} else {
if (needReset)
needReset = false;
used[width * nowY + nowX] = HANDLING;
pixels[width * nowY + nowX] = PX_UNKNOW;
}
} else if (pixels[width * nowY + nowX] == PX_UNKNOW) { if (charRect[2] - charRect[0] <= maxWidth && charRect[3] - charRect[1] <= maxHeight) {
pixels[width * nowY + nowX] = PX_BLACK;
if (charRect[0] > nowX)
charRect[0] = nowX; if (charRect[1] > nowY)
charRect[1] = nowY; if (charRect[2] < nowX)
charRect[2] = nowX; if (charRect[3] < nowY)
charRect[3] = nowY; used[width * nowY + nowX] = HANDLED;
} else {
if (needReset)
needReset = false;
}
} //当前像素的左边是否还有黑色像素点
int leftX = nowX - 1;
int leftIndex = width * nowY + leftX;
if (leftX >= 0 && pixels[leftIndex] != PX_WHITE && (used[leftIndex] == WAIT_HANDLE || (needReset && used[leftIndex] == HANDLING))) {
nowX = leftX;
stepStack.push(MOVE_LEFT);
continue;
} //当前像素的上边是否还有黑色像素点
int topY = nowY - 1;
int topIndex = width * topY + nowX;
if (topY >= 0 && pixels[topIndex] != PX_WHITE && (used[topIndex] == WAIT_HANDLE || (needReset && used[topIndex] == HANDLING))) {
nowY = topY;
stepStack.push(MOVE_TOP);
continue;
} //当前像素的右边是否还有黑色像素点
int rightX = nowX + 1;
int rightIndex = width * nowY + rightX;
if (rightX < width && pixels[rightIndex] != PX_WHITE && (used[rightIndex] == WAIT_HANDLE || (needReset && used[rightIndex] == HANDLING))) {
nowX = rightX;
stepStack.push(MOVE_RIGHT);
continue;
} //当前像素的下边是否还有黑色像素点
int bottomY = nowY + 1;
int bottomIndex = width * bottomY + nowX;
if (bottomY < height && pixels[bottomIndex] != PX_WHITE && (used[bottomIndex] == WAIT_HANDLE || (needReset && used[bottomIndex] == HANDLING))) {
nowY = bottomY;
stepStack.push(MOVE_BOTTOM);
continue;
} if (stepStack.size() > 0) {
int step = stepStack.pop();
switch (step) {
case MOVE_LEFT:
nowX++;
break;
case MOVE_RIGHT:
nowX--;
break;
case MOVE_TOP:
nowY++;
break;
case MOVE_BOTTOM:
nowY--;
break;
}
} else {
break;
}
}
return true;
} private int redThresh = 130;
private int blueThresh = 130;
private int greenThresh = 130; /**
* 计算扫描线所在像素行的平均阈值
* 我找了一些自动计算阈值的算法,都不太好用,这里就直接采集了中间一行像素的平均值
*/
private void measureThresh(int[] pixels, int width, int height) {
int centerY = height / 2; int redSum = 0;
int blueSum = 0;
int greenSum = 0;
for (int j = 0; j < width; j++) {
int gray = pixels[width * centerY + j];
redSum += ((gray & 0x00FF0000) >> 16);
blueSum += ((gray & 0x0000FF00) >> 8);
greenSum += (gray & 0x000000FF);
} redThresh = (int) (redSum / width * 1.5f * proportion);
blueThresh = (int) (blueSum / width * 1.5f * proportion);
greenThresh = (int) (greenSum / width * 1.5f * proportion);
} /**
* 二值化
*/
private void binarization(int[] pixels, int width, int height) {
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
int gray = pixels[width * i + j];
pixels[width * i + j] = getColor(gray);
if (pixels[width * i + j] != PX_WHITE)
pixels[width * i + j] = PX_BLACK;
}
} } /**
* 获取颜色
*/
private int getColor(int gray) {
int alpha = 0xFF << 24;
// 分离三原色
alpha = ((gray & 0xFF000000) >> 24);
int red = ((gray & 0x00FF0000) >> 16);
int green = ((gray & 0x0000FF00) >> 8);
int blue = (gray & 0x000000FF);
if (red > redThresh) {
red = 255;
} else {
red = 0;
}
if (blue > blueThresh) {
blue = 255;
} else {
blue = 0;
}
if (green > greenThresh) {
green = 255;
} else {
green = 0;
}
return alpha << 24 | red << 16 | green << 8
| blue;
} }
  • 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
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210
  • 211
  • 212
  • 213
  • 214
  • 215
  • 216
  • 217
  • 218
  • 219
  • 220
  • 221
  • 222
  • 223
  • 224
  • 225
  • 226
  • 227
  • 228
  • 229
  • 230
  • 231
  • 232
  • 233
  • 234
  • 235
  • 236
  • 237
  • 238
  • 239
  • 240
  • 241
  • 242
  • 243
  • 244
  • 245
  • 246
  • 247
  • 248
  • 249
  • 250
  • 251
  • 252
  • 253
  • 254
  • 255
  • 256
  • 257
  • 258
  • 259
  • 260
  • 261
  • 262
  • 263
  • 264
  • 265
  • 266
  • 267
  • 268
  • 269
  • 270
  • 271
  • 272
  • 273
  • 274
  • 275
  • 276
  • 277
  • 278
  • 279
  • 280
  • 281
  • 282
  • 283
  • 284
  • 285
  • 286
  • 287
  • 288
  • 289
  • 290
  • 291
  • 292
  • 293
  • 294
  • 295
  • 296
  • 297
  • 298
  • 299
  • 300
  • 301
  • 302
  • 303
  • 304
  • 305
  • 306
  • 307
  • 308
  • 309
  • 310
  • 311
  • 312
  • 313
  • 314
  • 315
  • 316
  • 317
  • 318
  • 319
  • 320
  • 321
  • 322
  • 323
  • 324
  • 325
  • 326
  • 327
  • 328
  • 329
  • 330
  • 331
  • 332
  • 333
  • 334
  • 335
  • 336
  • 337
  • 338
  • 339
  • 340
  • 341
  • 342
  • 343
  • 344
  • 345
  • 346
  • 347
  • 348
  • 349
  • 350
  • 351
  • 352
  • 353
  • 354
  • 355
  • 356
  • 357
  • 358
  • 359
  • 360
  • 361
  • 362
  • 363
  • 364
  • 365
  • 366
  • 367
  • 368
  • 369
  • 370
  • 371
  • 372
  • 373
  • 374
  • 375
  • 376
  • 377
  • 378
  • 379
  • 380
  • 381
  • 382
  • 383
  • 384
  • 385
  • 386
  • 387
  • 388
  • 389
  • 390
  • 391
  • 392
  • 393
  • 394
  • 395
  • 396
  • 397
  • 398
  • 399
  • 400
  • 401
  • 402
  • 403
  • 404
  • 405
  • 406
  • 407
  • 408
  • 409
  • 410
  • 411
  • 412
  • 413
  • 414
  • 415
  • 416
  • 417
  • 418
  • 419
  • 420
  • 421
  • 422
  • 423
  • 424
  • 425
  • 426
  • 427
  • 428
  • 429
  • 430
  • 431
  • 432
  • 433
  • 434
  • 435
  • 436
  • 437
  • 438
  • 439
  • 440
  • 441
  • 442
  • 443
  • 444
  • 445
  • 446
  • 447
  • 448
  • 449
  • 450
  • 451
  • 452
  • 453
  • 454
  • 455
  • 456
  • 457
  • 458
  • 459
  • 460
  • 461
  • 462
  • 463
  • 464
  • 465
  • 466
  • 467
  • 468
  • 469
  • 470
  • 471
  • 472
  • 473
  • 474
  • 475
  • 476
  • 477
  • 478
  • 479
  • 480
  • 481
  • 482
  • 483
  • 484
  • 485
  • 486
  • 487
  • 488
  • 489
  • 490
  • 491
  • 492
  • 493
  • 494
  • 495
  • 496
  • 497
  • 498
  • 499
  • 500
  • 501
  • 502
  • 503
  • 504
  • 505
  • 506
  • 507
  • 508
  • 509
  • 510
  • 511
  • 512
  • 513
  • 514
  • 515
  • 516
  • 517
  • 518
  • 519
  • 520
  • 521
  • 522
  • 523
  • 524
  • 525
  • 526
  • 527
  • 528
  • 529
  • 530
  • 531
  • 532
  • 533
  • 534
  • 535
  • 536
  • 537
  • 538
  • 539
  • 540
  • 541
  • 542
  • 543
  • 544
  • 545
  • 546
  • 547
  • 548
  • 549
  • 550
  • 551
  • 552
  • 553
  • 554
  • 555
  • 556
  • 557
  • 558
  • 559
  • 560
  • 561
  • 562
  • 563
  • 564
  • 565
  • 566
  • 567
  • 568
  • 569
  • 570
  • 571
  • 572
  • 573
  • 574
  • 575
  • 576
  • 577
  • 578
  • 579
  • 580
  • 581
  • 582
  • 583
  • 584
  • 585
  • 586
  • 587
  • 588
  • 589
  • 590
  • 591
  • 592
  • 593
  • 594
  • 595
  • 596
  • 597
  • 598
  • 599
  • 600
  • 601
  • 602
  • 603
  • 604
  • 605
  • 606
  • 607
  • 608
  • 609
  • 610
  • 611
  • 612
  • 613
  • 614
  • 615
  • 616
  • 617
  • 618
  • 619
  • 620
  • 621
  • 622
  • 623
  • 624
  • 625
  • 626
  • 627
  • 628
  • 629
  • 630
  • 631
  • 632
  • 633
  • 634
  • 635
  • 636
  • 637
  • 638
  • 639
  • 640
  • 641
 

Android OCR文字识别 实时扫描手机号(极速扫描单行文本方案)的更多相关文章

  1. 有什么OCR文字识别软件好用?

    OCR文字识别是指:对文本资料进行扫描,然后对图像文件进行分析处理,最后获取文字以及版面信息的过程.对于许多学生党而言,一款好用的文字识别软件,能节省很多抄笔记的时间,而对于许多处理文字内容的白领而言 ...

  2. 对OCR文字识别软件的扫描选项怎么设置

    说到OCR文字识别软件,越来越多的人选择使用ABBYY FineReader识别和转换文档,然而并不是每个人都知道转换质量取决于源图像的质量和所选的扫描选项,今天就给大家普及一下这方面的知识. ABB ...

  3. 百度OCR 文字识别 Android安全校验

    百度OCR接口使用总结: 之前总结一下关于百度OCR文字识别接口的使用步骤(Android版本 不带包名配置 安全性弱).这边博客主要介绍,百度OCR文字识别接口,官方推荐使用方式,授权文件(安全模式 ...

  4. 百度OCR文字识别-Android安全校验

    本文转载自好基友upuptop:https://blog.csdn.net/pyfysf/article/details/86438769 效果图: 如下为文章正文: 百度OCR接口使用总结:之前总结 ...

  5. 云+社区分享——腾讯云OCR文字识别

    欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由云+社区运营团队发布在腾讯云+社区 前言 2018年3月27日腾讯云云+社区联合腾讯云智能图像团队共同在客户群举办了腾讯云OCR文字识 ...

  6. 如何精准实现OCR文字识别?

    欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由云计算基础发表于云+社区专栏 前言 2018年3月27日腾讯云云+社区联合腾讯云智能图像团队共同在客户群举办了腾讯云OCR文字识别-- ...

  7. 怎么给OCR文字识别软件重编文档页面号码

    ABBYY FineReader Pro for Mac OCR文字识别软件处理文档时,在FineReader文档中,页面的加载顺序即是页面的导入顺序,完成导入之后,文档的所有页面均会被编号,各编号会 ...

  8. OCR文字识别软件许可文件被误删了怎么办

    使用任何一款软件,都会有误操作的情况发生,比如清理文件时一不小心删除了许可文件,对于ABBYY FineReader 12这样一款OCR文字识别软件,因失误错误删除了许可文件该怎么办呢?今天就来给大家 ...

  9. 怎么给OCR文字识别软件设置正确的扫描分辨率

    ABBYY FineReader 12是一款专业的OCR文字识别软件,可快速方便地将扫描纸质文档.PDF文件和数码相机的图像转换成可编辑.可搜索的文本,不仅支持对页扫描,还支持多页扫描,扫描分辨率的选 ...

随机推荐

  1. python学习笔记~INI、REG文件读取函数(自动修复)

    引入configparser,直接read整个INI文件,再调用get即可.但需要注意的是,如果INI文件本身不太规范,就会报各种错,而这又常常不可避免的.本文自定义函数通过try...except. ...

  2. pyhton 下 使用getch(), 输入字符无需回车

    原代码来自 https://code.activestate.com/recipes/134892-getch-like-unbuffered-character-reading-from-stdin ...

  3. HttpClient的Content-Type设置

    HttpClient的Content-Type设置 最近在对接公司内容的一个云服务的时候,遇到一个问题,就是如果使用HttpClient如何设置post时候的Content-Type? public ...

  4. SpringBoot入门(五)——自定义配置

    本文来自网易云社区 大部分比萨店也提供某种形式的自动配置.你可以点荤比萨.素比萨.香辣意大利比萨,或者是自动配置比萨中的极品--至尊比萨.在下单时,你并没有指定具体的辅料,你所点的比萨种类决定了所用的 ...

  5. Putty远程连接Ubuntu14.04

    步骤一.在ubuntu系统中安装ssh,可使用如下的命令进行安装: sudo apt-get install openssh-server 步骤二.为了保险起见,安装完成后重启一下ssh服务,命令如下 ...

  6. linux初学体会

    第一篇随笔,其实是为了写作业,可是老师的要求是对的,其实自己在配环境和做作业的时候也会把遇到的问题的解决方法记录下来,以便以后查找方便.这次借此将那些内容放在这里,也跟大家一起分享下. 上周六算是第二 ...

  7. 浅析JVM内存区域及垃圾回收

    一.JVM简介 JVM,全称Java Virtual Machine,即Java虚拟机.以Java作为编程语言所编写的应用程序都是运行在JVM上的.JVM是一种用于计算设备的规范,它是一个虚构出来的计 ...

  8. Android 简介

    一 Android起源 android: 机器人 android是google公司开发的基于Linux2.6的免费开源操作系统 2005 Google收购 Android Inc. 开始 Dalvik ...

  9. 【shell 每日一练6】初始化安装Mysql并修改密码

    一.简单实现mysql一键安装 参考:[第二章]MySQL数据库基于Centos7.3-部署 此脚本前提条件是防火墙,selinux都已经设置完毕: [root@web130 ~]# cat Inst ...

  10. 饥饿的小易(枚举+广度优先遍历(BFS))

    题目描述 小易总是感觉饥饿,所以作为章鱼的小易经常出去寻找贝壳吃.最开始小易在一个初始位置x_0.对于小易所处的当前位置x,他只能通过神秘的力量移动到 4 * x + 3或者8 * x + 7.因为使 ...