# 表格行匹配算法详解:`_match_html_rows_to_paddle_groups`
## 📋 算法概述
这是一个**动态规划(DP)算法**,用于将HTML表格行与OCR检测到的文本分组进行智能匹配。
### 问题场景
- **输入1**: HTML表格行(7行)
- 第0行:表头信息(部门、柜员、打印日期等)
- 第1-5行:账户信息行(账号、查询日期、证件信息等)
- 第6行:表头(Serial Num, Trans Date等)
- 第7行:数据行(实际交易记录)
- **输入2**: OCR文本分组(48组)
- 每组包含多个OCR检测框,按Y坐标分组
- 组0-4:表头信息
- 组5-47:表头和数据行混合
### 核心挑战
1. **1对多匹配**:一个HTML行可能对应多个OCR组(合并单元格、OCR分割错误)
2. **跳过处理**:某些HTML行可能没有对应的OCR组(OCR漏检)
3. **顺序不一致**:HTML行顺序与OCR组顺序可能不完全一致
---
## 🎯 算法流程
### 阶段1:预处理与验证
#### 1.1 提取文本
```python
# HTML行文本(7行)
html_row_texts = [
'部门department:01372403999柜员searchteller:ebf0000...', # 行0
'账号/卡号account/cardno:6222621010026732125...', # 行1
'查询起日querystartingdate:2023-08-12...', # 行2
'查询时间querytime:2024年08月12日...', # 行3
'证件种类idtype:第二代居民身份证...', # 行4
'serialnum序号transdate交易日期...', # 行5(表头)
'12023-08-1213:04:52网上银行卡转入...' # 行6(数据行)
]
# OCR组文本(48组)
group_texts = [
'部门department:01372403999...', # 组0
'账号/卡号account/cardno:...', # 组1
'查询起日querystartingdate:...', # 组2
...
'964642s0110305' # 组47
]
```
#### 1.2 快速路径:行数相等时的验证
```python
if len(html_rows) == len(grouped_boxes): # 7 == 48? False
# 不执行此分支
```
**实际场景**:7行 ≠ 48组,需要DP算法
---
### 阶段2:预计算合并文本缓存
#### 2.1 构建 `merged_cache`
为了支持**一个HTML行匹配多个OCR组**,预计算所有可能的组合:
```python
MAX_MERGE = 4 # 最多合并4个OCR组
merged_cache = {
(0, 1): '部门department:01372403999...', # 组0单独
(0, 2): '部门department:...账号/卡号account...', # 组0+组1
(0, 3): '部门department:...查询起日...', # 组0+组1+组2
(0, 4): '部门department:...查询时间...', # 组0+组1+组2+组3
(1, 1): '账号/卡号account/cardno:...', # 组1单独
(1, 2): '账号/卡号...查询起日...', # 组1+组2
...
(47, 1): '964642s0110305' # 组47单独
}
```
**作用**:避免在DP过程中重复拼接文本,提升性能
---
### 阶段3:动态规划初始化
#### 3.1 DP状态定义
```python
# dp[i][j] = 将HTML前i行匹配到OCR前j组的最大得分
dp = np.full((7, 48), -np.inf) # 初始化为负无穷
# path[(i, j)] = (prev_j, start_j)
# prev_j: 上一行结束的OCR组索引
# start_j: 当前行开始的OCR组索引
path = {}
```
#### 3.2 初始化第一行(i=0)
**目标**:找到HTML第0行与OCR组的最佳匹配
```python
SEARCH_WINDOW = 15 # 只在前15个组中搜索
MAX_MERGE = 4 # 最多合并4个组
# 遍历所有可能的匹配
for end_j in range(min(48, 15 + 4)): # end_j: 0..18
for count in range(1, 5): # count: 1..4
start_j = end_j - count + 1 # 计算起始组索引
# 例如:end_j=3, count=2 → start_j=2
# 表示:HTML行0 匹配 OCR组2+组3
current_text = merged_cache.get((start_j, count), "")
similarity = calculate_similarity(html_row_texts[0], current_text)
penalty = start_j * 0.1 # 跳过组的惩罚
score = similarity - penalty
if score > 0.1: # 只有得分足够高才记录
dp[0][end_j] = max(dp[0][end_j], score)
path[(0, end_j)] = (-1, start_j) # -1表示这是第一行
```
**示例计算过程**:
| end_j | count | start_j | 匹配的OCR组 | 相似度 | 惩罚 | 得分 | 是否记录 |
|-------|-------|---------|-------------|--------|------|------|----------|
| 0 | 1 | 0 | 组0 | 0.95 | 0.0 | 0.95 | ✅ |
| 1 | 1 | 1 | 组1 | 0.15 | 0.1 | 0.05 | ❌ |
| 1 | 2 | 0 | 组0+组1 | 0.98 | 0.0 | 0.98 | ✅ |
| 2 | 3 | 0 | 组0+组1+组2 | 0.92 | 0.0 | 0.92 | ✅ |
| ... | ... | ... | ... | ... | ... | ... | ... |
**结果**:假设 `dp[0][2] = 0.98` 是最优,表示HTML行0匹配OCR组0+组1+组2
---
### 阶段4:DP状态转移(核心)
#### 4.1 处理第i行(i=1..6)
**状态转移方程**:
```
dp[i][end_j] = max {
dp[i-1][prev_j] + match_score(i, start_j..end_j) - gap_penalty
}
```
**具体步骤**:
##### Step 1: 获取上一行的有效状态
```python
# 找到所有 dp[i-1][j] > -inf 的位置
valid_prev_indices = [j for j in range(48) if dp[i-1][j] > -np.inf]
# 例如处理第1行时:
# valid_prev_indices = [2] # 因为dp[0][2] = 0.98
```
##### Step 2: 剪枝优化
```python
if len(valid_prev_indices) > 30:
# 只保留得分最高的前30个
valid_prev_indices.sort(key=lambda j: dp[i-1][j], reverse=True)
valid_prev_indices = valid_prev_indices[:30]
```
##### Step 3: 允许跳过HTML行
```python
# 如果当前HTML行没有匹配,可以跳过(继承上一行状态)
for prev_j in valid_prev_indices:
score_skip = dp[i-1][prev_j] - 0.3 # SKIP_HTML_PENALTY
if score_skip > dp[i][prev_j]:
dp[i][prev_j] = score_skip
path[(i, prev_j)] = (prev_j, prev_j + 1) # 空范围,表示跳过
```
##### Step 4: 正常匹配逻辑
```python
for prev_j in valid_prev_indices: # 例如:prev_j = 2
prev_score = dp[i-1][prev_j] # 例如:0.98
max_gap = min(15, 48 - prev_j - 1) # 最多向前搜索15个组
for gap in range(max_gap): # gap: 0..14
start_j = prev_j + 1 + gap # 例如:gap=0 → start_j=3
for count in range(1, 5): # count: 1..4
end_j = start_j + count - 1 # 例如:count=2 → end_j=4
# 获取合并文本
current_text = merged_cache.get((start_j, count), "")
# 长度预筛选(避免明显不匹配的情况)
if len(html_text) > 10 and len(current_text) < len(html_text) * 0.2:
continue # 跳过
# 计算相似度
similarity = calculate_similarity(html_text, current_text)
# 计算惩罚
gap_penalty = gap * 0.1 # 跳过组的惩罚
len_penalty = ... # 长度不匹配的惩罚
current_score = similarity - gap_penalty - len_penalty
if current_score > 0.1: # 只有正收益才转移
total_score = prev_score + current_score
if total_score > dp[i][end_j]:
dp[i][end_j] = total_score
path[(i, end_j)] = (prev_j, start_j)
```
**示例:处理HTML行1(账号信息行)**
假设 `dp[0][2] = 0.98`(上一行匹配到组2)
| gap | start_j | count | end_j | 匹配的OCR组 | 相似度 | 总得分 | 是否更新 |
|-----|---------|-------|-------|-------------|--------|--------|----------|
| 0 | 3 | 1 | 3 | 组3 | 0.85 | 0.98+0.85=1.83 | ✅ |
| 0 | 3 | 2 | 4 | 组3+组4 | 0.92 | 0.98+0.92=1.90 | ✅ |
| 1 | 4 | 1 | 4 | 组4 | 0.20 | 0.98+0.20=1.18 | ✅ |
| ... | ... | ... | ... | ... | ... | ... | ... |
最终:`dp[1][4] = 1.90`,`path[(1, 4)] = (2, 3)`
---
### 阶段5:回溯找最优路径
#### 5.1 找到最优终点
```python
best_end_j = -1
max_score = -np.inf
# 从最后一行往前找
for i in range(6, -1, -1): # i: 6, 5, 4, ..., 0
for j in range(48):
if dp[i][j] > max_score:
max_score = dp[i][j]
best_end_j = j
best_last_row = i
# 例如:dp[6][47] = 5.2 是最大值
# → best_end_j = 47, best_last_row = 6
```
#### 5.2 回溯构建映射
```python
mapping = {}
curr_i = 6 # best_last_row
curr_j = 47 # best_end_j
while curr_i >= 0:
if (curr_i, curr_j) in path:
prev_j, start_j = path[(6, 47)] # 例如:(45, 46)
# 如果 start_j <= curr_j,说明消耗了OCR组
if start_j <= curr_j: # 46 <= 47? True
indices = list(range(start_j, curr_j + 1)) # [46, 47]
mapping[6] = [46, 47] # HTML行6匹配OCR组46+47
curr_j = prev_j # 45
curr_i -= 1 # 5
else:
break
# 继续回溯...
# mapping[5] = [40, 41, 42, 43, 44, 45]
# mapping[4] = [38, 39]
# ...
# mapping[0] = [0, 1, 2]
```
**最终映射结果**:
```python
mapping = {
0: [0, 1, 2], # HTML行0 → OCR组0+1+2
1: [3, 4], # HTML行1 → OCR组3+4
2: [5, 6], # HTML行2 → OCR组5+6
3: [7, 8, 9], # HTML行3 → OCR组7+8+9
4: [10, 11], # HTML行4 → OCR组10+11
5: [12, 13, ..., 39], # HTML行5 → OCR组12..39(表头)
6: [40, 41, ..., 47] # HTML行6 → OCR组40..47(数据行)
}
```
---
### 阶段6:后处理
#### 6.1 处理未匹配的OCR组(Orphans)
```python
used_groups = {0, 1, 2, ..., 47} # 已使用的组
unused_groups = [] # 如果没有未使用的组,跳过
# 如果有未使用的组,找到最近的已匹配组,合并过去
```
#### 6.2 按Y坐标排序
```python
for row_idx in mapping:
if mapping[row_idx]:
# 按OCR组的Y坐标排序,确保顺序正确
mapping[row_idx].sort(key=lambda idx: grouped_boxes[idx]['y_center'])
```
---
## 📊 算法复杂度
- **时间复杂度**:O(n_html × n_paddle × SEARCH_WINDOW × MAX_MERGE)
- 本例:7 × 48 × 15 × 4 ≈ 20,160 次计算
- **空间复杂度**:O(n_html × n_paddle)
- 本例:7 × 48 = 336 个DP状态
---
## 🎨 可视化示例
### DP状态矩阵(简化版)
```
OCR组索引 →
HTML 0 1 2 3 4 5 6 7 ... 47
行↓
0 [0.98] - - - - - - ... -
1 - - - [1.90] - - - ... -
2 - - - - - [2.85] - ... -
3 - - - - - - [3.80] ... -
4 - - - - - - - [4.75] ... -
5 - - - - - - - - ... [5.20]
6 - - - - - - - - ... [5.50] ← 最优终点
```
### 匹配路径(箭头表示)
```
HTML行0 → OCR组[0,1,2]
↓
HTML行1 → OCR组[3,4]
↓
HTML行2 → OCR组[5,6]
↓
...
↓
HTML行6 → OCR组[40..47]
```
---
## 🔑 关键优化点
1. **预计算合并文本**:避免重复拼接,提升性能
2. **剪枝优化**:只保留前30个最优状态,防止组合爆炸
3. **允许跳过HTML行**:提高鲁棒性,处理OCR漏检
4. **长度预筛选**:快速排除明显不匹配的情况
5. **多组合并**:支持一个HTML行匹配多个OCR组(MAX_MERGE=4)
---
## 📝 总结
这个算法通过**动态规划**实现了:
- ✅ 智能匹配HTML行与OCR组
- ✅ 支持1对多匹配(合并单元格)
- ✅ 处理OCR漏检(跳过HTML行)
- ✅ 处理顺序不一致(通过gap搜索)
- ✅ 高效计算(预计算+剪枝)
最终得到每个HTML行对应的OCR组索引列表,用于后续的单元格匹配。