表格结构识别全流程指南:从预处理到TEDS竞赛实践
简介面向文档图片表格结构识别赛题的算法竞赛源码包源自同花顺算法挑战赛2022春季赛适合计算机、数学、电子信息等专业学生作为课程设计、毕业设计或竞赛复现参考。资源围绕表格结构识别任务提供完整Python实现包含模型训练、推理预测、指标评估等环节另有Shell启动脚本、Dockerfile、配置文件、依赖清单等工程化配套可帮助读者快速搭建运行环境并理解比赛思路。压缩包共45个文件以27个Python源码文件为核心辅以4个Shell脚本、3个Markdown说明文档、2个文本文件及若干配置、图片、许可证文件整体仅272KB轻量易部署。目前已有107人学习下载适合具备一定深度学习或图像处理基础、希望借鉴完整参赛方案的开发者使用。通过阅读源码与项目说明可掌握文档图片表格结构识别的数据处理、模型设计及预测流程为后续算法改进或项目开发提供参考。1. 从表格结构识别到竞赛答辩这个标题到底要交付什么文档图片里的表格是 OCR 之后最难啃的一类对象。文字能用识别模型一行行读出但表格的语义依赖二维布局这一列对应表头还是数据、跨行跨列怎么合并、空单元格是真空还是解析丢了。表格结构识别Table Structure RecognitionTSR就是专门解决这个问题的方向它要把一张文档图片中的表格还原成带行列坐标的结构化矩阵再交给下游生成 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, h15) # 2. 自适应二值化表格线的局部对比度差异大全局阈值会断线 binary cv2.adaptiveThreshold( denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize31, C10 ) # 3. 膨胀把断裂的框线接起来 kernel np.ones((3, 3), np.uint8) dilated cv2.dilate(binary, kernel, iterations1) return binary, dilated binary, dilated preprocess_table_image(sample.jpg)参数说明blockSize31是自适应阈值计算邻域的大小单位是像素。图片分辨率越高这个值应该越大A4 扫描件 300dpi 下 3151 比较稳。C10是从邻域均值中减去的常量值越大越不容易把浅色文字误判为前景但表格线偏细时容易断。h15是去噪强度过大会把细线磨掉。这里要提醒一个误区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(prob0.5): return A.Compose([ # 随机制造断线切掉一小段表格线训练模型补线 A.CoarseDropout( max_holes4, max_height8, max_width8, fill_value0, pprob ), # 模拟手机拍照的倾斜 A.ShiftScaleRotate( shift_limit0.05, scale_limit0.1, rotate_limit5, border_mode0, value0, pprob ), # 模拟扫描后字迹扩散 A.GaussianBlur(blur_limit(1, 3), p0.3), ]) aug table_augment_pipeline() augmented aug(imagebinary)[image]CoarseDropout是关键参数它随机挖掉几个黑色小块等效于截断表格线。fill_value0表示挖掉后填充背景色配合二值图正好模拟真实扫描里表格线被污渍遮盖的情况。ShiftScaleRotate的border_mode0表示填充黑边因为后面做的是二值图黑边不会引入多余的像素噪声。这个增强策略的思路是真实的表格图片退化集中在表格线的断裂、弯曲、遮挡上而不是集中在颜色和纹理上。如果你用通用增强库的默认参数大概率会把表格线增强没了。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_factor2, modebilinear, align_cornersFalse) self.head nn.Conv2d(16, 2, kernel_size1) def _block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) 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], dim1)) d2 self.dec2(torch.cat([self.up(d3), e1], dim1)) d1 self.dec1(torch.cat([self.up(d2), x], dim1)) return self.head(d1) model TableLineNet()torch.cat拼接的是同尺度的编码器特征和解码器特征这保证了细表格线的位置信息在浅层特征里得到保留。align_cornersFalse是 UpSample 的常见设置避免像素中心对齐偏差。输出通道数 2 对应横线和竖线两个二分类用 BCEWithLogitsLoss 做监督。训练这样的分割网络标签生成是关键。竞赛数据如果给的是单元格坐标而非像素掩码你需要把矩形框边界反算成线掩码把单元格上边界和下边界之间的水平线段标为横线左边界和右边界之间的竖直线段标为竖线。3.2 端到端路线Table Transformer 的思路与取舍端到端路线直接预测表格的 HTML 结构或者单元格坐标序列典型代表是 Table TransformerTSRFormer 是其中的一个版本。它的核心借鉴了 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_thresh0.8, col_thresh0.8): h_proj horizontal_mask.sum(axis1) # (H,) v_proj vertical_mask.sum(axis0) # (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_xsrow_thresh和col_thresh是投影归一化后的判定阈值。投影值接近 1 说明整行都是表格线低于阈值说明这一行没有横线。这里拿max(width, 1)做归一化是为了避免除零。二值图里表格线可能因扫描质量出现小断口投影后断口处投影值会降低但通常不会低于 0.8这个阈值适合扫描质量普通的图片。如果你的图片表格线特别粗可以考虑用形态学闭运算先修复断线再做投影。3.3 跨行跨列的还原从坐标到 HTML 结构的转换检测到单元格边界后剩下的问题是判别哪些单元格属于同一行、同一列、是否有合并。这需要把坐标转成 HTML 表格结构而 HTML 结构对单元格的要求比坐标严格得多——它要求严格的二维矩阵形式不能有空洞。常用规则是矩形框 IoU 重叠超过阈值的单元格彼此合并。但更稳健的方式是按中心点行列网格对齐。先对所有单元格的中心点做聚类一行的中心点 y 坐标应该集中在同一水平线上一列的 x 坐标同理。聚类之后每个单元格的行号、列号就确定了如果某个单元格跨越了多个行网格说明它是跨行单元格。这一步的正确性直接影响最后指标因为 TEDSTree 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_thresh0.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_textsiou_thresh0.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 或 HTMLA 榜测试集不公开标签B 榜开放部分标签。常见的不一致场景主要有三种一是图像分辨率差异。本地验证集大多来自公开数据A 榜的图片可能是手机拍照透视变形严重。预处理里的霍夫变换找水平参考线在透视图上经常失效。应对做法是把透视矫正前置并且用竞赛官方提供的示例图片做基础校验。二是标签表达差异。有的平台把合并单元格表达为 rowspan/colspan有的表达为重复坐标。提交前先跑一遍官方示例确认你输出的 JSON 解析不报错。三是颜色和双面透印。手机拍照的文档背面字迹透过来预处理如果没做背景减除分割模型会把透印误识别为表格线。5. 最后 5 天把模型精度和答辩素材一起打磨赛前最后阶段模型结构基本定了再改网络结构意义不大。值得投入的是三个方面错误分析、阈值微调、答辩可视化。错误分析要按错误类型归类。把验证集预测和标注都转成可视化图片在每张图上标注出错误位置——漏检的表格线画红框、误检的线画蓝框、文字归属错的单元格画黄框。统计三类错误的占比做成柱状图放在答辩 PPT 里。表格线漏检一般通过形态学闭运算修复误检多半是预处理把印章或背景纹理误判为线文字归属错误往往出在跨行单元格上对应调整 IoU 阈值。阈值微调可以做成一个简单的扫描脚本把iou_thresh、row_thresh、col_thresh三个参数各自按步长遍历在验证集上跑出分数矩阵选出最优组合。值得注意的一点是这三个参数不宜单独调row_thresh高了横线断点增多但iou_thresh低一点可以容忍部分结构误差。把它们当成联合空间搜索比反复试单个参数高效。答辩可视化的核心不是展示几张效果图而是做一个结构对比的 GIF。左边是原图中间是模型输出的表格线叠加图右边是还原出的 HTML 渲染图。GIF 的三帧循环能很直观地说明输入到输出的映射过程。用 OpenCV 写一个脚本每次迭代保存一个 PNG再借助视频编码合成 GIF几百行代码就能复现。评委最容易问的跨行单元格合并、空单元格识别、透视变形表格各准备一页这样的可视化比贴十张表格数据都有效。最后提醒一句源码压缩包里除了模型权重一定要包含requirements.txt和 README。README 里写清楚 Python 版本、依赖版本、GPU 显存要求以及推理命令。代码能跑通、结果能复现是竞赛评审的第一道门槛。这一点占的分数权重往往比很多人想象的高。本文还有配套的精品资源点击获取