每日更新,建议关注、收藏、点赞
ocr流程
版面分析 、预处理-> 行列切割 -> 字符识别 -> 后处理识别矫正
- 判断页面上的文本朝向,图像预处理,做角度矫正和去噪。
- 对文档版面进行分析,进每一行进行行分割,把每一行的文字切割下来,最后再对每一行文本进行列分割,切割出每个字符。
- 将该字符送入训练好的OCR识别模型进行字符识别,得到结果。
- 但是模型识别结果往往是不太准确的,我们需要对其进行识别结果的矫正和优化,比如我们可以设计一个语法检测器,去检测字符的组合逻辑是否合理。比如,考虑单词Because,我们设计的识别模型把它识别为8ecause,那么我们就可以用语法检测器去纠正这种拼写错误,并用B代替8并完成识别矫正。
字符识别方法
- 开源OCR引擎Tesseract,这是谷歌维护的一个OCR引擎。Tesseract现在的版本已经支持识别很多种语言但汉字识别的精度上还是需要自己去改善。Tesseract在阿拉伯数字和英文字母上的识别还是可以的,如果你要做的应用是要识别英文或者数字,不妨考虑一下使用Tesseract;不过要做到你想要的识别率,后期微调或者优化肯定要多下功夫的。
- 字符模板匹配法。暴力的字符模板匹配法看起来很蠢,但是在简单应用上可能却很凑效。
比如在对电表数字进行识别时,考虑到电表上的字体较少(可能就只有阿拉伯数字),而且字体很统一,清晰度也很高,所以识别难度不高。
针对这种简单的识别场景,我们首先考虑的识别策略当然是最为简单和暴力的模板匹配法。我们首先定义出数字模板(0~9),然后用该模板滑动匹配电表上的字符,这种策略虽然简单但是相当有效。我们不需要左思右想去建模,训练模型,只需要识别前做好模板库就可以了。 - OCR的一般方法,即特征设计、特征提取、分类得出结果的计算机视觉通用的技巧。
在深度学习之前,OCR的方法基本都是这种方法,其效果不算特别好。
第一步是特征设计和提取,特征设计(做过模式识别相关项目的懂得都懂),我们现在识别的目标是字符,所以我们要为字符设计它独有的的特征,来为后面的特征分类做好准备。字符有结构特征,即字符的端点、交叉点、圈的个数、横线竖线条数等等,都是可以利用的字符特征。比如“品”字,它的特征就是它有3个圈,6条横线,6条竖线。除了结构特征,还有大量人工专门设计的字符特征,据说都能得到不错的效果。最后再将这些特征送入分类器(SVM)做分类,得出识别结果。
这种方式最大的缺点就是,人们需要花费大量时间做特征的设计,这是一件相当费工夫的事情。通过人工设计的特征(例如HOG)来训练字符识别模型,此类单一的特征在字体变化,模糊或背景干扰时泛化能力迅速下降。而且过度依赖字符切分的结果,在字符扭曲、粘连、噪声干扰的情况下,切分的错误传播尤其突出。针对传统OCR解决方案的不足,学界业界纷纷拥抱基于深度学习的OCR。 - 现在OCR基本都用卷积神经网络来做了,而且识别率也是惊人的好,人们也不再需要花大量时间去设计字符特征了。在OCR系统中,人工神经网络主要充当特征提取器和分类器的功能,输入是字符图像,输出是识别结果。运用卷积神经网络。
当然用深度学习做OCR并不是在每个方面都很优秀,因为神经网络的训练需要大量的训练数据,那么如果我们没有办法得到大量训练数据时,这种方法很可能就不奏效了。其次,神经网络的训练需要花费大量的时间,并且需要用到的硬件资源一般都比较多,这几个都是需要考虑的问题。
证件照识别
- 将证件轮廓找到
- 二值化+高斯滤波+膨胀+canny边缘提取
这里膨胀的作用:使某些信息区域轮廓闭合,便于提取信息区域轮廓。 - 轮廓查找并筛选
有很多干扰项轮廓,如果我们不能很好的剔除这些轮廓,我们根本没法找出我们想要的信息区域。我筛选轮廓的方法很简单,就是找出一张图片中面积最大的那个轮廓作为我们的信息区域轮廓
- 二值化+高斯滤波+膨胀+canny边缘提取
- 提取证件矩形轮廓四点进行透视变换
- 由于轮廓不一定是四边形的,所以(比如x坐标最大的那个坐标肯定是四边形右上角坐标或者右下角坐标,x坐标最小的那个坐标肯定是左上角或者下角的那个坐标,如此类推)这种思路不可行。
- 基于直线交点的思路。我们首先使用霍夫变换找出四边形的边,然后求两两直线的交点就是四边形的顶点。
最大的问题就是,我们怎么保证我们使用霍夫变换找到的直线刚好就是形成四边形的四条直线?所以我们就必须不断地去改变霍夫变换的参数,不断迭代,来求出一个可以形成四边形的直线情况。
什么情况的直线我们不能接受?两两直线过于接近的、两两直线没有交点、检测出来的直线数目不是4条
如果找到了满足条件的四条直线,我们就可以去计算他们的交点了。
计算出四个交点后,继续筛选:两两定点的距离过近排除、四个点构成不了四边形排除
通过以上筛选条件的,可以认为就是我们找的那四个顶点,这时我们就可以停止迭代,进行顶点排序,即确定这四个顶点哪个是左上角点,哪个又是右下点。 - 用这四点来进行透视变换, 后文有详细的介绍。
- 字符识别部分
复杂场景下的ocr
OCR传统方法在应对复杂图文场景的文字识别能力不够,如何把文字在复杂场景读出来,并且读得准确,关键在于 场景文本识别(文字检测+文字识别)。
- 图片预处理
- 透视矫正/透视变换
透视变换是将图片投影到一个新的视平面,也称作投影映射。它是二维(x,y)到三维(X,Y,Z),再到另一个二维空间(x’,y’)的映射。我们常说的仿射变换是透视变换的一个特例。
相对于仿射变换,它不仅仅是线性变换。它提供了更大的灵活性,可以将一个四边形区域映射到另一个四边形区域。
透视变换也是通过矩阵乘法实现的,使用的是一个3x3的矩阵,矩阵的前两行与仿射矩阵相同,这意味着仿射变换的所有变换透视变换也可以实现。而第三行则用于实现透视变换。
注意:变换矩阵T和KT得到的结果是一样的,这个可以自己推一下,相当于分子共同因子还有k,还是把k给除掉了
- 透视矫正/透视变换
通过透视变换,我们可以实现将一张正对我们的图片转换成仰视、俯视、侧视看这张图片的效果,反之也可以实现转换到正对的效果。
- 仿射变换是透视变换的一种特例。
仿射变换是一种二维坐标到二维坐标之间的线性变换,也就是只涉及一个平面内二维图形的线性变换。
图形的平移、旋转、错切、放缩都可以用仿射变换的变换矩阵表示。
它保持了二维图形的两种性质:
① “平直性”:直线经过变换之后依然是直线。一条直线经过平移、旋转、错切、放缩都还是一条直线。
②“平行性”:变换后平行线依然是平行线,且直线上点的位置顺序不变。
任意的仿射变换都能表示为一个坐标向量乘以一个矩阵的形式
#平移
x = 100
y = 200
M = np.float32([[1, 0, x], [0, 1, y]])
move1 = cv2.warpAffine(img, M, (width, height))#旋转
retval = cv2.getRotationMatrix2D(center, angle, scale)
'''
center是旋转的中心点
angle是旋转角度,正数表示逆时针旋转,负数表示顺时针旋转
scale为变换尺寸(缩放大小)
'''M = cv2.getRotationMatrix2D((width/2, height/2), 45, 1)
rotation = cv2.warpAffine(img, M, (width, height))#透视变换
dst = cv2.warpPerspective(src, M, dsize, [, flags[, borderMode[, borderValue]]])
'''
dst代表透视处理后的输出图像,dsize决定输出图像的实际大小
src代表要透视的图像
M为一个3X3的变换矩阵
dsize代表输出图像的尺寸大小
flags表示差值方法,默认为INTER_LINEAR。当值为WARP_INVERSE_MAP时,意味着M为逆变换,实现从目标图像dst到src的逆变换。具体值见下表。
borderMode表示边类型。默认为BORDER_CONSTANT。当值为BORDER_TRANSPARENT时,意味着目标图像内的值不做改变,这些值对应着原始图像的异常值。
border表示边界值,默认为0
与仿射变化一样,可以使用函数cv2.getPerspectiveTransform()来生成转换矩阵。'''
rows, cols, ch = img.shape
p1 = np.float32([[80, 266], [494, 27], [239, 543], [655, 300]])
# 左上角,右上角,左下角,右下角
p2 = np.float32([[0, 0], [800, 0], [0, 600], [800, 600]])
M = cv2.getPerspectiveTransform(p1, p2)
dst = cv2.warpPerspective(img, M, (cols, rows))#复杂的仿射变换
retval = cv2.getAffineTransform(src, dst)
'''
src表示输入图像的三个点坐标
dst表示输出图像的三个点坐标
src和dst三个点坐标分别表示平行四边形的左上角、右上角、右下角的三个点。
'''rows, cols, ch = img.shape
p1 = np.float32([[81, 265], [240, 540], [496, 26]])
p2 = np.float32([[0, 0], [0, 200], [300, 0]])
M = cv2.getAffineTransform(p1, p2)
dst = cv2.warpAffine(img, M, (cols, rows))#透视矫正
def order_points(pts):# 一共4个坐标点rect = np.zeros((4, 2), dtype = "float32")# 按顺序找到对应坐标0123分别是 左上,右上,右下,左下# 计算左上,右下s = pts.sum(axis = 1)rect[0] = pts[np.argmin(s)]rect[2] = pts[np.argmax(s)]# 计算右上和左下diff = np.diff(pts, axis = 1)#设置 axis=0 时,是按列进行做差,axis=1 时是按行进行做差。rect[1] = pts[np.argmin(diff)]rect[3] = pts[np.argmax(diff)]return rectdef four_point_transform(image, pts):# 获取输入坐标点rect = order_points(pts)(tl, tr, br, bl) = rect# 计算输入的w和h值widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))maxWidth = max(int(widthA), int(widthB))heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))maxHeight = max(int(heightA), int(heightB))# 变换后对应坐标位置dst = np.array([#四个顶点[0, 0], [maxWidth - 1, 0],[maxWidth - 1, maxHeight - 1],[0, maxHeight - 1]], dtype = "float32")# 计算变换矩阵M = cv2.getPerspectiveTransform(rect, dst)warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))# 返回变换后结果return warped# 透视矫正
def perspective_transformation(img):# 读取图像,做灰度化、高斯模糊、膨胀、Canny边缘检测gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)blurred = cv2.GaussianBlur(gray, (5, 5), 0)#高斯模糊、高斯滤波dilate = cv2.dilate(blurred, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))# edged = cv2.Canny(dilate, 75, 200)edged = cv2.Canny(dilate, 30, 120, 3)cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)'''
contours, hierarchy = cv2.findContours( image, mode, method)返回值为:
contours:返回的轮廓。
该返回值返回的是一组轮廓信息,每个轮廓都是由若干个点所构成的。例如,contours[i] 是第 i 个轮廓(下标从 0 开始),contours[i][j]是第 i 个轮廓内的第 j 个点。返回值 contours 的 type 属性是 list 类型,list 的每个元素都是图像的一个轮廓,用 Numpy中的 ndarray 结构表示。hierarchy:图像的拓扑信息(轮廓层次)。
图像内的轮廓可能位于不同的位置。比如,一个轮廓在另一个轮廓的内部。在这种情况下,
我们将外部的轮廓称为父轮廓,内部的轮廓称为子轮廓。按照上述关系分类,一幅图像中所有轮廓之间就建立了父子关系。
根据轮廓之间的关系,就能够确定一个轮廓与其他轮廓是如何连接的。比如,确定一个轮廓是某个轮廓的子轮廓,或者是某个轮廓的父轮廓。上述关系被称为层次(组织结构),返回值 hierarchy 就包含上述层次关系。
每个轮廓 contours[i]对应 4 个元素来说明当前轮廓的层次关系。其形式为:
[Next,Previous,First_Child,Parent]
Next:后一个轮廓的索引编号。
Previous:前一个轮廓的索引编号。
First_Child:第 1 个子轮廓的索引编号。
Parent:父轮廓的索引编号。
如果上述各个参数所对应的关系为空时,也就是没有对应的关系时,则将该参数所对应的值设为“-1”。式中的参数为:
image:原始图像。8 位单通道图像,所有非零值被处理为 1,所有零值保持不变。即 会被自动处理为二值图像。在实际操作时,可以根据需要,预先使用阈值处理等函数将待查找轮廓的图像处理为二值图像。mode:轮廓检索模式。
cv2.RETR_EXTERNAL:只检测外轮廓。
cv2.RETR_LIST:对检测到的轮廓不建立等级关系。
cv2.RETR_CCOMP:检索所有轮廓并将它们组织成两级层次结构。上面的一层为外边界,下面的一层为内孔的边界。如果内孔内还有一个连通物体,那么这个物体的边界仍
然位于顶层。
cv2.RETR_TREE:建立一个等级树结构的轮廓。method:轮廓的近似方法。
参数 method 决定了如何表达轮廓,可以为如下值:
cv2.CHAIN_APPROX_NONE:存储所有的轮廓点,相邻两个点的像素位置差不超过 1,即 max(abs(x1-x2),abs(y2-y1))=1。
cv2.CHAIN_APPROX_SIMPLE:压缩水平方向、垂直方向、对角线方向的元素,只保留该方向的终点坐标。例如,在极端的情况下,一个矩形只需要用 4 个点来保存轮廓信息。注意使用函数 cv2.findContours()查找图像轮廓之前,要预先对图像进行阈值分
割或者边缘检测处理,得到满意的二值图像后再将其作为参数使用。在 OpenCV 中,都是从黑色背景中查找白色对象。因此,对象必须是白色的,背景必须是黑色的。'''cnts = cnts[0] if imutils.is_cv2() else cnts[1] # 判断是OpenCV2还是OpenCV3docCnt = None# 确保至少找到一个轮廓if len(cnts) > 0:# 按轮廓大小降序排列cnts = sorted(cnts, key=cv2.contourArea, reverse=True)for c in cnts:# 近似轮廓peri = cv2.arcLength(c, True)#arcLength 函数用于计算封闭轮廓的周长或曲线的长度。approx = cv2.approxPolyDP(c, 0.02 * peri, True)#以指定的精度近似生成多边形曲线。# 如果我们的近似轮廓有四个点,则确定找到了纸if len(approx) == 4:docCnt = approxbreak# 对原始图像应用四点透视变换,以获得纸张的俯视图paper = four_point_transform(img, docCnt.reshape(4, 2))return paper
- 水平矫正
普通的水平矫正图像都会带有自己的边缘,根据边缘可以提取出一个mask,然后进行旋转即可。
文本图像的背景是白色的,所以我们没有办法像人民币发票那类有明显边界的矩形物体那样,提取出轮廓并旋转矫正。这里我们用基于直线探测的水平矫正。- 文本水平矫正
用霍夫线变换探测出图像中的所有直线
计算出每条直线的倾斜角,求他们的平均值
根据倾斜角旋转矫正
- 文本水平矫正
霍夫线变换,对图像中每个点对应曲线间的交点进行追踪,如果交于一点的曲线的数量超过了阈值,就认为这个交点所对应的 ( r , θ )在原图像中为一条直线。
这里对直线的表示不是用传统的斜率和截距表示,因为垂直线的斜率不存在(或无限大),所以用Hesse normal form(Hesse法线式) 。即用原点到直线上的最近点 ( r , θ ) 表示,这个点是可以将图像的每一条直线与一对参数相关联。这个参数平面有时被称为霍夫空间,用于二维直线的集合。
经过Hough变换,将图像空间中的一个点映射到Hough空间
对图像中所有的点进行上述操作,如果两个不同点进行上述操作后得到的曲线在平面 θ − r 相交, 这就意味着它们通过同一条直线。
霍夫线变换,对图像中每个点对应曲线间的交点进行追踪,如果交于一点的曲线的数量超过了阈值,就认为这个交点所对应的 ( r , θ )在原图像中为一条直线。
# coding=utf-8
import cv2
import numpy as npinput_img_file = "../test/test.png"# 度数转换
def DegreeTrans(theta):res = theta / np.pi * 180return res# 逆时针旋转图像degree角度(原尺寸)
def rotateImage(src, degree):# 旋转中心为图像中心h, w = src.shape[:2]# 计算二维旋转的仿射变换矩阵RotateMatrix = cv2.getRotationMatrix2D((w/2.0, h/2.0), degree, 1)'''cv.getRotationMatrix2D(center, angle, scale) → M
center 表示旋转中心坐标,二元元组 (x0, y0)。
angle 表示旋转角度,单位为角度,逆时针为正数,顺时针为负数。
scale 表示缩放因子。'''print(RotateMatrix)# 仿射变换,背景色填充为白色rotate = cv2.warpAffine(src, RotateMatrix, (w, h), borderValue=(255, 255, 255))return rotate# 通过霍夫变换计算角度
def CalcDegree(srcImage):midImage = cv2.cvtColor(srcImage, cv2.COLOR_BGR2GRAY)dstImage = cv2.Canny(midImage, 50, 200, 3)lineimage = srcImage.copy()# 通过霍夫变换检测直线# 第4个参数就是阈值,阈值越大,检测精度越高lines = cv2.HoughLines(dstImage, 1, np.pi/180, 200)# 由于图像不同,阈值不好设定,因为阈值设定过高导致无法检测直线,阈值过低直线太多,速度很慢'''
cv.HoughLines(img,rho,theta,threshold)
-img:检测的图像,要求是二值化的图像,所以在调用霍夫变换之前首先要进行二值化,或者进行Canny边缘检测
-rho、theta:\rho和\theta的精确度
-threshold:阈值,只有累加器中的值高于该阈值是才被认为是直线'''sum = 0# 依次画出每条线段for i in range(len(lines)):for rho, theta in lines[i]:# print("theta:", theta, " rho:", rho)a = np.cos(theta)b = np.sin(theta)x0 = a * rhoy0 = b * rho#(x1,y1),(x2,y2)构成了原点和(x0,y0)连线的垂直线段 即检测到的一条直线x1 = int(round(x0 + 1000 * (-b)))y1 = int(round(y0 + 1000 * a))x2 = int(round(x0 - 1000 * (-b)))y2 = int(round(y0 - 1000 * a))# 只选角度最小的作为旋转角度sum += thetacv2.line(lineimage, (x1, y1), (x2, y2), (0, 0, 255), 1, cv2.LINE_AA)cv2.imshow("Imagelines", lineimage)# 对所有角度求平均,这样做旋转效果会更好average = sum / len(lines)angle = DegreeTrans(average) - 90return angleif __name__ == '__main__':image = cv2.imread(input_img_file)cv2.imshow("Image", image)# 倾斜角度矫正degree = CalcDegree(image)print("调整角度:", degree)rotate = rotateImage(image, degree)cv2.imshow("rotate", rotate)# cv2.imwrite("../test/recified.png", rotate, [int(cv2.IMWRITE_PNG_COMPRESSION), 0])cv2.waitKey(0)cv2.destroyAllWindows()
在预处理工作做好之后,就可以开始切割字符了。最普通的切割算法可以总结为以下几个步骤:
对图片进行水平投影(水平投影就是对一张图片的每一行元素进行统计,往水平方向统计,根据这个统计结果画出统计结果图,进而确定每一行的起始点和结束点),找到每一行的上界限和下界限,进行行切割
对切割出来的每一行,进行垂直投影(统计每一列的元素个数),找到每一个字符的左右边界,进行单个字符的切割
常出现英语的切割效果很好,但中文效果一般。分析其原因,这其实跟中文的字体复杂度有关的,中文的字符的笔画和形态都比英文的多,更重要的是英文字母都是绝大部分都是联通体,切割起来很简单,但是汉字多存在左右结构和上下结构,很容易造成过度切割,即把一个左右偏旁的汉字切成了两份,比如上面的“则”字。
针对行字符分割,左右偏旁的字难以分割的情况,我觉得可以做以下处理:
先用通用的分割方法切割字符,得到一堆候选的切割字符集合。
统计字符集合的大多数字符的尺寸,作为标准尺寸。
根据标准尺寸选出标准的字符,切割保存。并对切割保存好的字符原位置涂成白色
对剩下下来的图片进行腐蚀,让字体粘连。
用算法再次分割,得到完整字体集合。
因为以上的思路可能只适应于纯汉字文本
一些字体较小,字体间隔较窄的情况。这类情况确实分割效果大打折扣,因为每个字体粘连过于接近,字体的波谷很难确定下来,进而造成切割字符失败。
现在解决汉字切割失败(过切割,一个字被拆成两个)的较好方法是,在OCR识别中再把它修正。比如“刺”字被分为两部分了,那么我们就直接将这两个“字”送去识别,结果当然是得到一个置信度很低的一个反馈,那么我们就将这两个部分往他们身边最近的、而且没被成功识别的部分进行合并,再将这个合并后的字送进OCR识别。