本文档详细说明 UNET 有线表格识别模块的技术实现细节,适用于开发人员进行二次开发和问题排查。
有线表格识别模块位于 models/adapters/wired_table/,提供基于深度学习的表格线检测、网格恢复和文本填充功能。与 VLM 方法相比,UNET 方法更适合处理规则的有线表格,具有更高的精度和更快的处理速度。
wired_table/ ├── init.py # 模块初始化 ├── debug_utils.py # 调试工具(可视化输出) ├── ocr_formatter.py # OCR 格式转换 ├── skew_detection.py # 倾斜检测与矫正 ⭐ ├── grid_recovery.py # 网格恢复(表格线 → 单元格)⭐ ├── text_filling.py # 文本填充(OCR → 单元格)⭐⭐⭐ ├── html_generator.py # HTML 生成 └── visualization.py # 可视化工具
主入口:models/adapters/mineru_wired_table.py - MinerUWiredTableRecognizer
graph TB
A[输入:表格图片 + OCR框] --> B[1. OCR预处理<br/>ocr_formatter.py]
B --> C[2. UNET线检测<br/>MinerU模型]
C --> D{启用倾斜矫正?}
D -->|是| E[3. 倾斜检测<br/>skew_detection.py]
D -->|否| G
E --> F[4. 图片与坐标矫正<br/>cv2.warpAffine]
F --> G[5. 网格恢复<br/>grid_recovery.py]
G --> H[6. 文本填充<br/>text_filling.py]
H --> I[7. HTML生成<br/>html_generator.py]
I --> J[8. 坐标逆转换<br/>回到原图坐标系]
J --> K{识别成功?}
K -->|是| L[返回HTML + 坐标]
K -->|否| M[Fallback到VLM]
style E fill:#e1f5ff
style F fill:#e1f5ff
style G fill:#fff4e1
style H fill:#fff4e1
这是整个表格识别中最复杂的部分。银行流水等场景的 OCR 质量直接影响最终结果,本节详细说明其设计思路和实现细节。
一次 OCR(整页识别)存在天然局限:
| 问题 | 表现 | 原因 |
|---|---|---|
| 检测遗漏 | 单元格内无文字 | 表格线干扰、文字过小、字号不统一 |
| 识别不完整 | 只识别到"支行"而非完整户名 | 文字偏单元格底部,上半部分被忽略 |
| 水印干扰 | "有限公司"被误识别为金额 | 斜向水印与正常文字重叠在同一单元格 |
| 低分碎片 | 识别结果置信度低 | 背景噪声、对比度不足 |
二次 OCR 在单元格裁剪图上重新识别,预处理空间更大,精度更高。
"先匹配,后反刍":
fill_text_by_center_point):将整页 OCR 结果按中心点 + 重叠比例匹配到单元格_should_second_pass_cell):判断哪些格需要二次 OCR二验(second_pass_ocr_fill):对判定需重处理的格,裁剪后再做 OCR
graph TB
subgraph 一验:整页OCR匹配
A[整页OCR结果<br/>ocr_boxes] --> B[遍历每个单元格]
B --> C{中心点+重叠比例<br/>匹配到OCR框?}
C -->|是| D[斜框过滤<br/>_is_bbox_slanted >10°?]
D -->|否,水平| E[纵向完整性检测<br/>_is_ocr_vertically_incomplete?]
D -->|是,斜框| D2[丢弃该框<br/>水印豁免]
E -->|不完整| E2[清空text/score<br/>→ 加入need_reocr_indices]
E -->|完整| F[嵌套框处理<br/>_resolve_cell_matched_boxes]
F --> G[拼接文本 + 计算置信度]
C -->|否| H[text=""<br/>score=0.0]
end
subgraph 触发判定:_should_second_pass_cell
I{是否需要二次OCR?} --> J[force_all]
I --> K[spanning/cross_cell]
I --> L[low_first_pass_score <0.9]
I --> M[tall_cell_low_score]
I --> N{bank_statement<br/>空格特殊逻辑}
N -->|表头空格| N1[header_row_empty → 触发]
N -->|表体空格| N2[_column_empty_ratio<br/>matched_boxes_list判空]
N2 -->|列大部分有框| N3[body_row_empty_column_mostly_filled → 触发]
N2 -->|列大部分无框| N4[跳过,列本身就空]
I --> O{任何reason命中?}
end
subgraph 二验:单元格裁剪OCR
P[裁剪cell图像] --> Q[Pass1: 格级预处理<br/>去水印 + upscale + OCR]
Q --> R{Pass1分数达标?}
R -->|是| S[采纳Pass1结果]
R -->|否| T[Pass2: enhance_retry<br/>更强预处理 + OCR]
T --> U[择优:pick_better]
U --> V[更新texts]
end
G --> I
H --> I
D2 --> F
E2 --> I
O -->|是| P
O -->|否| W[保持一验结果]
style D fill:#ffe1e1
style E fill:#ffe1e1
style F fill:#e1f5ff
style N fill:#e1f5ff
style N2 fill:#e1f5ff
style Q fill:#fff4e1
style T fill:#fff4e1
# 第一步:中心点筛选
if not (cell_x1 <= cx <= cell_x2 and cell_y1 <= cy <= cell_y2):
continue # 中心点必须在单元格内
# 第二步:重叠比例检查
overlap_ratio = calculate_overlap_ratio(ocr_bbox, cell_bbox)
if overlap_ratio >= 0.5: # OCR框至少50%在单元格内
matched.append(...)
相比纯 IOU 更宽松,能匹配到更多 OCR 框;比纯中心点更准确,能过滤跨单元格的 OCR 框。
_resolve_cell_matched_boxes)一个单元格内可能出现大框套小框的情况:
| 场景 | 处理 |
|---|---|
| 大框有字、小框在内 | 丢弃小框碎片,保留大框 |
| 大框无字、小框在内 | 丢弃小框,整格 score=0 → 触发二次 OCR |
_is_bbox_slanted)银行流水的水印通常为 30-45° 斜向,与正常水平文字(0-2°)角度差异明显:
@staticmethod
def _is_bbox_slanted(original_box, *, angle_threshold=10.0):
"""基于原始多边形角度检测。上边与水平面夹角 > angle_threshold → 斜框。"""
poly = box.get("poly") or box.get("original_bbox") or box.get("bbox") or []
# 计算上边 dx, dy → atan2 → 角度
angle_deg = abs(math.degrees(math.atan2(dy, dx)))
return angle_deg > angle_threshold
为什么放在
_resolve_cell_matched_boxes中? 斜框丢弃与嵌套框丢弃的语义一致——都是"某个匹配 OCR 框不应被保留"。
_is_ocr_vertically_incomplete)银行流水单元格文字纵向居中。若一次 OCR 只匹配到局部字(如"支行"),需要触发二次 OCR。
两种判定互补:
| 条件 | 原理 | 示例 |
|---|---|---|
| y_center 偏移 | 合并 bbox 的 y_center 偏离 cell y_center 超过 cell_h * 1/3 |
OCR 框完全跑到单元格底部 |
| 空白不对称 | 上下空白占比差 > 0.3(文字贴顶部或底部) | "支行" 36px 高偏在 86px 高单元格的底部 |
# 条件一:y_center 偏移
deviation = abs(merged_yc - cell_yc)
if deviation > cell_h * y_deviation_ratio: # 默认 1/3
return True
# 条件二:空白不对称
top_gap = merged_y1 - cell_y1
bottom_gap = cell_y2 - merged_y2
asymmetry = abs(top_gap/cell_h - bottom_gap/cell_h)
return asymmetry > margin_asymmetry_threshold # 默认 0.3
为什么不单纯用 y_center 偏移? "支行"偏底部但 y_center 偏差不大(20.5px < 28.6px),单靠 y_center 条件无法检测。空白不对称(上方 53% vs 下方 5% → 0.48)才能正确触发。
银行流水的表格结构具有规律性,可以对空单元格做更智能的判断。
如果一列的多数格本身都是空的(如"附言"列),那么其中一个空格就可能是真的空,不应触发二次 OCR。反之,如果一列大部分有文字,某个空格大概率是 OCR 遗漏。
关键设计决策:用 matched_boxes_list(是否命中 OCR 框)而非 texts(是否识别出文字)判空。
matched_boxes_list[i] |
texts[i] |
含义 |
|---|---|---|
[{text: '', bbox: [...]}] |
"" |
OCR 检到了框但未识别 → 列不空 |
[] |
"" |
真的空 → 列空 |
[{text: '广东农信', ...}] |
"广东农信" |
正常 |
因为 OCR 可能检测出框(说明有内容)但识别失败(text 为空)。用 texts 判空会把"有框无字"的格误判为该列为空 → 跳过二次 OCR → 本该识别的内容永远丢失。
@staticmethod
def _column_empty_ratio(merged_cells, matched_boxes_list, col, header_row):
col_cells = [
matched_boxes_list[j] # 基于已匹配的 OCR 框判空
for j, c in enumerate(merged_cells)
if int(c.get("col")) == col and int(c.get("row")) > header_row
]
return sum(1 for boxes in col_cells if not boxes) / len(col_cells)
触发规则:
| 场景 | 条件 | 触发原因 |
|---|---|---|
| 表头行空格 | row == header_row |
header_row_empty |
| 表体行空格 + 列大部分有 OCR 框 | col_empty_ratio < 0.5 |
body_row_empty_column_mostly_filled |
| 表体行空格 + 列大部分无 OCR 框 | col_empty_ratio >= 0.5 |
跳过,列本来就空 |
graph TB
A[raw_crop 单元格裁剪] --> B{Pass1: light 模式}
B --> C[格级去水印<br/>WatermarkProcessor cell]
C --> D[可选: denoise / contrast]
D --> E[upscale<br/>light.upscale_min_side]
E --> F[det分行 + whole兜底 OCR]
F --> G{分数达标?}
G -->|是| H[采纳Pass1]
G -->|否| I{Pass2: enhance_retry<br/>是否为启用?}
I -->|否| H
I -->|是| J[enhance 模式预处理<br/>可选更激进去水印+对比度+upscale]
J --> K[再次 OCR]
K --> L[择优: pick_better_ocr_result]
L --> H
style C fill:#fff4e1
style E fill:#e1f5ff
style J fill:#fff4e1
table_recognition_wired:
second_pass_ocr:
reocr_mode: "bank_statement" # 启用水印过滤、列空判断等启发式
min_hit_score: 0.9
suspicious_short_min_chars: 4
cell_preprocess: # Pass1 预处理
watermark:
enabled: true
method: threshold
threshold: 155
upscale_min_side: 96 # Pass1 放大最短边
contrast:
enabled: false
enhance_retry: # Pass2 预处理
enabled: true
upscale_min_side: 128 # Pass2 独立放大尺寸
contrast:
enabled: true
method: clahe
clip_limit: 1.0
tile_grid_size: 4