Python Opencv 图片识别表格:线条信息计算(投影算法)
通过OpenCV算法识别出图片中的框线后,面临几个问题:
线条变换为绝对水平和垂直
线条并非绝对的水平或垂直,需要变换为绝对的水平线或垂直线。简单的算法是可以选择线条的起点或终点作为坐标,但最好是可以选择线条的中心的坐标。这里设计了一种轴心投影法,通过线条在垂直轴上的投影得到中心点的坐标。以横线条为例,将线条的cv矩阵传入,计算在数据i轴上的投影,得到中心的i位置,即为绝对水平线的y轴坐标。这里一定要注意数据矩阵中的i、j分别对应着cv坐标轴的y和x。
通过投影算法,可以得到线条垂直坐标轴的轴心,同理,将线条在j轴上进行投影就可以得到线条的绝对水平或垂直的长度。
仔细分析后我们会发现,在i轴上进行投影算法能得到绝对横线的宽度,在j轴上投影可以得到绝对横线的长度。那我们是否需要写一个i轴投影算法和一个j轴投影算法呢?其实投影原理都一样,只是投影的轴不同。如果将横线转置,向i轴投影,是不是就得到了绝对横线的长度呢?这样我们就可以复用同一套投影算法了。
如果是竖线怎么办呢?竖线刚好相反,向j轴投影得到宽度,向i轴投影得到长度。如何复用同一套线段信息计算代码呢?没错,将竖线转置后就变成横线了,算法一致了!
完成了以上思路的梳理,我们就可以写出对应的投影算法了。
线条的断裂
另一个问题是线条的断裂,由于干扰因素导致一根线条中间出现了断裂,为了应对这种问题,我们在投影计算时支持了break,即允许在投影截面上出现几个像素的断裂。
线条被分割为多条线段
不是所有的表格框线都是一直连续的,看下面这个例子,红框中的表格框线被两个合并单元格分割成了三段。在线条信息计算时,需要精确的计算出每条线段起止坐标信息。为什么呢?这是因为稍后我们进行单元格重建算法的时候,需要知道表格中每一个横纵交叉点的完整情况,来决定每个单元格到底有几行几列。这个问题在表格结构分析算法会详细谈到的。
完整算法
根据以上的分析,我们写出了下面完整的算法,实现了线条的投影计算、宽度计算、长度计算、线条中的线段信息的计算,支持了干扰造成的断裂等情况。
'''
计算线条的完整信息,包括线条的轴心位置、线条起止pos、内部线段的信息
原理:通过坐标轴投影算法,获取垂直于该坐标轴的线段在该轴上的中心位置
前提:输入的lines_matrix必须是i,j二维数组,且i为要投影的坐标轴(相当于cv图像的y轴,若要投影x轴请先转置后传入)
输入:lines_matrix-线段的二值变反矩阵;max_break-最大支持线段中间出现的断裂(像素数量)
输出:线段信息list
line_info = {
'axis': 0, # 线条轴心
'wide': 0, # 线条粗细
'len': 0, # 线条总长度(线段长度之和)
'segment': [], # 线条内部的线段信息 [[线段长度, 线段start, 线段end],...] 支持一根线条被分割为多条线段(中间跨域一个或多个合并单元格)
}
输入示例:lines_matrix
i0-------------------------> j
i1-------------------------> j
i2-------------------------> j
i3-------------------------> j
'''
def calc_line_info(lines_matrix, max_break, debug=False):
# 计算i轴每个位置的投影是否有像素值
project_i = [any(x) for x in lines_matrix] # 对每个i对应的list进行any操作,求出i轴上该位置是否出现像素点
# 取出有像素值的i轴pos
pos_i = [i for i,x in enumerate(project_i) if x==True]
# 异常检测:若只检测到一条线或没有线 则返回空
if len(pos_i)<=1:
return []
# 将连续的pos分组(支持连续pos出现10个像素的断裂)
pos_group_i = []
temp_group = [pos_i[0]] # 第一个pos默认满足要求,放入临时结果中
# 可调参数 线段断层截面像素点
for i in range(1,len(pos_i)): # 从第二个pos开始计算
if pos_i[i]-pos_i[i-1]<=5: # 连续像素计为一组,支持截面断层
temp_group.append(pos_i[i])
else:
pos_group_i.append(temp_group) # 上一组结束,放入结果中
temp_group = [pos_i[i]] # 新一组第一个pos默认满足要求,放入临时结果中
# 最后一组pos放入结果中
pos_group_i.append(temp_group)
'''
线条信息 数据结构
'''
line_info = {
'axis': 0, # 线条轴心
'wide': 0, # 线条粗细
'len': 0, # 线条总长度(线段长度之和)
'segment': [], # 线条内部的线段信息 [[线段长度, 线段start, 线段end],...] 支持一根线条被分割为多条线段(中间跨域一个或多个合并单元格)
}
lines_info = []
for (i, poses) in enumerate(pos_group_i):
info = line_info.copy()
info['axis'] = int(np.median(poses))
info['wide'] = poses[-1]-poses[0]+1
'''
计算图像中线段的长度(即在j轴像素点的个数)
注意有可能一条线段被分割成了多个分段(中间出现了合并单元格),因此该算法需要返回线段长度list[]
同时要支持干扰造成的线段中间出现的断裂
'''
# 获取该条线所在的矩阵 并转置
area = np.transpose( lines_matrix[poses[0]:poses[-1]+1] )
# 取得每个投影点的像素情况
mask = [str(any(x)+0) for x in area] # any(x)+0可以将True False转为1 0
# 将mask中连续的1分割出来,每段连续的1即为一条线段
s = ''.join(mask).split('0')
segs = [ [i,len(v)] for (i,v) in enumerate(s) if len(v)>0 ]
# 调整每条线段在原始list中正确的pos(segs中的i只是s数组中的位置,并非mask中的位置)
for i in range(1,len(segs)):
# 每条线段的pos = segs中的i + 之前线段的总长度
segs[i][0] = segs[i][0] + sum([x[1]-1 for (j,x) in enumerate(segs) if j<i])
segments = [segs[0]] # 初始化为第一条线段
# 若有多条线段,进行智能线段分析:因干扰造成的10像素内的断裂自动连在一起
MAX_LEN_BREAK = max_break # 最大支持线段断裂长度
if len(segs)>1:
# 从第二条线段开始判断与上一条线段之间是断裂还是间隔
for i in range(1,len(segs)):
delta = segs[i][0] - segs[i-1][0] - segs[i-1][1]
if delta<MAX_LEN_BREAK:
# 小于断裂长度,应连接线段
segments[-1] = [ segments[-1][0], segs[i][0]-segments[-1][0]+segs[i][1] ]
else:
# 为间隔,是不同的线段
segments.append(segs[i])
# 线段数据重组为:[线段长度, 线段start, 线段end]
segments = [ [x[1], x[0], x[0]+x[1]] for x in segments ]
info['segment'] = segments
info['len'] = sum([x[0] for x in segments])
lines_info.append(info)
if debug:
print('\n\n----------lines info------------')
x = [print(v) for v in lines_info]
return lines_info
那么calc_line_info传入的参数是什么呢?就是上一篇文章讲到的横线检测和竖线检测算法得到的横线和竖线的cv矩阵。