表格行匹配算法详解.md 11 KB

表格行匹配算法详解:_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 提取文本

# 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 快速路径:行数相等时的验证

if len(html_rows) == len(grouped_boxes):  # 7 == 48? False
    # 不执行此分支

实际场景:7行 ≠ 48组,需要DP算法


阶段2:预计算合并文本缓存

2.1 构建 merged_cache

为了支持一个HTML行匹配多个OCR组,预计算所有可能的组合:

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状态定义

# 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组的最佳匹配

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: 获取上一行的有效状态
# 找到所有 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: 剪枝优化
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行
# 如果当前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: 正常匹配逻辑
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.90path[(1, 4)] = (2, 3)


阶段5:回溯找最优路径

5.1 找到最优终点

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 回溯构建映射

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]

最终映射结果

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)

used_groups = {0, 1, 2, ..., 47}  # 已使用的组
unused_groups = []  # 如果没有未使用的组,跳过

# 如果有未使用的组,找到最近的已匹配组,合并过去

6.2 按Y坐标排序

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组索引列表,用于后续的单元格匹配。