图像表格实线和虚线检测

1 背景简述

图像中的表格结构化是一个比较热门的话题,其输入是一张图片,输出是结构化过的所有表格,也可以认为输出的是一个excel。目前市面上也没有哪家做的比较完美,因为表格总是千奇百怪的。不过对于一些简单规整的有线表或者多线表,还是可以做到比较好的结构化的。

图像表格检测的一般流程为
图像表格检测流程

图1-1 图像表格检测流程图

【表格检测】是为了找到图像上的表格位置,同时分开一些挨得比较近的表,需要训练一个图像检测的模型,这个标一批数据硬train就行了,Yolov5等一般的图像检测模型效果都不错。不过对于只有一行的表,检测效果不太好,这个得要传统方法的辅助。

【水平线和垂直线检测】是为了检测表格中的分割线,对表格结构化有很大的参考意义。某些单行表也可以通过这步的结果来判断。或者说四边有线的,就可以用这里的结果来判断表格的位置。这篇讲的就是这一步。

【OCR】是为了得到表格中每个单元格的文本。

【表格结构化】是结合前三个模块的结果,得到结构化的表格,这里根据要处理的业务场景中表格的多样性程度,会有不同代码量的规则。我处理的场景得要写了几千行的规则了。

这篇只讲讲怎么把图像中的表格线给检测出来。

方案主要有两种:
(1)二值化+腐蚀膨胀+轮廓检测,这是camelot中使用的方法。
(2)边缘检测+霍夫直线检测,这是网上见到比较多的方法。

下面就来说说这两种方法,所使用的图片就是
测试图片样例

图1-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 : string
        Path 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 a
        threshold 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 : object
        numpy.ndarray representing the original image.
    threshold : object
        numpy.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 morphological
    transformations on an image.

    Parameters
    ----------
    threshold : object
        numpy.ndarray representing the thresholded image.
    regions : list, optional (default: None)
        List of page regions that may contain tables of the form x1,y1,x2,y2
        where (x1, y1) -> left-top and (x2, y2) -> right-bottom
        in 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 get
        smallest length of lines that should be detected.

        The larger this value, smaller the detected lines. Making it
        too 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 : object
        numpy.ndarray representing pixels where vertical/horizontal
        lines lie.
    lines : list
        List of tuples representing vertical/horizontal lines with
        coordinates relative to a left-top origin in
        image coordinate space.

    """
    lines = []

    if direction == "vertical":
        size = threshold.shape[0] // line_scale
        if size < 2:
            size = threshold.shape[0]
        el = cv2.getStructuringElement(cv2.MORPH_RECT, (1, size))
    elif direction == "horizontal":
        size = threshold.shape[1] // line_scale
        if 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 = region
            region_mask[y : y + h, x : x + w] = 1
        threshold = 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 + w
        y1, y2 = y, y + h
        if 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所示。
结果展示图

图2-1 结果展示图

不难看出,这种方法下,需要的实线都找到了,但是某些大字上的笔画也被认为是线段,更严重的是,虚线检测不出来。

在表格中没有虚线的场景下,这其实是一个简单快捷准确的方案。

3 基于霍夫直线检测的方法

为了解决虚线没法检测出来的问题,就想到了霍夫直线检测。这里简单说明一下霍夫直线检测是怎么回事。

3.1 霍夫直线检测原理

霍夫直线检测想明白了很简单,初中的知识就能解了。

要想明白这个问题,首先得要知道过xy坐标系上的某个点 ( x 0 , y 0 ) (x_0, y_0) (x0,y0)的所有直线如何表示。我们都知道,一条直线可以用斜率 k k k和截距 b b b唯一确定为 y = k x + b y=kx+b y=kx+b。我们再构造一个kb坐标系,横轴为 k k k,纵轴为 b b b。那么这个坐标系上的任意一点 ( k i , b i ) (k_i, b_i) (ki,bi)就是xy坐标系上的一条直线。再说一遍,kb坐标系上的一个点,就代表了xy坐标系上的一条直线

那么好了,过 ( x 0 , y 0 ) (x_0, y_0) (x0,y0)的所有直线在kb坐标系上就是直线

y 0 = k x 0 + b → { k = − 1 x 0 b + y 0 x 0 i f   x 0 ≠ 0 b = y 0 i f   x 0 = 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坐标系上的某个点的所有直线

同理,假设有另一个点 ( x 1 , y 1 ) (x_1, y_1) (x1,y1),过该点的所有直线在kb坐标系上的直线为 y 1 = k x 1 + b y_1=kx_1+b y1=kx1+b

方程组 ( 3 − 2 ) (3-2) (32)的解 ( k ∗ , b ∗ ) (k^*, b^*) (k,b),就是过 ( x 0 , y 0 ) (x_0, y_0) (x0,y0) ( x 1 , y 1 ) (x_1, y_1) (x1,y1)这两点所确定的直线。

{ y 0 = k x 0 + b y 1 = k x 1 + 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]借过来,所以符号不一致,懒得画了。
xy和kb空间映射图

图3-1 xy和kb空间映射图

这样以来,我们就可以根据kb空间上某个点被多少条直线穿过来判断在xy坐标系上有多少个点在这条直线上。

不过映射到kb坐标系会有一个问题,当xy坐标系上的直线接近于平行y轴时,k也会接近于无穷大,无穷大就没法算了。为了解决这个问题,就提出了把xy空间映射到极坐标 θ r \theta r θr空间。

映射方法如下图3-2所示,这图是借的参考资料[3]的。xy坐标系中的每条直线都 θ r \theta r θr空间中的一个点 ( θ , r ) (\theta, r) (θ,r) r r r为xy坐标系中原点距离直线的距离, θ \theta θ表示原点到直线的垂线与x轴的夹角;xy坐标系中过某个点的所有直线都对应于 θ r \theta r θr空间中的一条曲线 r = x i c o s θ + y i s i n θ r = x_i cos\theta + y_i sin\theta r=xicosθ+yisinθ
xy和极坐标映射图

图3-2 xy和极坐标空间映射图

如果这里想不明白为啥 r = x i c o s θ + y i s i n θ r = x_i cos\theta + y_i sin\theta r=xicosθ+yisinθ可以表示xy空间过 ( x i , y i ) (x_i, y_i) (xi,yi)的所有直线。不妨这样想一下,某条直线过图3-2中的点 ( x 2 , y 2 ) (x_2, y_2) (x2,y2),刚开始是和y轴平行的,即 θ = 0 \theta=0 θ=0,然后开始绕 ( x 2 , y 2 ) (x_2, y_2) (x2,y2)旋转, θ \theta θ不断变大,直到转过 2 π 2\pi 2π。这个转动的过程遍历了所有过 ( x 2 , y 2 ) (x_2, y_2) (x2,y2)的直线,而且动手画辅助线算算看的话,会发现 r r r一直满足 r = x 2 c o s θ + y 2 s i n θ r = x_2 cos\theta + y_2 sin\theta r=x2cosθ+y2sinθ

与kb坐标系不同,这里 θ \theta θ就是 [ 0 , 2 π ) [0, 2\pi) [0,2π)的取值范围, r r r只要点 ( x i , y i ) (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 θ r r r。概率霍夫在opencv中对应于cv2.HoughLinesP这个函数,返回线段的起始点和终止点坐标。这两个函数的参数说明可以参考参考资料[7],这里就不说了。

直接上代码,总的来说就两步,先Canny边缘检测,再概率霍夫。

import cv2

im = 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所示。
概率霍夫结果

图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

  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

七元权

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值