|
|
@@ -13,7 +13,7 @@ wired_table/
|
|
|
├── ocr_formatter.py # OCR 格式转换
|
|
|
├── skew_detection.py # 倾斜检测与矫正 ⭐
|
|
|
├── grid_recovery.py # 网格恢复(表格线 → 单元格)⭐
|
|
|
-├── text_filling.py # 文本填充(OCR → 单元格)⭐
|
|
|
+├── text_filling.py # 文本填充(OCR → 单元格)⭐⭐⭐
|
|
|
├── html_generator.py # HTML 生成
|
|
|
└── visualization.py # 可视化工具
|
|
|
|
|
|
@@ -53,3 +53,239 @@ graph TB
|
|
|
style H fill:#fff4e1
|
|
|
```
|
|
|
|
|
|
+---
|
|
|
+
|
|
|
+## 六、单元格 OCR:文本填充机制(text_filling.py)⭐⭐⭐
|
|
|
+
|
|
|
+这是整个表格识别中最复杂的部分。银行流水等场景的 OCR 质量直接影响最终结果,本节详细说明其设计思路和实现细节。
|
|
|
+
|
|
|
+### 6.1 为什么需要二次 OCR?
|
|
|
+
|
|
|
+一次 OCR(整页识别)存在天然局限:
|
|
|
+
|
|
|
+| 问题 | 表现 | 原因 |
|
|
|
+|------|------|------|
|
|
|
+| **检测遗漏** | 单元格内无文字 | 表格线干扰、文字过小、字号不统一 |
|
|
|
+| **识别不完整** | 只识别到"支行"而非完整户名 | 文字偏单元格底部,上半部分被忽略 |
|
|
|
+| **水印干扰** | "有限公司"被误识别为金额 | 斜向水印与正常文字重叠在同一单元格 |
|
|
|
+| **低分碎片** | 识别结果置信度低 | 背景噪声、对比度不足 |
|
|
|
+
|
|
|
+二次 OCR 在**单元格裁剪图**上重新识别,预处理空间更大,精度更高。
|
|
|
+
|
|
|
+### 6.2 核心设计理念
|
|
|
+
|
|
|
+**"先匹配,后反刍"**:
|
|
|
+
|
|
|
+1. **一验**(`fill_text_by_center_point`):将整页 OCR 结果按中心点 + 重叠比例匹配到单元格
|
|
|
+2. **触发判定**(`_should_second_pass_cell`):判断哪些格需要二次 OCR
|
|
|
+3. **二验**(`second_pass_ocr_fill`):对判定需重处理的格,裁剪后再做 OCR
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+### 6.3 一验匹配策略
|
|
|
+
|
|
|
+#### 中心点 + 重叠比例
|
|
|
+
|
|
|
+```python
|
|
|
+# 第一步:中心点筛选
|
|
|
+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°)角度差异明显:
|
|
|
+
|
|
|
+```python
|
|
|
+@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 高单元格的底部 |
|
|
|
+
|
|
|
+```python
|
|
|
+# 条件一: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)才能正确触发。
|
|
|
+
|
|
|
+### 6.4 bank_statement 空单元格特殊逻辑
|
|
|
+
|
|
|
+银行流水的表格结构具有规律性,可以对空单元格做更智能的判断。
|
|
|
+
|
|
|
+#### 问题
|
|
|
+
|
|
|
+如果一列的多数格本身都是空的(如"附言"列),那么其中一个空格就可能是**真的空**,不应触发二次 OCR。反之,如果一列大部分有文字,某个空格大概率是 OCR 遗漏。
|
|
|
+
|
|
|
+#### 实现:基于 matched_boxes_list 的列空判断
|
|
|
+
|
|
|
+**关键设计决策**:用 `matched_boxes_list`(是否命中 OCR 框)而非 `texts`(是否识别出文字)判空。
|
|
|
+
|
|
|
+| `matched_boxes_list[i]` | `texts[i]` | 含义 |
|
|
|
+|-------------------------|-----------|------|
|
|
|
+| `[{text: '', bbox: [...]}]` | `""` | OCR **检到了框但未识别** → 列不空 |
|
|
|
+| `[]` | `""` | **真的空** → 列空 |
|
|
|
+| `[{text: '广东农信', ...}]` | `"广东农信"` | 正常 |
|
|
|
+
|
|
|
+因为 OCR 可能检测出框(说明有内容)但识别失败(text 为空)。用 texts 判空会把"有框无字"的格误判为该列为空 → 跳过二次 OCR → 本该识别的内容永远丢失。
|
|
|
+
|
|
|
+```python
|
|
|
+@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` | **跳过**,列本来就空 |
|
|
|
+
|
|
|
+### 6.5 二次 OCR 预处理
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+### 6.6 配置示例
|
|
|
+
|
|
|
+```yaml
|
|
|
+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
|
|
|
+```
|
|
|
+
|