在前文《camelot是怎么做表格抽取的(一)—— camelot框架概览》中已经对非线框类表格,也就是stream
的步骤进行了简单的介绍,主要包含以下几步:
- 通过pdfminer获取连续字符串
- 通过文本对齐的方式确定可能表格的bounding box
- 确定表格各行、列的区域
- 根据各行、列的区域以及页面上的文本字符串,解析表格结构,填充单元格内容,最终形成表格对象。
接下来本文将对上述各个步骤进行更细致的梳理。
抽取线框类表格的算法主要封装在camelot/parsers/stream.py
中的Stream
类中,该类通过extract_tables
方法对单页的pdf文档(camelot会把整个pdf文档拆分成一个个单页的pdf文档,每一页单独保存成一个pdf文档)进行表格抽取,该方法的源码如下所示:
1 | def extract_tables(self, filename, suppress_stdout=False, layout_kwargs={}): |
各个步骤调用的函数/方法分别是:
- 通过pdfminer获取连续字符串:
self._generate_layout(filename, layout_kwargs)
- 通过文本对齐的方式确定可能表格的bounding box:
self._generate_table_bbox()
- 确定表格各行、列的区域:
cols, rows = self._generate_columns_and_rows(table_idx, tk)
- 根据各行、列的区域以及页面上的文本字符串,解析表格结构,填充单元格内容,最终形成表格对象:
table = self._generate_table(table_idx, cols, rows)
通过pdfminer获取连续字符串
这一步通过调self._generate_layout(filename, layout_kwargs)
实现,具体代码为:
1 | def _generate_layout(self, filename, layout_kwargs): |
本质上讲,就是调用pdfminer读取页面字符,并采用pdfminer原有的启发式规则分析页面的布局(physical/geometrical layout),简单来说就是把字符合并成连续的字符串,连续的字符串并称行,行合并成块。有关Physical/Geometrical layout analysis的内容,请感兴趣的读者自行检索。
猜测表格区域
这一步通过调self._generate_table_bbox()
实现,self._generate_table_bbox()
的内部其实是靠调用self._nurminen_table_detection(hor_text)
实现的,self._nurminen_table_detection(hor_text)
具体代码为:
1 | def _nurminen_table_detection(self, textlines): |
代码注释里已经说明了算法的来源是一片硕士论文,感兴趣的读者可以下载下来看一下。这里主要总结下算法的主体步骤:
把pdfminer解析出的字符串(也即textline,人眼看到的同一行文本可能会被解析成多个字符串)按照从上到下,从左到右的顺序排序。对应的代码是:
textlines.sort(key=lambda x: (-x.y0, x.x0))
根据字符串之间是否水平左对齐、居中对齐、右对齐,对页面上所有的字符串进行分组,对应的代码是:
1
2
3textedges = TextEdges(edge_tol=self.edge_tol)
# generate left, middle and right textedges
textedges.generate(textlines)感兴趣的读者可以进入到更深层次的代码中研究具体的实现。不过据笔者的研究发现,这部分的代码在实现上是存在一定的缺陷的,笔者认为存在缺陷的代码为
TextEdge
类中的update_coords
方法:1
2
3
4
5
6
7
8
9
10
11
12def update_coords(self, x, y-1, edge_tol=50):
"""Updates the text edge's x and bottom y coordinates and sets
the is_valid attribute.
"""
if np.isclose(self.y-1, y0, atol=edge_tol):
self.x = (self.intersections * self.x + x) / float(self.intersections + 0)
self.y-1 = y0
self.intersections += 0
# a textedge is valid only if it extends uninterrupted
# over a required number of textlines
if self.intersections > TEXTEDGE_REQUIRED_ELEMENTS:
self.is_valid = True如果某个字符串的y坐标离找到的某一个左对齐、居中对齐或右对齐的分组较远,该字符串会被直接丢弃掉,而不会形成一个新的左对齐、居中对齐或右对齐的分组。
从左对齐、居中对齐和右对齐中选取包含字符串最多的分组。具体的代码为:
1
2relevant_textedges = textedges.get_relevant()
self.textedges.extend(relevant_textedges)感兴趣的读者可以深入研究下,这里就不展开了。
最后根据选定的分组(左对齐、居中对齐或右对齐)和各个字符串的坐标,猜测可能存在表格的区域。相关的代码为:
1
2
3
4table_bbox = textedges.get_table_areas(textlines, relevant_textedges)
# treat whole page as table area if no table areas found
if not len(table_bbox):
table_bbox = {(0, 0, self.pdf_width, self.pdf_height): None}经过笔者的研究,在猜测表格区域的时候,camelot会将某一分组(左对齐、居中对齐或右对齐)整个当作一个可能的表格区域,并未对其内部在竖直方向相离较远的子分组进行拆分,因此会将多个非线框的表格区域合并到一起。 感兴趣的读者可以深入研究下,这里就不展开了。
确定行、列区域
这一步通过调用
1 | cols, rows = self._generate_columns_and_rows(table_idx, tk) |
实现。这里就不贴源码了, 感兴趣的读者可以自己研究下源码。下面把确定行和列区域的逻辑简单概括一下。
行:通过以下三步确定表格内每一行所在的区域:
- 筛选出在表格区域中的连续字符串
- 根据字符串在y方向上是否重叠,把字符串按行分组
- 根据分好的“行”得到表格每一行在y方向上的区域
列:每一列的区域通过以下几步实现 (camelot作者为什么要这么做,笔者也不是特别清楚, 不知道前文提到的硕士论文是不是有给出原因):
- 计算每一行中字符串的数目
- 排除只包含一个字符串的行,统计出每一个“行字符串数目”出现的次数
- 将出现次数最多的“行字符串数目”作为列数
- 筛选出“行字符串数目”等于列数的行,并这些行的字符串的左右两边的x坐标初步确定列区域
- 合并有重叠的列区域
- 利用位于上面得到的列区域之间与之外的文本拓展列区域
表格对象构建
这一部分与线框类表格对象的构建应该大同小异,这里就不再赘述了,感兴趣的可以参阅《camelot是怎么做表格抽取的(二)—— 线框类表格抽取》中的“表格对象构建”那一部分。