引
做OCR时遇到的一个重要的问题在于检测文本时容易把一段多行文本给检测成单行,这会导致在后期识别部分的准确率降低,毕竟把多行文字当成一行文字去识别,肯定无法得到准确地结果。因此在送入识别之前,需要对检测出的文本框内容进行多行文本检测与分割。也就是:
代码语言:javascript复制if (is_multi_row(box)):
rows = divite_rows(box)
do_next(rows)
else:
do_next(box)
所有检测出来的文本框都送入算法进行判断,如果是多行,则分割成多个单行文本后再送入识别;如果就是单行,那就直接送入识别。
通过调研后了解到,检测多行最常用的就是水平投影法,当然在执行水平投影之前还会进行多个形态学处理。使用水平投影法判断后,也可以很方便地得知分割的坐标点,从而分割成多个单行。
形态学处理
在做水平投影前,首先可以对文本图像进行形态学处理,形态学处理听起来高大上,其实也比较常见,最常用的就是腐蚀和膨胀。具体的讲解可以参考这篇文章:OpenCV学习笔记(五)形态学操作:腐蚀、膨胀,感觉写的挺好的。
简单说明一下功能,所谓腐蚀就是把图像中的颜色区域进行一定程度的“收缩”,使其的边缘毛躁部分被“圆润”掉,用在文字上则可以在一定程度上使一个个的文字“收缩”起来,使密集的文字不至于互相掺杂在一起。而膨胀就是把图像中的颜色区域进行一定程度的“扩大”,使其内部的小空洞被填充掉,用在文字上则可以在一定程度上使一个个文字变成一个个整块的字团。还有开运算和闭运算其实就是把腐蚀和膨胀结合起来使用。
当然以上所说的效果都是理想的情况,真正使用起来其实效果并没有那么完美,而且要根据情况对变换时使用的“核”进行调整,来找到最合适的尺寸。
这里我对文本图像做形态学处理之前,还进行了二值化,也就是设置一个阈值,根据每个像素点的色值将其转化为白色或者黑色,这样就将文本图像转化成了纯净的白底黑字图像,为形态学处理做准备。
然后我先做一次腐蚀,然后做一次膨胀,想法是先去除掉文字的周边线条,然后将它尽量填充成小方块。也就是希望让文本行之间的距离变大(去除文字周边线头的意义),同时文本行自身区域的像素点足够充实。这是为了分行做准备。代码如下:
代码语言:javascript复制 #二值化
(_, thresh) = cv2.threshold(img, 150, 255, cv2.THRESH_BINARY)
#扩大黑色面积,使效果更明显
# 定义核的矩形结构
kernel1 = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
kernel2 = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
handled = cv2.erode(thresh, kernel1, iterations = 1) # 先腐蚀
handled = cv2.dilate(thresh, kernel2, iterations = 1) # 再膨胀
水平投影法
预处理完后,就可以开始做水平投影了,所谓水平投影法,就是很简单,想象文本图像上有很多条水平直线,有些线穿过了文字区域,有些线在文本行之间穿过。记录下每条线穿过图像时遇到的黑色的像素点(文本部分才为黑色)数量,得到一个值,作为该条线所在y坐标下的值,就会得到一个曲线图,这个图上每个点的长度表示该y坐标上,黑色像素点的数量。在文本区域,因为有字,所以会有值。在文本行之间的空白区域,因为没字,所以值为0。那么我们最后得到的图像就会是一段有值的,一段0,一段有值的,一段0。这样我们就可以遍历这些值,遇到0表示是行间。所以如果我们在遇到值(文本行)之后,遇到了0(行间),然后又遇到了值(文本行),这就说明我们这个图像是个多行文本,否则,就不是多行的。同时,我们可以根据这些为0的y坐标点,判断文本行之间的分割点位置,也就能够做分割了。
说的简单,代码其实也简单。首先要遍历上面处理后的文本图像,记录每个y坐标代表的水平直线遇到的有值的像素点数量:
代码语言:javascript复制 height, width = handled.shape[:2]
z = [0]*height #记录每个y坐标遇到的有值的像素点的数量
#水平投影并统计每一行的黑点数
a = 0
for y in range(0, height):
for x in range(0, width):
if handled[y,x] == 0:
a = a 1
else :
continue
z[y] = a
a = 0
print("full ")
print (z)
记录完之后,就可以开始遍历我们记录的数组来判断是否为多行,并给出多行分割点了:
代码语言:javascript复制 begin = False
lastH = 0
h_list = []
division = []
for y in range(0,height):
if (z[y] > 0):
begin = True
elif (begin):
h_list.append(y - lastH)
lastH = y
division.append(y)
begin = False
if (z[height-1] > 0):
h_list.append(height - lastH)
if (len(h_list) > 1):
return True, division
else:
return False, division
这里的做法是,begin用来记录是否遇到一个新的文本行(z[y]有值),lastH记录文本行后遇到的第一个值为0的有坐标,h_list记录每个文本行的高度,如果这个数组数量大于1,说明文本多于一行,也就是判断为多行文本了。division记录用于分割文本行的y坐标点。在循环判断最后还要判断一次是因为最后一行文本可能直接到达了图像底部,如果不记录可能会把两行判断成一行了。
最后会返回是否为多行以及多行文本的分割y坐标点。
这里可以看到h_list其实没有被完全用完,其实还可以由此得出固定行高,来更好地判断多行分割点,另外对于分割点的选取也可以不用这么粗暴,而是选择值为0的中间点,也就是行间的中点,这样分割后的文本行图像比较好。
问题与优化
倾斜文本
这种方法其实也有问题,第一个问题在于只能处理水平的文本行,当然如果文本行是竖行的,那打不了统计竖直的像素点即可,问题是倾斜的文本行,比如:
倾斜文本行
这时直接用水平投影就无法奏效了,这时就需要加一个文本倾斜矫正,也就是文本旋转功能,将文本行部分转正。参考通过OpenCV和Python进行文本倾斜校正这篇文章,代码如下:
代码语言:javascript复制# 图片文本倾斜矫正
def rotate_img(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.bitwise_not(gray) # 将图片转成白字黑底
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1] # 将字都转成255,背景转成0
# 探索所有像素值大于0的坐标,使用坐标来计算包含所有这些坐标的旋转边框
coords = np.column_stack(np.where(thresh > 0))
angle = cv2.minAreaRect(coords)[-1] # 该函数返回[-90, 0]的角度
# 对角度进行处理
if angle < -45:
angle = -(90 angle)
else:
angle = -angle
# 进行旋转
(h, w) = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
rotated = cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
return rotated
传入文本图像,函数就会返回转正后的图像了。不过这个方法也不是完美有效,实际测试中还是会有小部分图像无法转正的。
密集文本
另一种不好处理的是密集文本行,这种文本行的行间距非常小,所以在做水平投影的时候,上下两行之间的字的线头会出现交错,这样会导致投影后在行间距的位置值并不为0,那就无法准确地判断和分割了,对于这种问题其实很难处理,想到的一个方法是把图像的高度进行拉伸,从而强行使文本行之间的区域变得稀疏,同时使用形态学处理更好地将字的“线头”腐蚀掉,不过效果也并不是特别完美的。
完整代码
代码语言:javascript复制# -*- coding: utf-8 -*-
import cv2
import numpy as np
import os
# 整行文字投影检验
def detect_rows_full(image):
img = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
bigNumber = 1 # 高度拉伸倍数
height, width = img.shape[:2]
resized_img = cv2.resize(img, (width,bigNumber*height), interpolation=cv2.INTER_LINEAR)
#二值化
(_, thresh) = cv2.threshold(resized_img, 150, 255, cv2.THRESH_BINARY)
#扩大黑色面积,使效果更明显
kernel1 = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
kernel2 = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
handled = cv2.erode(thresh, kernel1, iterations = 1) # 先腐蚀
handled = cv2.dilate(thresh, kernel2, iterations = 1) # 再膨胀
height, width = handled.shape[:2]
z = [0]*height
a = 0
#水平投影并统计每一行的黑点数
a = 0
for y in range(0, height):
for x in range(0, width):
if handled[y,x] == 0:
a = a 1
else :
continue
z[y] = a
a = 0
print("full ")
print (z)
begin = False
lastH = 0
h_list = []
division = []
for y in range(0,height):
if (z[y] > 0):
begin = True
elif (begin):
h_list.append(y - lastH)
lastH = y
division.append(y)
begin = False
if (z[height-1] > 0):
h_list.append(height - lastH)
if (len(h_list) > 1):
return True, division
else:
return False, division
# 图片文本倾斜矫正
def rotate_img(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.bitwise_not(gray) # 将图片转成白字黑底
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1] # 将字都转成255,背景转成0
# 探索所有像素值大于0的坐标,使用坐标来计算包含所有这些坐标的旋转边框
coords = np.column_stack(np.where(thresh > 0))
angle = cv2.minAreaRect(coords)[-1] # 该函数返回[-90, 0]的角度
# 对角度进行处理
if angle < -45:
angle = -(90 angle)
else:
angle = -angle
# 进行旋转
(h, w) = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
rotated = cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
return rotated
def detect_divite_multi_row(image):
rotated = rotate_img(image)
# 判断多行
is_multi, division = detect_rows_full(rotated)
if (is_multi):
cv2.imwrite(("./multi_imgs/multi_" str(yesNumber) ".png"), rotated)
for i,line in enumerate(division):
# 分割多行