文章目录
- 1 背景简述
- 2 camelot中的方法
- 2.1 二值化
- 2.2 腐蚀膨胀
- 2.3 轮廓检测
- 2.4 结果展示
- 3 基于霍夫直线检测的方法
- 3.1 霍夫直线检测原理
- 3.2 概率霍夫直线检测
- 3.3 霍夫直线应用
- 参考资料
1 背景简述
图像中的表格结构化是一个比较热门的话题,其输入是一张图片,输出是结构化过的所有表格,也可以认为输出的是一个excel。目前市面上也没有哪家做的比较完美,因为表格总是千奇百怪的。不过对于一些简单规整的有线表或者多线表,还是可以做到比较好的结构化的。
图像表格检测的一般流程为
【表格检测】是为了找到图像上的表格位置,同时分开一些挨得比较近的表,需要训练一个图像检测的模型,这个标一批数据硬train就行了,Yolov5等一般的图像检测模型效果都不错。不过对于只有一行的表,检测效果不太好,这个得要传统方法的辅助。
【水平线和垂直线检测】是为了检测表格中的分割线,对表格结构化有很大的参考意义。某些单行表也可以通过这步的结果来判断。或者说四边有线的,就可以用这里的结果来判断表格的位置。这篇讲的就是这一步。
【OCR】是为了得到表格中每个单元格的文本。
【表格结构化】是结合前三个模块的结果,得到结构化的表格,这里根据要处理的业务场景中表格的多样性程度,会有不同代码量的规则。我处理的场景得要写了几千行的规则了。
这篇只讲讲怎么把图像中的表格线给检测出来。
方案主要有两种:
(1)二值化+腐蚀膨胀+轮廓检测,这是camelot中使用的方法。
(2)边缘检测+霍夫直线检测,这是网上见到比较多的方法。
下面就来说说这两种方法,所使用的图片就是
取这张图片是因为图中的表格又有实线,又有虚线。方便比较不同方法的效果。
2 camelot中的方法
2.1 二值化
二值化之用了opencv当中的cv2.adaptiveThreshold,这种二值化的方法可以根据局部的色差来自适应调整阈值,比较符合表格背景色花里胡哨的场景。
def adaptive_threshold(imagename, process_background=False, blocksize=15, c=-2):"""Thresholds an image using OpenCV's adaptiveThreshold.Parameters----------imagename : stringPath to image file.process_background : bool, optional (default: False)Whether or not to process lines that are in background.blocksize : int, optional (default: 15)Size of a pixel neighborhood that is used to calculate athreshold value for the pixel: 3, 5, 7, and so on.For more information, refer `OpenCV's adaptiveThreshold <https://docs.opencv.org/2.4/modules/imgproc/doc/miscellaneous_transformations.html#adaptivethreshold>`_.c : int, optional (default: -2)Constant subtracted from the mean or weighted mean.Normally, it is positive but may be zero or negative as well.For more information, refer `OpenCV's adaptiveThreshold <https://docs.opencv.org/2.4/modules/imgproc/doc/miscellaneous_transformations.html#adaptivethreshold>`_.Returns-------img : objectnumpy.ndarray representing the original image.threshold : objectnumpy.ndarray representing the thresholded image."""img = cv2.imread(imagename)gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)if process_background:threshold = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blocksize, c)else:threshold = cv2.adaptiveThreshold(np.invert(gray),255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,cv2.THRESH_BINARY,blocksize,c,)return img, threshold
使用的时候,直接用就行
image, threshold = adaptive_threshold(image_path,process_background=False,blocksize=11,c=-2,
)
这里的threshold就是二值化之后的图像。
2.2 腐蚀膨胀
腐蚀膨胀的目的是把沿水平和竖直方向的长线给找出来。腐蚀是当kernel范围内有0时,就全部置0,滤掉了不连续的像素点;膨胀是把kernel中心为255的点膨胀成kernel的大小,把原来线段上被腐蚀的点给还原回来。
举个例子,我们先构造一个垂直方向长度为5的kernel。
import cv2
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 5))
kernel为
array([[1],[1],[1],[1],[1]], dtype=uint8)
情况一:
我们先构造一个数值方向长度不足5的直线,并用kernel腐蚀一下
import numpy as np
img = np.zeros((10, 5))
img[1:5, 0] = 255
erode_img = cv2.erode(img, kernel)
img为
array([[ 0., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.]])
erode_img为
array([[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.]])
情况二:
我们再构造一个数值方向长度足够5的直线,并用kernel腐蚀一下
import numpy as np
img = np.zeros((10, 5))
img[1:6, 0] = 255
erode_img = cv2.erode(img, kernel)
img为
array([[ 0., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.],[ 0., 0., 0., 0., 0.]])
erode_img为
array([[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[255., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.],[0., 0., 0., 0., 0.]])
膨胀的话,就可以把没有完全被腐蚀掉的点,恢复回线,这里就不举例啰嗦了。
2.3 轮廓检测
轮廓检测部分是把腐蚀膨胀得到的线给找出来,这个和腐蚀膨胀在同一个函数里
def find_lines(threshold, regions=None, direction="horizontal", line_scale=15, iterations=0
):"""Finds horizontal and vertical lines by applying morphologicaltransformations on an image.Parameters----------threshold : objectnumpy.ndarray representing the thresholded image.regions : list, optional (default: None)List of page regions that may contain tables of the form x1,y1,x2,y2where (x1, y1) -> left-top and (x2, y2) -> right-bottomin image coordinate space.direction : string, optional (default: 'horizontal')Specifies whether to find vertical or horizontal lines.line_scale : int, optional (default: 15)Factor by which the page dimensions will be divided to getsmallest length of lines that should be detected.The larger this value, smaller the detected lines. Making ittoo large will lead to text being detected as lines.iterations : int, optional (default: 0)Number of times for erosion/dilation is applied.For more information, refer `OpenCV's dilate <https://docs.opencv.org/2.4/modules/imgproc/doc/filtering.html#dilate>`_.Returns-------dmask : objectnumpy.ndarray representing pixels where vertical/horizontallines lie.lines : listList of tuples representing vertical/horizontal lines withcoordinates relative to a left-top origin inimage coordinate space."""lines = []if direction == "vertical":size = threshold.shape[0] // line_scaleif size < 2:size = threshold.shape[0]el = cv2.getStructuringElement(cv2.MORPH_RECT, (1, size))elif direction == "horizontal":size = threshold.shape[1] // line_scaleif size < 2:size = threshold.shape[1]el = cv2.getStructuringElement(cv2.MORPH_RECT, (size, 1))elif direction is None:raise ValueError("Specify direction as either 'vertical' or 'horizontal'")if regions is not None:region_mask = np.zeros(threshold.shape)for region in regions:x, y, w, h = regionregion_mask[y : y + h, x : x + w] = 1threshold = np.multiply(threshold, region_mask)threshold = cv2.erode(threshold, el)threshold = cv2.dilate(threshold, el)dmask = cv2.dilate(threshold, el, iterations=iterations)try:contours, _ = cv2.findContours(threshold.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)except ValueError:# for opencv backward compatibility_, contours, _ = cv2.findContours(threshold.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)for c in contours:x, y, w, h = cv2.boundingRect(c)x1, x2 = x, x + wy1, y2 = y, y + hif direction == "vertical":lines.append(((x1 + x2) // 2, y1, (x1 + x2) // 2, y2))elif direction == "horizontal":lines.append((x1, (y1 + y2) // 2, x2, (y1 + y2) // 2))return dmask, lines
横线和竖线的检测代码为
# 竖线检测
iterations = 0
vertical_line_scale = 60
regions = None
vertical_mask, vertical_segments = find_lines(threshold,regions=regions,direction="vertical",line_scale=vertical_line_scale,iterations=iterations,
)# 横线检测
iterations = 0
horizontal_line_scale = 50
regions = None
horizontal_mask, horizontal_segments = find_lines(threshold,regions=regions,direction="horizontal",line_scale=horizontal_line_scale,iterations=iterations,
)
通过控制vertical_line_scale和horizontal_line_scale可以控制最小线段长度。vertical_mask和horizontal_mask是二值图像,vertical_segments和horizontal_segments是线段的位置。
2.4 结果展示
按这种方法检测出来的线段如下图2-1所示。
不难看出,这种方法下,需要的实线都找到了,但是某些大字上的笔画也被认为是线段,更严重的是,虚线检测不出来。
在表格中没有虚线的场景下,这其实是一个简单快捷准确的方案。
3 基于霍夫直线检测的方法
为了解决虚线没法检测出来的问题,就想到了霍夫直线检测。这里简单说明一下霍夫直线检测是怎么回事。
3.1 霍夫直线检测原理
霍夫直线检测想明白了很简单,初中的知识就能解了。
要想明白这个问题,首先得要知道过xy坐标系上的某个点(x0,y0)(x_0, y_0)(x0,y0)的所有直线如何表示。我们都知道,一条直线可以用斜率kkk和截距bbb唯一确定为y=kx+by=kx+by=kx+b。我们再构造一个kb坐标系,横轴为kkk,纵轴为bbb。那么这个坐标系上的任意一点(ki,bi)(k_i, b_i)(ki,bi)就是xy坐标系上的一条直线。再说一遍,kb坐标系上的一个点,就代表了xy坐标系上的一条直线。
那么好了,过(x0,y0)(x_0, y_0)(x0,y0)的所有直线在kb坐标系上就是直线
y0=kx0+b→{k=−1x0b+y0x0ifx0≠0b=y0ifx0=0(3-1)y_0 = kx_0 + b \rightarrow \begin{cases} k = -\frac{1}{x_0}b + \frac{y_0}{x_0} &if\ x_0 \ne 0\\ b = y_0 &if\ x_0 =0 \end{cases} \tag{3-1} y0=kx0+b→{k=−x01b+x0y0b=y0if x0=0if x0=0(3-1)
kb坐标系上的一条直线,就是过xy坐标系上的某个点的所有直线。
同理,假设有另一个点(x1,y1)(x_1, y_1)(x1,y1),过该点的所有直线在kb坐标系上的直线为y1=kx1+by_1=kx_1+by1=kx1+b。
方程组(3−2)(3-2)(3−2)的解(k∗,b∗)(k^*, b^*)(k∗,b∗),就是过(x0,y0)(x_0, y_0)(x0,y0)和(x1,y1)(x_1, y_1)(x1,y1)这两点所确定的直线。
{y0=kx0+by1=kx1+b(3-2)\begin{cases} y_0 = kx_0 + b \\ y_1=kx_1+b \end{cases} \tag{3-2} {y0=kx0+by1=kx1+b(3-2)
xy坐标系同一直线上的所有点的所有直线的表示,在kb坐标系上必定都过同一个点,如下图3-1所示。图是从参考资料[2]借过来,所以符号不一致,懒得画了。
这样以来,我们就可以根据kb空间上某个点被多少条直线穿过来判断在xy坐标系上有多少个点在这条直线上。
不过映射到kb坐标系会有一个问题,当xy坐标系上的直线接近于平行y轴时,k也会接近于无穷大,无穷大就没法算了。为了解决这个问题,就提出了把xy空间映射到极坐标θr\theta rθr空间。
映射方法如下图3-2所示,这图是借的参考资料[3]的。xy坐标系中的每条直线都θr\theta rθr空间中的一个点(θ,r)(\theta, r)(θ,r),rrr为xy坐标系中原点距离直线的距离,θ\thetaθ表示原点到直线的垂线与x轴的夹角;xy坐标系中过某个点的所有直线都对应于θr\theta rθr空间中的一条曲线r=xicosθ+yisinθr = x_i cos\theta + y_i sin\thetar=xicosθ+yisinθ。
如果这里想不明白为啥r=xicosθ+yisinθr = x_i cos\theta + y_i sin\thetar=xicosθ+yisinθ可以表示xy空间过(xi,yi)(x_i, y_i)(xi,yi)的所有直线。不妨这样想一下,某条直线过图3-2中的点(x2,y2)(x_2, y_2)(x2,y2),刚开始是和y轴平行的,即θ=0\theta=0θ=0,然后开始绕(x2,y2)(x_2, y_2)(x2,y2)旋转,θ\thetaθ不断变大,直到转过2π2\pi2π。这个转动的过程遍历了所有过(x2,y2)(x_2, y_2)(x2,y2)的直线,而且动手画辅助线算算看的话,会发现rrr一直满足r=x2cosθ+y2sinθr = x_2 cos\theta + y_2 sin\thetar=x2cosθ+y2sinθ。
与kb坐标系不同,这里θ\thetaθ就是[0,2π)[0, 2\pi)[0,2π)的取值范围,rrr只要点(xi,yi)(x_i, y_i)(xi,yi)离原点是有限距离的就行,这个在实际场景都能满足。不会产生无限大的值。
至于怎么找直线,也是和kb坐标系一样,在θr\theta rθr空间找很多条曲线相交的那个点,就是xy空间的直线。点数量设置一个阈值,不让太短的线进来就行。
霍夫直线的好处是可以找到虚线。
3.2 概率霍夫直线检测
霍夫直线检测一般需要先把图像过边缘检测(比如canny),然后再将所有的边缘点映射到θr\theta rθr空间后寻找被超过一定数量的曲线相交的那些点。这样有两个缺点,一是计算量太大,二是不知道线段的真实长度。所以就有了概率霍夫直线检测。
概率霍夫会取边缘点的一个子集,来进行θr\theta rθr空间交点的统计,有一个累加器(Hough accumulator)会统计候选点被曲线穿过的次数。这大大减小了计算量,根据参考资料[6]说的,只要2%的边缘点,就有比较好的效果了。由于使用的是子集,所以点数量的阈值也要相应地调小。
根据阈值确定了候选点之后,概率霍夫会去边缘点的全集上找还有哪些点也在这条直线上,并发间隔太大的点过滤掉,这样以来就可以找到一条线段上的所有点了。
这样以来计算量大和不知道线段真实长度的问题就都解决了。
3.3 霍夫直线应用
霍夫直线检测在opencv中对应于cv2.HoughLines这个函数,只返回θ\thetaθ和rrr。概率霍夫在opencv中对应于cv2.HoughLinesP这个函数,返回线段的起始点和终止点坐标。这两个函数的参数说明可以参考参考资料[7],这里就不说了。
直接上代码,总的来说就两步,先Canny边缘检测,再概率霍夫。
import cv2im = cv2.imread(image_path)
gray = cv2.cvtColor(im,cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 150, 200, apertureSize=3)
img = im.copy()
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 100, minLineLength = 100, maxLineGap = 10)
这样得到的结果如下图3-3所示。
不难看出,虚线出来了,但是有三个问题,一是文字的笔画也被认为是直线了,二是有挺多接近于重合的直线,三是有斜线出现。这几个问题都可以通过后处理来解决。
ocr的结果可以提供文字的位置,那些文字上的直线可以用这个信息过滤掉;接近于重合的直线可以根据直线的距离过滤掉;斜线根据斜率过滤掉即可,用表格检测的检测框也能过滤掉一大波线,因为我们只要表格里的表格线。
整体来说,方法总比困难多。
参考资料
[1] https://github.com/atlanhq/camelot
[2] https://blog.csdn.net/lkj345/article/details/50699981
[3] https://congleetea.github.io/blog/2018/09/28/hough-transform/
[4] https://stackoverflow.com/questions/59340367/how-does-the-probabilistic-hough-transform-compute-the-end-points-of-lines
[5] https://blog.csdn.net/zhaocj/article/details/40047397
[6] https://jayrambhia.com/blog/probabilistic-hough-transform
[7] https://www.cxyzjd.com/article/hihell/113670582