news 2026/9/10 10:41:03

表格结构识别全流程指南:从预处理到TEDS竞赛实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
表格结构识别全流程指南:从预处理到TEDS竞赛实践

简介:面向文档图片表格结构识别赛题的算法竞赛源码包,源自同花顺算法挑战赛2022春季赛,适合计算机、数学、电子信息等专业学生作为课程设计、毕业设计或竞赛复现参考。资源围绕表格结构识别任务提供完整Python实现,包含模型训练、推理预测、指标评估等环节,另有Shell启动脚本、Dockerfile、配置文件、依赖清单等工程化配套,可帮助读者快速搭建运行环境并理解比赛思路。压缩包共45个文件,以27个Python源码文件为核心,辅以4个Shell脚本、3个Markdown说明文档、2个文本文件及若干配置、图片、许可证文件,整体仅272KB,轻量易部署。目前已有107人学习下载,适合具备一定深度学习或图像处理基础、希望借鉴完整参赛方案的开发者使用。通过阅读源码与项目说明,可掌握文档图片表格结构识别的数据处理、模型设计及预测流程,为后续算法改进或项目开发提供参考。

1. 从表格结构识别到竞赛答辩:这个标题到底要交付什么

文档图片里的表格,是 OCR 之后最难啃的一类对象。文字能用识别模型一行行读出,但表格的语义依赖二维布局:这一列对应表头还是数据、跨行跨列怎么合并、空单元格是真空还是解析丢了。表格结构识别(Table Structure Recognition,TSR)就是专门解决这个问题的方向,它要把一张文档图片中的表格还原成带行列坐标的结构化矩阵,再交给下游生成 HTML、Excel 或 JSON。同花顺算法挑战赛 2022 春季赛把这道题作为赛题,要求参赛者提交完整算法工程和项目说明,说明它考察的不只是模型精度,还有工程化落地、排错和表达能力。这篇文章不打算复述竞赛规则,而是顺着这个标题,把一条能复现的技术路线拆开讲清楚:数据怎么准备、模型怎么选、指标怎么算、答辩时怎么验证。

2. 数据路径与预处理:把文档图片切到能进模型的样子

2.1 表格检测与表格结构识别是两件事,别混在一个模型里

很多第一次接触表格识别的团队,上来就训练一个端到端模型,输入图片直接输出 HTML。这个思路在公开 benchmark 上可行,但在竞赛和真实业务里很难维护。原因很简单:检测和结构还原的错误信号混在一起,定位偏了 2 个像素,结构还原可能错一整行。

常见做法是先做版面分析,定位表格区域;再对表格区域做结构识别。版面分析可以用现成的检测模型,比如 YOLO 或 Faster R-CNN,也可以用传统 CV 的直线检测配合连通域筛选。竞赛场景下,数据通常来自扫描件或手机拍照,表格区域往往带有页码、页眉、盖章等干扰,先切区域能大幅降低结构模型的输入噪声。

2.1.1 预处理管线的最小可跑版本

下面这段 Python 代码处理单张文档图片:先做自适应二值化,再做透视矫正的粗略版——用霍夫变换找最长直线作为水平参考。它在脏数据上不一定完美,但作为赛前 baseline 足够。

import cv2 import numpy as np def preprocess_table_image(img_path): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 1. 去噪:非局部均值,保留表格线边缘 denoised = cv2.fastNlMeansDenoising(img, h=15) # 2. 自适应二值化:表格线的局部对比度差异大,全局阈值会断线 binary = cv2.adaptiveThreshold( denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize=31, C=10 ) # 3. 膨胀:把断裂的框线接起来 kernel = np.ones((3, 3), np.uint8) dilated = cv2.dilate(binary, kernel, iterations=1) return binary, dilated binary, dilated = preprocess_table_image("sample.jpg")

参数说明:blockSize=31是自适应阈值计算邻域的大小,单位是像素。图片分辨率越高,这个值应该越大;A4 扫描件 300dpi 下 31~51 比较稳。C=10是从邻域均值中减去的常量,值越大,越不容易把浅色文字误判为前景,但表格线偏细时容易断。h=15是去噪强度,过大会把细线磨掉。

这里要提醒一个误区:THRESH_BINARY_INV得到的是背景黑、前景白的图,正好匹配后面的轮廓查找逻辑。如果你直接用它训练模型,记得存成 uint8 格式,不要存成 0/255 的 float,否则很多深度学习框架的数据加载器会默认归一化到 [-1,1],图像分布直接错了。

2.2 表格结构识别的公开数据集与竞赛数据对齐

竞赛数据通常是隐藏的,A 榜不公开标签,B 榜才开放部分标注。这时候最忌讳的是拿公开数据集训练完直接盲猜竞赛分布。值得对齐的公开数据集有三个:

数据集内容适合做什么
PubTabNet科研论文表格,HTML 标注预训练结构模型,学习跨行跨列表头
SciTSR科学文章表格,行列坐标表格线检测的监督训练
FinTabNet财报表格,复杂表头贴近金融竞赛场景,表头层级丰富

如果竞赛数据以金融文档为主,FinTabNet 的迁移收益通常最高。实操时先在 FinTabNet 上训练,再用竞赛数据做少量微调,比直接用竞赛数据训练稳定得多。竞赛数据量一般几百到几千张,直接训练一个 ResNet-18 级别的结构模型很难收敛。

2.2.1 数据增强:给表格线加噪声而不是给图像加噪声

表格识别模型对图像增强非常敏感。通用分类任务里常用的随机裁剪、色彩抖动,在表格任务里会直接破坏行列结构。我常用的增强策略是:对表格线做形态学扰动,而不是对像素做随机扰动。

import random import albumentations as A def table_augment_pipeline(prob=0.5): return A.Compose([ # 随机制造断线:切掉一小段表格线,训练模型补线 A.CoarseDropout( max_holes=4, max_height=8, max_width=8, fill_value=0, p=prob ), # 模拟手机拍照的倾斜 A.ShiftScaleRotate( shift_limit=0.05, scale_limit=0.1, rotate_limit=5, border_mode=0, value=0, p=prob ), # 模拟扫描后字迹扩散 A.GaussianBlur(blur_limit=(1, 3), p=0.3), ]) aug = table_augment_pipeline() augmented = aug(image=binary)["image"]

CoarseDropout是关键参数,它随机挖掉几个黑色小块,等效于截断表格线。fill_value=0表示挖掉后填充背景色,配合二值图正好模拟真实扫描里表格线被污渍遮盖的情况。ShiftScaleRotateborder_mode=0表示填充黑边,因为后面做的是二值图,黑边不会引入多余的像素噪声。

这个增强策略的思路是:真实的表格图片退化,集中在表格线的断裂、弯曲、遮挡上,而不是集中在颜色和纹理上。如果你用通用增强库的默认参数,大概率会把表格线增强没了。

3. 表格结构还原的模型路线:从双阶段到端到端

3.1 双阶段基线:表格线检测 + 单元格聚合

双阶段路线的思路最直观:先检测出图片里的横线和竖线,再根据线段的交叉点确定单元格边界,最后把 OCR 文本按坐标映射到单元格内。这正好对应表格识别的本质——把二维坐标问题拆成两个一维问题。

横线和竖线检测可以用分割模型,也可以用传统的霍夫变换。分割模型的优势是能处理轻微弯曲的表格线,劣势是需要标注。竞赛场景下,如果允许自己标注,双阶段方案的上限更高;如果不允许,就退回到传统 CV 方法,或者直接训一个二分类分割网络预测"哪些像素属于横线"和"哪些像素属于竖线"。

3.1.1 用分割网络预测表格线的训练骨架

这里用一个简化的 UNet 作为示例,输入是二值化后的表格图片,输出是两个通道:横线概率图和竖线概率图。

import torch import torch.nn as nn class TableLineNet(nn.Module): def __init__(self): super().__init__() # 编码器:下采样到 1/16 self.enc1 = self._block(1, 32) self.enc2 = self._block(32, 64) self.enc3 = self._block(64, 128) self.pool = nn.MaxPool2d(2) # 解码器:上采样回原尺寸 self.dec3 = self._block(128 + 128, 64) self.dec2 = self._block(64 + 64, 32) self.dec1 = self._block(32 + 32, 16) self.up = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False) self.head = nn.Conv2d(16, 2, kernel_size=1) def _block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) d3 = self.dec3(torch.cat([self.up(e3), e2], dim=1)) d2 = self.dec2(torch.cat([self.up(d3), e1], dim=1)) d1 = self.dec1(torch.cat([self.up(d2), x], dim=1)) return self.head(d1) model = TableLineNet()

torch.cat拼接的是同尺度的编码器特征和解码器特征,这保证了细表格线的位置信息在浅层特征里得到保留。align_corners=False是 UpSample 的常见设置,避免像素中心对齐偏差。输出通道数 2 对应横线和竖线两个二分类,用 BCEWithLogitsLoss 做监督。

训练这样的分割网络,标签生成是关键。竞赛数据如果给的是单元格坐标而非像素掩码,你需要把矩形框边界反算成线掩码:把单元格上边界和下边界之间的水平线段标为横线,左边界和右边界之间的竖直线段标为竖线。

3.2 端到端路线:Table Transformer 的思路与取舍

端到端路线直接预测表格的 HTML 结构或者单元格坐标序列,典型代表是 Table Transformer(TSRFormer 是其中的一个版本)。它的核心借鉴了 DETR 的查询机制:每个查询对应一个单元格,通过注意力机制直接输出单元格的边界框和类别。

竞赛团队选端到端路线时,最常踩的坑是查询数量固定与真实单元格数量不匹配。DETR 系列模型会预设一个最大查询数(通常 300 或 500),表格单元格数量如果超过这个值,后处理阶段靠置信度截断会丢行。表格行列数本身是稀疏的,最大支持 50 行 20 列已经非常极限,但很多模型预设的查询数只有 100,一个 20×20 的财务表格就会崩。

3.2.1 为什么双阶段方案更适合个人参赛者

从实际参赛角度看,双阶段方案有三个优势:

第一,可调试性。横线检测错了,直接看可视化掩码,是断线还是错检一目了然;端到端模型的错误无法从中间结果定位。第二,标注成本弹性大。你不用一次性标注全部数据,可以先用少量数据训一个分割模型,再用模型辅助标注扩大数据。第三,算力要求低。UNet 级别的分割模型用一张消费级显卡就能训练,端到端 Transformer 即使训练出来,部署推理时也要更复杂的预处理。

3.2.2 单元格聚合的代码实现

分割出横线和竖线后,需要把它们转成单元格坐标。这一步的常见做法是投影法:对横线掩码做水平方向的投影,每一行的连续值就是一条横线;对竖线掩码做垂直方向的投影,得到竖线位置。交点就是单元格的四个角。

def lines_to_cells(horizontal_mask, vertical_mask, row_thresh=0.8, col_thresh=0.8): h_proj = horizontal_mask.sum(axis=1) # (H,) v_proj = vertical_mask.sum(axis=0) # (W,) h_norm = h_proj / max(horizontal_mask.shape[1], 1) v_norm = v_proj / max(vertical_mask.shape[0], 1) # 找横线的 y 索引:投影值超过阈值视为存在一条横线 row_starts, row_ends = [], [] in_line = False for y, val in enumerate(h_norm): if val > row_thresh and not in_line: row_starts.append(y) in_line = True elif val <= row_thresh and in_line: row_ends.append(y) in_line = False # 合并:一行线的 start 和 end 取中点作为纵坐标 horizontal_ys = [(s + e) // 2 for s, e in zip(row_starts, row_ends)] col_starts, col_ends = [], [] in_line = False for x, val in enumerate(v_norm): if val > col_thresh and not in_line: col_starts.append(x) in_line = True elif val <= col_thresh and in_line: col_ends.append(x) in_line = False vertical_xs = [(s + e) // 2 for s, e in zip(col_starts, col_ends)] cells = [] for i in range(len(horizontal_ys) - 1): for j in range(len(vertical_xs) - 1): y1, y2 = horizontal_ys[i], horizontal_ys[i + 1] x1, x2 = vertical_xs[j], vertical_xs[j + 1] cells.append([x1, y1, x2, y2]) return cells, horizontal_ys, vertical_xs

row_threshcol_thresh是投影归一化后的判定阈值。投影值接近 1 说明整行都是表格线,低于阈值说明这一行没有横线。这里拿max(width, 1)做归一化是为了避免除零。二值图里表格线可能因扫描质量出现小断口,投影后断口处投影值会降低但通常不会低于 0.8,这个阈值适合扫描质量普通的图片。如果你的图片表格线特别粗,可以考虑用形态学闭运算先修复断线再做投影。

3.3 跨行跨列的还原:从坐标到 HTML 结构的转换

检测到单元格边界后,剩下的问题是判别哪些单元格属于同一行、同一列、是否有合并。这需要把坐标转成 HTML 表格结构,而 HTML 结构对单元格的要求比坐标严格得多——它要求严格的二维矩阵形式,不能有空洞。

常用规则是:矩形框 IoU 重叠超过阈值的单元格彼此合并。但更稳健的方式是按中心点行列网格对齐。先对所有单元格的中心点做聚类,一行的中心点 y 坐标应该集中在同一水平线上,一列的 x 坐标同理。聚类之后,每个单元格的行号、列号就确定了;如果某个单元格跨越了多个行网格,说明它是跨行单元格。

这一步的正确性直接影响最后指标,因为 TEDS(Tree Edit Distance based Similarity)指标是按 HTML 树结构计算的,不是按像素坐标计算的。坐标对了但 HTML 结构错了,得分一样低。

4. 推理管线、指标计算与竞赛提交的坑

4.1 与 OCR 引擎的集成和参数设定

表格结构模型负责空间结构,OCR 负责文字内容。推理时两个模块串联:结构模型先输出表格线、再聚合单元格;OCR 对整张图片做检测和识别,输出文本和对应的坐标框;最后按 IoU 或中心点距离将文本分配到单元格。

OCR 引擎的选择上,PaddleOCR 和 Tesseract 都有人用。竞赛场景优先考虑 PaddleOCR,它对中文文档的支持明显好于 Tesseract,而且自带文本检测框输出,省去自己写坐标转换的成本。调用时需要注意两个参数:text_thresh控制文本检测的置信度阈值,默认 0.5,表格中数字较多时建议调高到 0.6 以上,减少误检;unclip_ratio控制检测框的扩张比例,默认 1.5,表格场景下建议调低到 1.2,否则相邻单元格的文字框会重叠,影响归属判断。

4.1.1 文本归属到单元格的实现
def assign_text_to_cells(text_boxes, text_contents, cells, iou_thresh=0.3): cell_texts = [[] for _ in cells] for box, content in zip(text_boxes, text_contents): # 文本框: [x1, y1, x2, y2] bx1, by1, bx2, by2 = box best_cell_idx = -1 best_iou = iou_thresh for idx, (cx1, cy1, cx2, cy2) in enumerate(cells): # 计算交集 inter_x1 = max(bx1, cx1) inter_y1 = max(by1, cy1) inter_x2 = min(bx2, cx2) inter_y2 = min(by2, cy2) if inter_x2 <= inter_x1 or inter_y2 <= inter_y1: continue inter_area = (inter_x2 - inter_x1) * (inter_y2 - inter_y1) union_area = (bx2 - bx1) * (by2 - by1) + (cx2 - cx1) * (cy2 - cy1) - inter_area iou = inter_area / max(union_area, 1e-6) if iou > best_iou: best_iou = iou best_cell_idx = idx if best_cell_idx != -1: cell_texts[best_cell_idx].append(content) return cell_texts

iou_thresh=0.3是一个经验值。文本检测框通常略小于单元格,如果要求太高,空白单元格旁边的文字会被漏掉;太低又会把相邻单元格的文字拉进来。判断归属时尽量用 IoU 而不是单纯的中心点距离,因为合并单元格的文字框可能横跨多个子区域,中心点方法无法判断合并后单元格的整体归属。

4.2 TEDS 指标为什么比简单准确率更虐

表格结构识别竞赛里,主排名指标通常不是像素级 IoU 或单元格检测的 F1,而是 TEDS。TEDS 的基本思路是把预测的表格和标注的表格都解析成树结构,然后计算两棵树的编辑距离。两个表格在单元格内容、合并关系、行列数上存在差异时,都会通过树节点的增删改反映出来。

它的一个特点是很重的结构惩罚。一个单元格的文字内容识别错了,只影响那一个节点;但一个跨行合并错了,可能导致整棵子树的结构与标注不一致,编辑距离会成倍增加。所以,竞赛调参的重心往往放在结构一致性上,其次是文字精度。

4.2.1 自己算 TEDS 的轻量验证脚本
def teds_similarity(pred_html, gt_html): # 简化版:按标签序列计算编辑距离 import re from difflib import SequenceMatcher def tokenize(html): tags = re.findall(r"<[^>]+>|[^<]+", html) # 空格、换行统一忽略 return [t.strip() for t in tags if t.strip()] pred_tokens = tokenize(pred_html) gt_tokens = tokenize(gt_html) # SequenceMatcher: 返回 0~1 的相似度,1 表示完全一致 sim = SequenceMatcher(None, pred_tokens, gt_tokens).ratio() return sim

正则r"<[^>]+>|[^<]+"把 HTML 拆成标签和纯文本两类 token,再去掉空白。这个简化版没有实现真正的树编辑距离,但作为赛前自检足够:它能反映结构错误和文本错误的相对数量级。如果比赛方提供官方评测脚本,建议优先使用官方实现,这个脚本只用于你本地迭代时候的快速排序。

4.3 提交格式和线上评测的不一致问题

竞赛平台要求的输出格式通常是 JSON 或 HTML,A 榜测试集不公开标签,B 榜开放部分标签。常见的不一致场景主要有三种:

一是图像分辨率差异。本地验证集大多来自公开数据,A 榜的图片可能是手机拍照,透视变形严重。预处理里的霍夫变换找水平参考线,在透视图上经常失效。应对做法是把透视矫正前置,并且用竞赛官方提供的示例图片做基础校验。二是标签表达差异。有的平台把合并单元格表达为 rowspan/colspan,有的表达为重复坐标。提交前先跑一遍官方示例,确认你输出的 JSON 解析不报错。三是颜色和双面透印。手机拍照的文档背面字迹透过来,预处理如果没做背景减除,分割模型会把透印误识别为表格线。

5. 最后 5 天:把模型精度和答辩素材一起打磨

赛前最后阶段,模型结构基本定了,再改网络结构意义不大。值得投入的是三个方面:错误分析、阈值微调、答辩可视化。

错误分析要按错误类型归类。把验证集预测和标注都转成可视化图片,在每张图上标注出错误位置——漏检的表格线画红框、误检的线画蓝框、文字归属错的单元格画黄框。统计三类错误的占比,做成柱状图放在答辩 PPT 里。表格线漏检一般通过形态学闭运算修复;误检多半是预处理把印章或背景纹理误判为线;文字归属错误往往出在跨行单元格上,对应调整 IoU 阈值。

阈值微调可以做成一个简单的扫描脚本,把iou_threshrow_threshcol_thresh三个参数各自按步长遍历,在验证集上跑出分数矩阵,选出最优组合。值得注意的一点是:这三个参数不宜单独调,row_thresh高了横线断点增多,但iou_thresh低一点可以容忍部分结构误差。把它们当成联合空间搜索,比反复试单个参数高效。

答辩可视化的核心不是展示几张效果图,而是做一个结构对比的 GIF。左边是原图,中间是模型输出的表格线叠加图,右边是还原出的 HTML 渲染图。GIF 的三帧循环能很直观地说明输入到输出的映射过程。用 OpenCV 写一个脚本,每次迭代保存一个 PNG,再借助视频编码合成 GIF,几百行代码就能复现。评委最容易问的跨行单元格合并、空单元格识别、透视变形表格,各准备一页这样的可视化,比贴十张表格数据都有效。

最后提醒一句:源码压缩包里除了模型权重,一定要包含requirements.txt和 README。README 里写清楚 Python 版本、依赖版本、GPU 显存要求以及推理命令。代码能跑通、结果能复现,是竞赛评审的第一道门槛。这一点占的分数权重,往往比很多人想象的高。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 10:40:54

旧Mac如何升级最新macOS:OpenCore Legacy Patcher指南

旧Mac如何升级最新macOS&#xff1a;OpenCore Legacy Patcher指南 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher 点开系统设置&#xff0c;发现macOS更新按钮…

作者头像 李华
网站建设 2026/9/10 10:34:10

告别tf树乱麻:用超图统一坐标框架,解决多传感器回环难题

如果你搞过多传感器机器人定位&#xff0c;大概率有过被 tf 树逼疯的瞬间。之前在做一个室内机器人项目&#xff0c;车上同时有轮式里程计、IMU 和激光雷达&#xff0c;走一圈回来想用闭环把轨迹校准一下&#xff0c;一广播新变换&#xff0c;整个 tf 树直接乱成一团。后来我把…

作者头像 李华
网站建设 2026/9/10 10:33:47

CANN/GE常量值匹配配置

EnableConstValueMatch 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、Ten…

作者头像 李华