一块车牌、一张字符型验证码图片,我们人类看一眼,脑海中就已经有了答案,整个过程主要包括眼睛和大脑的协作:眼睛采集字符关键信息,通过大脑学习沉淀的知识进行比对,从而得出答案。那么对于机器识别,它是如何做的呢?本文将为大家提供一种类似于卷积神经网络算法的识别方案。
在文章开始前,先为大家简单介绍下数字图像处理的一些基础概念及数据结构。
一幅图像可以定义为一个二维数组f(x,y),这里x,y是空间坐标,而在任何一对空间坐标(x,y)上的幅值f称为该点图像的强度或灰度。当x,y和幅值f为有限的、离散的数值时,称该图像为数字图像。将图片二值化后其二维矩阵如下:(相信大家已经看出其中的内容了吧)
对于字符型图片,机器识别的完整流程跟人类读取信息基本一致,主要包括:
眼睛采集关键信息:图片预处理、字符切割,得到特征值;
大脑学习沉淀知识:数据模型训练,形成特征库;
大脑根据已学知识匹配得出结论:将待识别字符的特征值与特征库中的字符特征码进行类似卷积操作的计算,得出结果。
图片预处理
为了减少后面卷积比对的复杂度,同时增加识别率,很有必要对图片进行预处理,使其对机器识别更友好。预处理的主要包括:对图像进行灰度化、二值化、抑噪(滤波)等技术。
原始验证码图片:
图像灰度化
RGB系统中一个颜色值由3个分量组成,这样的图像称为彩色图像,RGB系统称为颜色空间模型。常见的颜色空间模型还有HSI、CMYK等。如果一幅图像的颜色空间是一维的(一个颜色值只有一个颜色分量),则这幅图像就是一副灰度图。在位图图像中,一般以R=G=B来显示灰度图像。常用的灰度化方法有以下三种:
(1.1)
(1.2)
(1.3)
其中,公式(1.1)的方法来源于I色彩空间中I分量的计算公式,公式(1.2)来源于NTSC色彩空间中Y分量的计算公式。公式(1.3)是基于采用保留最小亮度(黑色)的方法。本文主要以公式1.2算法为例,其核心实现如下:
public static Integer[][] getGrayMatrix (BufferedImage bi){
Integer width = bi.getWidth();
Integer height =bi.getHeight();
Integer[][] matrix = new Integer[width][height];
for(int i = 0;i < width;i ++){
for(int j = 0;j < height;j ++){
Color color = new Color(bi.getRGB(i,j));
int red = color.getRed();
int green = color.getGreen();
int blue = color.getBlue();
matrix[i][j] = (int)(0.3*red+0.6*green+0.1*blue);
}
}
return matrix;
}
二值化
将灰度图按照设定阈值转化为二值图,二值图像中的数据全部是0或1。
public static Integer[][] binariazation(Integer[][] matrix) {
Integer width = matrix.length;
Integer height = matrix[0].length;
for(int i=0;i<width;i++){
for(int j = 0;j < height; j ++){
if(matrix[i][j] < threshold){
matrix[i][j] = 1;
}else{
matrix[i][j] = 0;
}
}
}
return matrix;
}
经过灰度及二值化处理后的图片:
其二维矩阵样例,大家已经在文章开篇见识过了。
抑噪
在转化为二值图片后,就需要清除噪点。本文选择的素材比较简单,大部分噪点也是最简单的那种 孤立点,所以可以通过检测这些孤立点就能移除大量的噪点。
关于如何去除更复杂的噪点甚至干扰线和色块,有比较成熟的算法: 洪水填充法 Flood Fill ,后面有兴趣的时间可以继续研究一下。
本文主要采用九宫格降噪来解决掉这个问题:
对某个 黑点 周边的九宫格里面的黑色点计数
如果黑色点少于2个则证明此点为孤立点,然后得到所有的孤立点
对所有孤立点一次批量移除。
九宫格降噪示意图:
核心代码实现:
// 定义九宫格
public static final Integer[][] BOX_9 = new Integer[3][3];
static {
BOX_9[0] = new Integer[]{0, 0, 0};
BOX_9[1] = new Integer[]{0, 1, 0};
BOX_9[2] = new Integer[]{0, 0, 0};
}
/**
* 九宫格孤点降噪算法
* @param source
* @param nineBox
*/
public static void reducePiont(Integer[][] source, Integer[][] nineBox) {
//去掉边缘燥点
for (int i = 0; i < source.length; i++) {
for (int j = 0; j < source[i].length; j++) {
if (i == 0 || i == source.length - 1) {
source[i][j] = 0;
} else {
if (j == 0 || j == source[0].length - 1) {
source[i][j] = 0;
}
}
}
}
int _sx = source[0].length;//列
int _sy = source.length;//行
int _tx = nineBox[0].length;
int _ty = nineBox.length;
int _deltax = _sx - _tx;//横轴卷积核平移次数
int _deltay = _sy - _ty;//纵轴卷积核平移次数
for (int dy = 0; dy <= _deltay; dy++) {
for (int dx = 0; dx <= _deltax; dx++) {
// 根据目标对待分析图片进行卷积求和
int cn = 0; // 卷积
for (int ty = 0; ty < _ty; ty++) {
for (int tx = 0; tx < _tx; tx++) {
int muti = nineBox[ty][tx] | source[ty + dy][tx + dx];
cn += muti;
}
}
if (cn == 1) {
source[dy + 1][dx + 1] = 0;
}
}
}
}
字符切割
如果采用统计特征匹配以及神经网络法识别,必须要先分割出单个的字符。简单的分割方法包括等距分割、投影法分割、交叉点分割、求连通区等。其中,粘连字符的分割是一个难点,复杂的粘连情况下分割比较困难,是一个硬人工智能问题。本文主要介绍投影法分割。
投影法
投影法的原理其实很简单,利用二值化图片的像素的分布直方图进行分析,从而找出相邻字符的分界点进行分割。(本文以左右布局的图片为例,就适合用垂直投影的方法,反之若是上下型,则做水平投影即可。)
上图其实已经看的很明白,投影所反应的就是在垂直方向上数字区域像素个数,而字与字之间的间隔(上图白色空隙)黑色像素点数为零,以此为依据分割。实现过程如下:
/**
* 投影
*
* @param source 二维矩阵
* @return
*/
public static Integer[] shadow(Integer[][] source) {
Integer[] shadowResult = new Integer[source[0].length];
for (int i = 0; i < source[0].length; i++) {
int xn = 0;
for (int k = 0; k < source.length; k++) {
xn += source[k][i];
}
shadowResult[i] = xn;
}
return shadowResult;
}
/**
* 根据X投影坐标计算出字符分割边界
*
* @param source 投影后的权值矩阵
*/
public static List<Point> segmentX(Integer[] source) {
List<Point> points_x = new ArrayList<Point>();
for (int i = 0; i < source.length; i++) {
if (source[i] > 0) {
if (i == source.length - 1) {
points_x.add(new Point(i, i));
} else {
for (int j = i + 1; j < source.length; j++) {
if (source[j] == 0 || j == source.length - 1) {
points_x.add(new Point(i, j - 1));
i = j;
break;
}
}
}
}
}
return points_x;
}
切割后的单个字符与二维矩阵:
大家可以看到在原始图片数字9后,还有两段干扰线,按投影法切割也会切成图片,在使用时候,只需设定单个字符像素点的最小阈值,小于阈值的舍弃即可。
模型训练
通过前面几步,已经完成了对单个图片的处理和分割,得到了每个字符的特征码——01二维矩阵,在展开卷积识别之前,机器是不具备任何字符观念的。所以需要预先建立特征库、为每个字符生成特征码,并人为对特征码进行打标,“告诉”机器什么样的图片内容是 A、什么样的图片内容是B等。
整个训练过程如下:
准备大量的图片素材,完成预处理并切割得到原子级(单个字符的01矩阵)的图片素材
对每个原子级素材图片进行人为分类打标
0~9数字特征库分类示意图:
将图片旋转一定角度并分类(若目标图片中,字符有倾斜或旋转等情况需要)
单个字符特征码示意图:
4.将旋转后的单个字符素材转换为二维矩阵,便于后续卷积比对。
“卷积”识别
字符识别就是把处理后的图片还原回字符文本的过程。本文主要采用类似卷积神经网络的卷积操作,对分割后的单个字符进行识别。
将图片预处理并分割后,已经得到了待识别字符的特征码(01二维矩阵),同时经过模型训练,也已经得到每个字符的特征库(多组01二维矩阵),使用相似性度量将待识别字符和特征库的每组特征码进行“卷积操作”,将该字符识别为与其特征码相似性最高的字符。
卷积比对过程如下图所示:
本文所采用的类似卷积操作核心代码实现如下:
/**
* 二维矩阵卷积运算
* 考虑了位置因素,相当于卷积神经网络的一层卷积操作
*
* @param source 待卷积矩阵
* @param kernel 卷积核
* @return 待卷积矩阵与卷积核的重合度(简单粗暴的计算点相同的数量)
*/
public static Integer computeCN(Integer[][] source, Integer[][] kernel) {
Integer num_kernel = MatrixUtil.pv(kernel);
int _sx = source[0].length;//列
int _sy = source.length;//行
int _tx = kernel[0].length;
int _ty = kernel.length;
int _deltax = _sx - _tx;//横向移动次数
int _deltay = _sy - _ty;//纵向移动次数
Integer ref = 0;// 最大相似区域
// 计算最大相似区域
for (int dy = 0; dy <= _deltay; dy++) {
for (int dx = 0; dx <= _deltax; dx++) {
// 根据目标对待分析图片进行卷积求和
int cn = 0; // 卷积
for (int ty = 0; ty < _ty; ty++) {
for (int tx = 0; tx < _tx; tx++) {
int muti = kernel[ty][tx] & source[ty + dy][tx + dx];
cn += muti;
}
}
ref = Math.max(ref, cn);
if (ref.doubleValue() / num_kernel.doubleValue() > 0.9) {
return ref;
}
}
}
return ref;
}
通过“卷积操作”后,匹配到相似度较高的字符:0、2、6、9,拼接在一起即得到最终字符内容。
本文为大家介绍了一种字符型图片内容识别技术,同时业界也有很多成熟的ocr识别框架,如谷歌的tesseract等,不过作为技术,我们应当知其然知其所以然。在选择技术方案时,不管是自研、还是成熟的框架,最适合业务场景的才是最好的,比如我们的图片识别场景,采用自研算法,针对特定字符进行优化、模型训练后,单次识别可以做到100ms左右,远高于相同条件下tesseract平均1s左右的耗时,对用户端更加友好。
最后,技术是中立的、本文旨在为大家提供一些图文识别的思路,对于掌握OCR技术的人不要做违法的事。
ps:文中所提到的卷积神经网络识别,仅用到其卷积层的比对思想,实际整个神经网络是非常复杂的,感兴趣的同学可查找资料深入研究。
推荐阅读