漫漫ocr之路
浅谈ocr
最近在做一个ocr相关的项目,由于是第一次接触图像切割相关工作,有许多一知半解甚者丝毫没思路无从下手的问题,就边做边摸索边学习,现在总结如下:
- 项目总体需求是将一张医疗报告或者发票之类的图片上边的文字进行切割,切割成单个字符,再进行文字识别,同时以json格式返回识别结果和文字的坐标信息,然后利用这些信息进行结构化提取,从而得到结构化提取结果,方便使用。
======
- pdf转图片: 如果是pdfminer不能解析的(加密的或者图片格式的)pdf文件,可以通过用mutool转为图片,然后经ocr的方式切割识别,得到带坐标信息的识别字符,然后返回去提取算法进行结构化提取。
整体流程及思路
-
要达到预期效果,图片的预处理和文字切割的准确性至关重要,只有图片预处理效果好,图片切割的足够精确,后续识别才能准确,所以图片预处理和文字切割是重中之重,要做好这部分工作。
-
这里直接从图片文件说起,可以是.png或者.jpg格式的图片,我们多是.png图片。在对图片进行分行操作之前,要先进行二值化,后边都是对二值化图片进行操作的。
-
ocr大体上包含的流程如下:
- 图片预处理
- 投影法图像切割
- CNN识别(或者tesseract)
- 识别结果后处理
下面将一一展开论述:
第一部分:图片预处理
预处理是OCR整个流程中必不可少的一环。预处理过程可以去掉那些会影响最终OCR输出的因素和问题。当你拿到图像数据以后,应该多花一些时间对其进行测试,进行预处理,最终达到一个比较好的效果,利于后续切割识别。
- 很多图片中仍然存在噪声、倾斜、变形等因素,需要抗扭斜,去燥等处理
==预处理关键步骤
1. 灰度化及二值化
补充:
- 单通道图:俗称灰度图,每个像素点只能有有一个值表示颜色,它的像素值在0到255之间,0是黑色,255是白色,中间值是一些不同等级的灰色。对于灰度图,len(image.shape)值为2,分别是图像高和宽。
- 三通道图:每个像素点都有3个值表示,RGB图片即为三通道图片。对于rgb图,len(image.shape)值为3,前两个是图像宽高,后一个值是通道数。
====
- 如果是三通道图(彩色),要先灰度化为灰度图,然后再二值化;
1.1 灰度化
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
1.2 二值化
threshold, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV)
=== 二值化方法
网上有许多二值化相关方法,这里不再赘述。具体二值化方法要根据需求选择,本项目采用Otsu’s二值化方法。
2.去直线
因为我们是基于投影法进行图像切割的,而直线则是影像投影法的一个重要因素,所以最好能在投影之前先将直线检测到并去掉,下面就是检测直线的方法。
2.1 hough变换法
edges = cv2.Canny(gray, 50, 150,apertureSize=5)
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 100, minLineLength = 50 * dpiMultiple,maxLineGap=1 * dpiMultiple)
说明:
- cv2.Canny()函数的第2、3个参数是像素的高低阈值,低于低阈值的会被去掉,高于高阈值的确定为边界,在高低之间的,如果与确定的边界相连,则视为边界,否则不是边界,apertureSize定义高斯滤波器的大小,用来平滑模糊图片,较大的滤波器适用于于检测彩虹边界之类的较大边缘,较小的滤波器适用于检测细线之类的小边界
- cv2.HoughLinesP()函数第2、3个参数是ρ和θ的采样间距,越小就越能检测到更多直线,第4个参数是累加器中能够算作直线的阈值,越高检测到的直线就越少。
2.2 detect_table卷积核
通过卷积核腐蚀图片找到线轮廓,然后找轮廓边界得到线的端点,从而去掉直线。
_, h_contours, _ = cv2.findContours(binaryH, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
_, v_contours, _ = cv2.findContours(binaryV, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for hcontour in h_contours:
xList = [point[0][0] for point in hcontour]
yList = [point[0][1] for point in hcontour]
minX, maxX = min(xList), max(xList)
minY, maxY = min(yList), max(yList)
cv2.line(img_binary, (minX, minY), (maxX, minY), 0, thickness=5 * dpiMultiple)
cv2.line(img_binary, (minX, maxY), (maxX, maxY), 0, thickness=5 * dpiMultiple)
cv2.line(img_binary, (minX, minY), (maxX, maxY), 0, thickness=5 * dpiMultiple)
3. 倾斜校正
当原始图片存在倾斜时,要对其做校正处理
3.1 仿射变换
仿射变换(Affine Transformation或 Affine Map)是一种二维坐标到二维坐标之间的线性变换,它保持了二维图形的“平直性”(即:直线经过变换之后依然是直线)和“平行性”(即:二维图形之间的相对位置关系保持不变,平行线依然是平行线,且直线上点的位置顺序不变)
3.2 透视变换
对于拍照时存在相机与被拍摄物不平行的情况,得到的图片会两端不同程度倾斜,则要通过透视变换将其校正过来
3.3 最小外接矩形求倾角
步骤:
- 1.获得最大轮廓
image_out,contours, hierarchy = cv2.findContours(
img_binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
contour = max(contours, key=cv2.contourArea)
- 求最小外接矩形,获取矩形的长和宽,从而求旋转角度
rect = cv2.minAreaRect(contour)
rect_w, rect_h = int(np.sqrt((x3 - x2)**2 + (y3 - y2)**2)), int(np.sqrt((x2 - x1)**2 + (y2 - y1)**2))
=====ocr时大概思路是行投影分行,然后针对每一行,列投影法分成单个字符,接下来就干这个工作。
第二部分: 投影法切割
对于二值化之后的图片,我们就可以进行行投影分行了。
part one:行投影
1. 求行间隔,找出所有有值行的起止index,并存入nonZeroList
gapVector = np.sum(binary,axis=1)
2. 根据行间隔的index进行分行,把每行的y轴起始值传出去
rowList=[]
for startIdx,endIdx in nonZeroList:
img = binary[startIdx:endIdx, :] #返回二值化分行
rowList.append((startIdx,endIdx,img)) #始末y值和行图
- 然后得到的rowList里边是第一次行切分之后的行图信息,正常的思路应该是针对每个行图,向下继续列投影切除单个字符,然而实际情况远不止那么简单,有包含超大图片的行,有多行重叠的,有相互遮挡的行,这些都没法精确列投影,所以在此要加判断条件进行判断,那些是可能存在遮挡等恶劣情况的行图片,需对其进一步切块。
====
3. 对行投影的得到的行,求得行高h。
此时的图片分为两种:一种行高很高的可能存在多行重叠,另一种是正常高度的单行的。
- 对较高的行进一步分块判断
- 对正常不太高的行则正常存下来参与计算平均行高,参与后边正常列投影分成单字符。
4. 对较高的行进一步分块判断
====
4.1 超级高的再分块:
- 判断若是一块,认为是图片,跳过;
for j in range(len(rowList)):
rowStartY, rowEndY, rowImg = rowList[j]
h, w, startY, startX_, img_ = _opencv_ocr.getHW(rowImg)
if h > 0.09 * image.shape[0] :
colBlockList = _opencv_ocr.splitBigBlock(rowImg)
if len(colBlockList) == 1: #大图片,放过
continue
-
否则可能是多行遮挡,做分块再对每一块再分行处理(但有可能还遮挡着),如果一行,不是大图的,存起来;两行,则每一行都存起来;
else: #可能多行遮挡 for colBlockStartX, colBlockEndX in colBlockList: colBlockImg =rowImg[:,colBlockStartX:colBlockEndX] _, _, startY1, startX_1, img_1 = _opencv_ocr.getHW(colBlockImg) colBlockImg = colBlockImg[startY1: , :] newRowList ,startX = _opencv_ocr.splitRowAfterBlock(colBlockImg, rowStartY+startY1, rowEndY, colBlockStartX) #对每一块分行,并且连每块开始的绝对坐标一同返回 if len(newRowList)==1: hh, ww, startY2, _, _ = _opencv_ocr.getHW(newRowList[0][2]) if hh>0.9*newRowList[0][2].shape[0] and ww>0.9*newRowList[0][2].shape[1] and \ hh>30*dpiMultiple and ww>30*dpiMultiple: #一块大图片,放过 continue else: beginX.append(startX) splitRowList.append((newRowList[0][0], newRowList[0][1], newRowList[0][2])) else: #两行,则每一行都存起来 for i in range(len(newRowList)): _, _, startY1, startX_1, img_1 = _opencv_ocr.getHW(newRowList[i][2]) beginX.append(startX+startX_1) splitRowList.append((newRowList[i][0], newRowList[i][1], newRowList[i][2][:,startX_1:]
5. 对正常不太高的行保存
else:
beginX.append(0)
row_List.append((rowStartY, rowEndY, rowImg))
splitRowList.append((rowStartY, rowEndY, rowImg))
sumH += h
下边计算平均行高:
averH = int(sumH / len(row_List))
====
接下来把保存的新行列表splitRowList赋给rowList,
rowList=splitRowList
这个是处理过超高的行的,剩下的行,可能包含有两行重叠的(h不太大的算作了一行),下边需要对这个重叠遮挡进行处理,下边对可能有两行重叠的行(不是超级高,但稍微比平均行高高那么一些的),分块再分行。
for j in range(len(rowList)):
rowStartY, rowEndY, rowImg = rowList[j] #rowImg 是按行分块后的,有遮挡行,这里的每行应该切出来放一起
h, w, startY, startX_, img_ = _opencv_ocr.getHW(rowImg) #w是这一行左右边缘的字之间的距离,这里只用h
scale=float(w)/float(h)
if h > 0.1 * image.shape[0]: #超大,则跳过
continue
elif h > 1.2 * averH: #可能有两行重叠的
colBlockList = _opencv_ocr.splitBlock(rowImg) # 粗分切块
if len(colBlockList)==1:
Flag.append(False)
beginX_new.append(beginX[j])
splitRowList.append((rowStartY, rowEndY, rowImg))
else:
flag_=[]
for colBlockStartX, colBlockEndX in colBlockList:
colBlockImg = rowImg[:, colBlockStartX:colBlockEndX] # 块图
newRowList, startX = _opencv_ocr.splitRowAfterBlock(colBlockImg, rowStartY, rowEndY, colBlockStartX) # 新行
block_h=_opencv_ocr.get_block_hsize(colBlockImg)
_, block_w = colBlockImg.shape
block_scale = float(block_w) / float(block_h)
if block_scale<1.4 and len(newRowList)!=1 : #block_scale<1.5是单个字为一块的,若行投影为2行的 ,可能是上下结构的字,进一步判断
if len(newRowList)==2 and (newRowList[0][1]-newRowList[0][0])>5*(newRowList[1][1]-newRowList[1][0]): #上边一个大的二维码,下边是一小行字(扫一扫,查看电子报告),跳过
continue
else: #两行字不分行,直接返回原块
beginX_new.append(beginX[j] + startX)
flag_.append(True)
splitRowList.append((rowStartY, rowEndY, colBlockImg))
else:
for i in range(len(newRowList)):
flag_.append(True)
beginX_new.append(beginX[j] + startX)
splitRowList.append((newRowList[i][0], newRowList[i][1], newRowList[i][2]))
Flag.append(flag_)
else:
Flag.append(False)
beginX_new.append(beginX[j])
splitRowList.append((rowStartY, rowEndY, rowImg))
至此,就基本实现了分块,把splitRowList重新赋给rowList,就可以进行后续列投影处理了。
rowList=splitRowList
part two:列投影
1. 下边对行切分之后得到的行图进行列投影切成单个字符:
colList = _opencv_ocr.splitMultiRowCol(rowImg, row_start_x)
- 对于那些遮挡重叠的行,分块分行后得到的行图有可能是一行被拆出来的一小部分,这样进行列切分后,得到的不是每行(自然行)的所有字符,而后边结构化提取是针对每个自然行的字符进行处理的,所以需要对被分开的块人为的给拼成行,然后再对每行的字符坐标重排列,尽可能使其接近原先的行。处理后,重新拼成新的colList。
- 其中列切分里边又包含了多种情况:
- 直接正确切开成单字符的;
- 粘连在一起没切开的;
- 上下没切开的和误切的
这些都要在splitMultiRowCol()函数里边一一作出判断与处理。在此,先不做详细介绍,后续会单独补充。
part three: CNN识别
下边是对于列切分得到的字符进行识别:
char,confidence = _opencv_ocr.ocrOneChar(img)
part four: 后处理
对于切分成的单个字符,CNN识别结果人存在一定的不准确性,我们需要人为根据识别结果和语言模型来进行后处理,从而提高精确度。
part five:乱码筛除
- 对于识别结果,针对每一行得到的字符,统计中英文字符之和,若和所占比重小于0.7(自己根据情况设这个值),则认为该行乱码无效,删除;
- 当然,在整个ocr过程中,有手写签名的地方就很让人头疼了:切分阶段,由于其跨行造成的多行多列相互遮挡严重影响了切分;识别阶段,由于其字体各异,而且妖娆多姿的字体,很可能就被分成了多个乱七八糟的字符,无法准确识别,而且好多乱码造成了该行中英文字符所占比重下降,使得在乱码筛除阶段被整行筛除。
小结
这些就是整个ocr的基本思路和流程,当然写的有好多不详细的地方,包括最后的识别结果有时候也不是很精确,还需后续继续努力完善。