table_cell_matcher_v3.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906
  1. """
  2. 表格单元格匹配器
  3. 负责将 HTML 表格单元格与 PaddleOCR bbox 进行匹配
  4. """
  5. from typing import List, Dict, Tuple, Optional
  6. from bs4 import BeautifulSoup
  7. import numpy as np
  8. try:
  9. from .text_matcher import TextMatcher
  10. from .bbox_extractor import BBoxExtractor
  11. except ImportError:
  12. from text_matcher import TextMatcher
  13. from bbox_extractor import BBoxExtractor
  14. class TableCellMatcher:
  15. """表格单元格匹配器"""
  16. def __init__(self, text_matcher: TextMatcher,
  17. x_tolerance: int = 3,
  18. y_tolerance: int = 10):
  19. """
  20. Args:
  21. text_matcher: 文本匹配器
  22. x_tolerance: X轴容差(用于列边界判断)
  23. y_tolerance: Y轴容差(用于行分组)
  24. """
  25. self.text_matcher = text_matcher
  26. self.x_tolerance = x_tolerance
  27. self.y_tolerance = y_tolerance
  28. def enhance_table_html_with_bbox(self, html: str, paddle_text_boxes: List[Dict],
  29. start_pointer: int, table_bbox: Optional[List[int]] = None) -> Tuple[str, List[Dict], int]:
  30. """
  31. 为 HTML 表格添加 bbox 信息(优化版:先筛选表格区域)
  32. 策略:
  33. 1. 根据 table_bbox 筛选出表格区域内的 paddle_text_boxes
  34. 2. 将筛选后的 boxes 按行分组
  35. 3. 智能匹配 HTML 行与 paddle 行组
  36. 4. 在匹配的组内查找单元格
  37. Args:
  38. html: HTML 表格
  39. paddle_text_boxes: 全部 paddle OCR 结果
  40. start_pointer: 开始位置
  41. table_bbox: 表格边界框 [x1, y1, x2, y2]
  42. """
  43. soup = BeautifulSoup(html, 'html.parser')
  44. cells = []
  45. # 🔑 第一步:筛选表格区域内的 paddle boxes
  46. table_region_boxes, actual_table_bbox = self._filter_boxes_in_table_region(
  47. paddle_text_boxes[start_pointer:],
  48. table_bbox,
  49. html
  50. )
  51. if not table_region_boxes:
  52. print(f"⚠️ 未在表格区域找到 paddle boxes")
  53. return str(soup), cells, start_pointer
  54. print(f"📊 表格区域: {len(table_region_boxes)} 个文本框")
  55. print(f" 边界: {actual_table_bbox}")
  56. # 🔑 第二步:将表格区域的 boxes 按行分组
  57. grouped_boxes = self._group_paddle_boxes_by_rows(
  58. table_region_boxes,
  59. y_tolerance=self.y_tolerance,
  60. auto_correct_skew=True
  61. )
  62. # 🔑 第三步:在每组内按 x 坐标排序
  63. for group in grouped_boxes:
  64. group['boxes'].sort(key=lambda x: x['bbox'][0])
  65. grouped_boxes.sort(key=lambda g: g['y_center'])
  66. print(f" 分组: {len(grouped_boxes)} 行")
  67. # 🔑 第四步:智能匹配 HTML 行与 paddle 行组
  68. html_rows = soup.find_all('tr')
  69. row_mapping = self._match_html_rows_to_paddle_groups(html_rows, grouped_boxes)
  70. print(f" HTML行: {len(html_rows)} 行")
  71. print(f" 映射: {len([v for v in row_mapping.values() if v])} 个有效映射")
  72. # 🔑 第五步:遍历 HTML 表格,使用映射关系查找
  73. for row_idx, row in enumerate(html_rows):
  74. group_indices = row_mapping.get(row_idx, [])
  75. if not group_indices:
  76. continue
  77. # 合并多个组的 boxes
  78. current_boxes = []
  79. for group_idx in group_indices:
  80. if group_idx < len(grouped_boxes):
  81. current_boxes.extend(grouped_boxes[group_idx]['boxes'])
  82. current_boxes.sort(key=lambda x: x['bbox'][0])
  83. # 🎯 关键改进:提取 HTML 单元格并预先确定列边界
  84. html_cells = row.find_all(['td', 'th'])
  85. if not html_cells:
  86. continue
  87. # 🔑 预估列边界(基于 x 坐标分布)
  88. col_boundaries = self._estimate_column_boundaries(
  89. current_boxes,
  90. len(html_cells)
  91. )
  92. print(f" 行 {row_idx + 1}: {len(html_cells)} 列,边界: {col_boundaries}")
  93. # 🎯 关键改进:顺序指针匹配
  94. box_pointer = 0 # 当前行的 boxes 指针
  95. for col_idx, cell in enumerate(html_cells):
  96. cell_text = cell.get_text(strip=True)
  97. if not cell_text:
  98. continue
  99. # 🔑 从当前指针开始匹配
  100. matched_result = self._match_cell_sequential(
  101. cell_text,
  102. current_boxes,
  103. col_boundaries,
  104. box_pointer
  105. )
  106. if matched_result:
  107. merged_bbox = matched_result['bbox']
  108. merged_text = matched_result['text']
  109. cell['data-bbox'] = f"[{merged_bbox[0]},{merged_bbox[1]},{merged_bbox[2]},{merged_bbox[3]}]"
  110. cell['data-score'] = f"{matched_result['score']:.4f}"
  111. cell['data-paddle-indices'] = str(matched_result['paddle_indices'])
  112. cells.append({
  113. 'type': 'table_cell',
  114. 'text': cell_text,
  115. 'matched_text': merged_text,
  116. 'bbox': merged_bbox,
  117. 'row': row_idx + 1,
  118. 'col': col_idx + 1,
  119. 'score': matched_result['score'],
  120. 'paddle_bbox_indices': matched_result['paddle_indices']
  121. })
  122. # 标记已使用
  123. for box in matched_result['used_boxes']:
  124. box['used'] = True
  125. # 🎯 移动指针到最后使用的 box 之后
  126. box_pointer = matched_result['last_used_index'] + 1
  127. print(f" 列 {col_idx + 1}: '{cell_text[:20]}...' 匹配 {len(matched_result['used_boxes'])} 个box (指针: {box_pointer})")
  128. # 计算新的指针位置
  129. used_count = sum(1 for box in table_region_boxes if box.get('used'))
  130. new_pointer = start_pointer + used_count
  131. print(f" 匹配: {len(cells)} 个单元格")
  132. return str(soup), cells, new_pointer
  133. def _estimate_column_boundaries(self, boxes: List[Dict],
  134. num_cols: int) -> List[Tuple[int, int]]:
  135. """
  136. 估算列边界(改进版:处理同列多文本框)
  137. Args:
  138. boxes: 当前行的所有 boxes(已按 x 排序)
  139. num_cols: HTML 表格的列数
  140. Returns:
  141. 列边界列表 [(x_start, x_end), ...]
  142. """
  143. if not boxes:
  144. return []
  145. # 🔑 关键改进:先按 x 坐标聚类(合并同列的多个文本框)
  146. x_clusters = self._cluster_boxes_by_x(boxes, x_tolerance=self.x_tolerance)
  147. print(f" X聚类: {len(boxes)} 个boxes -> {len(x_clusters)} 个列簇")
  148. # 获取所有 x 坐标范围
  149. x_min = min(cluster['x_min'] for cluster in x_clusters)
  150. x_max = max(cluster['x_max'] for cluster in x_clusters)
  151. # 🎯 策略 1: 如果聚类数量<=列数接近
  152. if len(x_clusters) <= num_cols:
  153. # 直接使用聚类边界
  154. boundaries = [(cluster['x_min'], cluster['x_max'])
  155. for cluster in x_clusters]
  156. return boundaries
  157. # 🎯 策略 2: 聚类数多于列数(某些列有多个文本簇)
  158. if len(x_clusters) > num_cols:
  159. print(f" ℹ️ 聚类数 {len(x_clusters)} > 列数 {num_cols},合并相近簇")
  160. # 合并相近的簇
  161. merged_clusters = self._merge_close_clusters(x_clusters, num_cols)
  162. boundaries = [(cluster['x_min'], cluster['x_max'])
  163. for cluster in merged_clusters]
  164. return boundaries
  165. return []
  166. def _cluster_boxes_by_x(self, boxes: List[Dict],
  167. x_tolerance: int = 3) -> List[Dict]:
  168. """
  169. 按 x 坐标聚类(合并同列的多个文本框)
  170. Args:
  171. boxes: 文本框列表
  172. x_tolerance: X坐标容忍度
  173. Returns:
  174. 聚类列表 [{'x_min': int, 'x_max': int, 'boxes': List[Dict]}, ...]
  175. """
  176. if not boxes:
  177. return []
  178. # 按左边界 x 坐标排序
  179. sorted_boxes = sorted(boxes, key=lambda b: b['bbox'][0])
  180. clusters = []
  181. current_cluster = None
  182. for box in sorted_boxes:
  183. bbox = box['bbox']
  184. x_start = bbox[0]
  185. x_end = bbox[2]
  186. if current_cluster is None:
  187. # 开始新簇
  188. current_cluster = {
  189. 'x_min': x_start,
  190. 'x_max': x_end,
  191. 'boxes': [box]
  192. }
  193. else:
  194. # 🔑 检查是否属于当前簇(修正后的逻辑)
  195. # 1. x 坐标有重叠:x_start <= current_x_max 且 x_end >= current_x_min
  196. # 2. 或者距离在容忍度内
  197. has_overlap = (x_start <= current_cluster['x_max'] and
  198. x_end >= current_cluster['x_min'])
  199. is_close = abs(x_start - current_cluster['x_max']) <= x_tolerance
  200. if has_overlap or is_close:
  201. # 合并到当前簇
  202. current_cluster['boxes'].append(box)
  203. current_cluster['x_min'] = min(current_cluster['x_min'], x_start)
  204. current_cluster['x_max'] = max(current_cluster['x_max'], x_end)
  205. else:
  206. # 保存当前簇,开始新簇
  207. clusters.append(current_cluster)
  208. current_cluster = {
  209. 'x_min': x_start,
  210. 'x_max': x_end,
  211. 'boxes': [box]
  212. }
  213. # 添加最后一簇
  214. if current_cluster:
  215. clusters.append(current_cluster)
  216. return clusters
  217. def _merge_close_clusters(self, clusters: List[Dict],
  218. target_count: int) -> List[Dict]:
  219. """
  220. 合并相近的簇,直到数量等于目标列数
  221. Args:
  222. clusters: 聚类列表
  223. target_count: 目标列数
  224. Returns:
  225. 合并后的聚类列表
  226. """
  227. if len(clusters) <= target_count:
  228. return clusters
  229. # 复制一份,避免修改原数据
  230. working_clusters = [c.copy() for c in clusters]
  231. while len(working_clusters) > target_count:
  232. # 找到距离最近的两个簇
  233. min_distance = float('inf')
  234. merge_idx = 0
  235. for i in range(len(working_clusters) - 1):
  236. distance = working_clusters[i + 1]['x_min'] - working_clusters[i]['x_max']
  237. if distance < min_distance:
  238. min_distance = distance
  239. merge_idx = i
  240. # 合并
  241. cluster1 = working_clusters[merge_idx]
  242. cluster2 = working_clusters[merge_idx + 1]
  243. merged_cluster = {
  244. 'x_min': cluster1['x_min'],
  245. 'x_max': cluster2['x_max'],
  246. 'boxes': cluster1['boxes'] + cluster2['boxes']
  247. }
  248. # 替换
  249. working_clusters[merge_idx] = merged_cluster
  250. working_clusters.pop(merge_idx + 1)
  251. return working_clusters
  252. def _get_boxes_in_column(self, boxes: List[Dict],
  253. boundaries: List[Tuple[int, int]],
  254. col_idx: int) -> List[Dict]:
  255. """
  256. 获取指定列范围内的 boxes(改进版:包含重叠)
  257. Args:
  258. boxes: 当前行的所有 boxes
  259. boundaries: 列边界
  260. col_idx: 列索引
  261. Returns:
  262. 该列的 boxes
  263. """
  264. if col_idx >= len(boundaries):
  265. return []
  266. x_start, x_end = boundaries[col_idx]
  267. col_boxes = []
  268. for box in boxes:
  269. bbox = box['bbox']
  270. box_x_start = bbox[0]
  271. box_x_end = bbox[2]
  272. # 🔑 改进:检查是否有重叠(不只是中心点)
  273. overlap = not (box_x_start > x_end or box_x_end < x_start)
  274. if overlap:
  275. col_boxes.append(box)
  276. return col_boxes
  277. def _filter_boxes_in_table_region(self, paddle_boxes: List[Dict],
  278. table_bbox: Optional[List[int]],
  279. html: str) -> Tuple[List[Dict], List[int]]:
  280. """
  281. 筛选表格区域内的 paddle boxes
  282. 策略:
  283. 1. 如果有 table_bbox,使用边界框筛选(扩展边界)
  284. 2. 如果没有 table_bbox,通过内容匹配推断区域
  285. Args:
  286. paddle_boxes: paddle OCR 结果
  287. table_bbox: 表格边界框 [x1, y1, x2, y2]
  288. html: HTML 内容(用于内容验证)
  289. Returns:
  290. (筛选后的 boxes, 实际表格边界框)
  291. """
  292. if not paddle_boxes:
  293. return [], [0, 0, 0, 0]
  294. # 🎯 策略 1: 使用提供的 table_bbox(扩展边界)
  295. if table_bbox and len(table_bbox) == 4:
  296. x1, y1, x2, y2 = table_bbox
  297. # 扩展边界(考虑边框外的文本)
  298. margin = 20
  299. expanded_bbox = [
  300. max(0, x1 - margin),
  301. max(0, y1 - margin),
  302. x2 + margin,
  303. y2 + margin
  304. ]
  305. filtered = []
  306. for box in paddle_boxes:
  307. bbox = box['bbox']
  308. box_center_x = (bbox[0] + bbox[2]) / 2
  309. box_center_y = (bbox[1] + bbox[3]) / 2
  310. # 中心点在扩展区域内
  311. if (expanded_bbox[0] <= box_center_x <= expanded_bbox[2] and
  312. expanded_bbox[1] <= box_center_y <= expanded_bbox[3]):
  313. filtered.append(box)
  314. if filtered:
  315. # 计算实际边界框
  316. actual_bbox = [
  317. min(b['bbox'][0] for b in filtered),
  318. min(b['bbox'][1] for b in filtered),
  319. max(b['bbox'][2] for b in filtered),
  320. max(b['bbox'][3] for b in filtered)
  321. ]
  322. return filtered, actual_bbox
  323. # 🎯 策略 2: 通过内容匹配推断区域
  324. print(" ℹ️ 无 table_bbox,使用内容匹配推断表格区域...")
  325. # 提取 HTML 中的所有文本
  326. from bs4 import BeautifulSoup
  327. soup = BeautifulSoup(html, 'html.parser')
  328. html_texts = set()
  329. for cell in soup.find_all(['td', 'th']):
  330. text = cell.get_text(strip=True)
  331. if text:
  332. html_texts.add(self.text_matcher.normalize_text(text))
  333. if not html_texts:
  334. return [], [0, 0, 0, 0]
  335. # 找出与 HTML 内容匹配的 boxes
  336. matched_boxes = []
  337. for box in paddle_boxes:
  338. normalized_text = self.text_matcher.normalize_text(box['text'])
  339. # 检查是否匹配
  340. if any(normalized_text in ht or ht in normalized_text
  341. for ht in html_texts):
  342. matched_boxes.append(box)
  343. if not matched_boxes:
  344. # 🔑 降级:如果精确匹配失败,使用模糊匹配
  345. print(" ℹ️ 精确匹配失败,尝试模糊匹配...")
  346. from fuzzywuzzy import fuzz
  347. for box in paddle_boxes:
  348. normalized_text = self.text_matcher.normalize_text(box['text'])
  349. for ht in html_texts:
  350. similarity = fuzz.partial_ratio(normalized_text, ht)
  351. if similarity >= 70: # 降低阈值
  352. matched_boxes.append(box)
  353. break
  354. if matched_boxes:
  355. # 计算边界框
  356. actual_bbox = [
  357. min(b['bbox'][0] for b in matched_boxes),
  358. min(b['bbox'][1] for b in matched_boxes),
  359. max(b['bbox'][2] for b in matched_boxes),
  360. max(b['bbox'][3] for b in matched_boxes)
  361. ]
  362. # 🔑 扩展边界,包含可能遗漏的文本
  363. margin = 30
  364. expanded_bbox = [
  365. max(0, actual_bbox[0] - margin),
  366. max(0, actual_bbox[1] - margin),
  367. actual_bbox[2] + margin,
  368. actual_bbox[3] + margin
  369. ]
  370. # 重新筛选(包含边界上的文本)
  371. final_filtered = []
  372. for box in paddle_boxes:
  373. bbox = box['bbox']
  374. box_center_x = (bbox[0] + bbox[2]) / 2
  375. box_center_y = (bbox[1] + bbox[3]) / 2
  376. if (expanded_bbox[0] <= box_center_x <= expanded_bbox[2] and
  377. expanded_bbox[1] <= box_center_y <= expanded_bbox[3]):
  378. final_filtered.append(box)
  379. return final_filtered, actual_bbox
  380. # 🔑 最后的降级:返回所有 boxes
  381. print(" ⚠️ 无法确定表格区域,使用所有 paddle boxes")
  382. if paddle_boxes:
  383. actual_bbox = [
  384. min(b['bbox'][0] for b in paddle_boxes),
  385. min(b['bbox'][1] for b in paddle_boxes),
  386. max(b['bbox'][2] for b in paddle_boxes),
  387. max(b['bbox'][3] for b in paddle_boxes)
  388. ]
  389. return paddle_boxes, actual_bbox
  390. return [], [0, 0, 0, 0]
  391. def _group_paddle_boxes_by_rows(self, paddle_boxes: List[Dict],
  392. y_tolerance: int = 10,
  393. auto_correct_skew: bool = True) -> List[Dict]:
  394. """
  395. 将 paddle_text_boxes 按 y 坐标分组(聚类)- 增强版本
  396. Args:
  397. paddle_boxes: Paddle OCR 文字框列表
  398. y_tolerance: Y 坐标容忍度(像素)
  399. auto_correct_skew: 是否自动校正倾斜
  400. Returns:
  401. 分组列表,每组包含 {'y_center': float, 'boxes': List[Dict]}
  402. """
  403. if not paddle_boxes:
  404. return []
  405. # 🎯 步骤 1: 检测并校正倾斜(使用 BBoxExtractor)
  406. if auto_correct_skew:
  407. rotation_angle = BBoxExtractor.calculate_skew_angle(paddle_boxes)
  408. if abs(rotation_angle) > 0.5:
  409. max_x = max(box['bbox'][2] for box in paddle_boxes)
  410. max_y = max(box['bbox'][3] for box in paddle_boxes)
  411. image_size = (max_x, max_y)
  412. print(f" 🔧 校正倾斜角度: {rotation_angle:.2f}°")
  413. paddle_boxes = BBoxExtractor.correct_boxes_skew(
  414. paddle_boxes, -rotation_angle, image_size
  415. )
  416. # 🎯 步骤 2: 按校正后的 y 坐标分组
  417. boxes_with_y = []
  418. for box in paddle_boxes:
  419. bbox = box['bbox']
  420. y_center = (bbox[1] + bbox[3]) / 2
  421. boxes_with_y.append({
  422. 'y_center': y_center,
  423. 'box': box
  424. })
  425. # 按 y 坐标排序
  426. boxes_with_y.sort(key=lambda x: x['y_center'])
  427. groups = []
  428. current_group = None
  429. for item in boxes_with_y:
  430. if current_group is None:
  431. # 开始新组
  432. current_group = {
  433. 'y_center': item['y_center'],
  434. 'boxes': [item['box']]
  435. }
  436. else:
  437. if abs(item['y_center'] - current_group['y_center']) <= y_tolerance:
  438. current_group['boxes'].append(item['box'])
  439. # 更新组的中心
  440. current_group['y_center'] = sum(
  441. (b['bbox'][1] + b['bbox'][3]) / 2 for b in current_group['boxes']
  442. ) / len(current_group['boxes'])
  443. else:
  444. groups.append(current_group)
  445. current_group = {
  446. 'y_center': item['y_center'],
  447. 'boxes': [item['box']]
  448. }
  449. if current_group:
  450. groups.append(current_group)
  451. print(f" ✓ 分组完成: {len(groups)} 行")
  452. return groups
  453. def _match_html_rows_to_paddle_groups(self, html_rows: List,
  454. grouped_boxes: List[Dict]) -> Dict[int, List[int]]:
  455. """
  456. 智能匹配 HTML 行与 paddle 分组(优化版:支持跳过无关组 + 防贪婪)
  457. """
  458. if not html_rows or not grouped_boxes:
  459. return {}
  460. mapping = {}
  461. # 🎯 策略 1: 数量相等,简单 1:1 映射
  462. if len(html_rows) == len(grouped_boxes):
  463. for i in range(len(html_rows)):
  464. mapping[i] = [i]
  465. return mapping
  466. # 🎯 策略 2: 基于内容匹配(带跳过机制的单调匹配)
  467. # 提取 HTML 文本
  468. html_row_texts = []
  469. for row in html_rows:
  470. cells = row.find_all(['td', 'th'])
  471. texts = [self.text_matcher.normalize_text(c.get_text(strip=True)) for c in cells]
  472. html_row_texts.append("".join(texts))
  473. # 辅助函数:获取组的文本
  474. def get_group_text(idx):
  475. if 0 <= idx < len(grouped_boxes):
  476. boxes = grouped_boxes[idx]['boxes']
  477. # 注意:这里不排序,假设 grouped_boxes 内部已经按 X 排序
  478. texts = [self.text_matcher.normalize_text(b['text']) for b in boxes]
  479. return "".join(texts)
  480. return ""
  481. paddle_idx = 0
  482. num_paddle = len(grouped_boxes)
  483. used_groups = set()
  484. for html_idx, html_row_text in enumerate(html_row_texts):
  485. if paddle_idx >= num_paddle:
  486. mapping[html_idx] = []
  487. continue
  488. if not html_row_text:
  489. mapping[html_idx] = []
  490. continue
  491. # --- 步骤 1: 在搜索窗口内找到最佳的"起始"组 ---
  492. best_score = 0.0
  493. best_start_idx = -1
  494. # 搜索窗口:当前指针向后 10 个组
  495. search_limit = min(paddle_idx + 10, num_paddle)
  496. for i in range(paddle_idx, search_limit):
  497. # 计算单组得分
  498. group_text = get_group_text(i)
  499. score = self._calculate_similarity(html_row_text, group_text)
  500. # 距离惩罚(越远的组扣分越多,优先匹配最近的)
  501. dist_penalty = (i - paddle_idx) * 0.02
  502. final_score = score - dist_penalty
  503. if final_score > best_score and final_score > 0.3: # 基础阈值
  504. best_score = final_score
  505. best_start_idx = i
  506. if best_start_idx == -1:
  507. mapping[html_idx] = []
  508. continue
  509. # --- 步骤 2: 贪婪合并 (Look-ahead Merge) ---
  510. current_indices = [best_start_idx]
  511. current_text = get_group_text(best_start_idx)
  512. current_score = self._calculate_similarity(html_row_text, current_text)
  513. next_probe = best_start_idx + 1
  514. max_merge = 5 # 限制最大合并行数
  515. while next_probe < num_paddle and len(current_indices) < max_merge:
  516. next_group_text = get_group_text(next_probe)
  517. # 🛑 关键改进:前瞻检查 (Look-ahead Check)
  518. # 检查候选组是否更属于下一行
  519. should_stop = False
  520. if html_idx + 1 < len(html_row_texts):
  521. next_html_text = html_row_texts[html_idx + 1]
  522. # 计算该组属于当前行的程度 vs 属于下一行的程度
  523. # 使用 (group, html) 顺序,计算 group 被 html 覆盖的程度
  524. match_current = self._calculate_similarity(next_group_text, html_row_text)
  525. match_next = self._calculate_similarity(next_group_text, next_html_text)
  526. # 如果更匹配下一行,且匹配度可信(>0.5),则停止
  527. if match_next > match_current and match_next > 0.5:
  528. print(f" 🛑 停止合并组 {next_probe}: 更匹配下一行 (Next: {match_next:.2f} > Curr: {match_current:.2f})")
  529. should_stop = True
  530. if should_stop:
  531. break
  532. # 尝试合并
  533. combined_text = current_text + next_group_text
  534. new_score = self._calculate_similarity(html_row_text, combined_text)
  535. # 判定条件:分数提升
  536. if new_score > current_score + 0.01:
  537. current_indices.append(next_probe)
  538. current_text = combined_text
  539. current_score = new_score
  540. next_probe += 1
  541. else:
  542. break
  543. mapping[html_idx] = current_indices
  544. used_groups.update(current_indices)
  545. print(f" ✓ 行 {html_idx}: 匹配组 {current_indices} (得分: {current_score:.2f})")
  546. # 更新指针:跳过已使用的组
  547. paddle_idx = current_indices[-1] + 1
  548. # --- 步骤 3: 处理未匹配的组 (Orphans) ---
  549. unused_groups = [i for i in range(len(grouped_boxes)) if i not in used_groups]
  550. if unused_groups:
  551. print(f" ℹ️ 发现 {len(unused_groups)} 个未匹配的 paddle 组: {unused_groups}")
  552. for unused_idx in unused_groups:
  553. unused_group = grouped_boxes[unused_idx]
  554. unused_y_min = min(b['bbox'][1] for b in unused_group['boxes'])
  555. unused_y_max = max(b['bbox'][3] for b in unused_group['boxes'])
  556. above_idx = None
  557. below_idx = None
  558. above_distance = float('inf')
  559. below_distance = float('inf')
  560. for i in range(unused_idx - 1, -1, -1):
  561. if i in used_groups:
  562. above_idx = i
  563. above_group = grouped_boxes[i]
  564. max_y_box = max(above_group['boxes'], key=lambda b: b['bbox'][3])
  565. above_y_center = (max_y_box['bbox'][1] + max_y_box['bbox'][3]) / 2
  566. above_distance = abs(unused_y_min - above_y_center)
  567. break
  568. for i in range(unused_idx + 1, len(grouped_boxes)):
  569. if i in used_groups:
  570. below_idx = i
  571. below_group = grouped_boxes[i]
  572. min_y_box = min(below_group['boxes'], key=lambda b: b['bbox'][1])
  573. below_y_center = (min_y_box['bbox'][1] + min_y_box['bbox'][3]) / 2
  574. below_distance = abs(below_y_center - unused_y_max)
  575. break
  576. closest_used_idx = None
  577. merge_direction = ""
  578. if above_idx is not None and below_idx is not None:
  579. if above_distance < below_distance:
  580. closest_used_idx = above_idx
  581. merge_direction = "上方"
  582. else:
  583. closest_used_idx = below_idx
  584. merge_direction = "下方"
  585. elif above_idx is not None:
  586. closest_used_idx = above_idx
  587. merge_direction = "上方"
  588. elif below_idx is not None:
  589. closest_used_idx = below_idx
  590. merge_direction = "下方"
  591. if closest_used_idx is not None:
  592. target_html_row = None
  593. for html_row_idx, group_indices in mapping.items():
  594. if closest_used_idx in group_indices:
  595. target_html_row = html_row_idx
  596. break
  597. if target_html_row is not None:
  598. if unused_idx not in mapping[target_html_row]:
  599. mapping[target_html_row].append(unused_idx)
  600. mapping[target_html_row].sort()
  601. print(f" • 组 {unused_idx} 合并到 HTML 行 {target_html_row}({merge_direction}行)")
  602. used_groups.add(unused_idx)
  603. # 🔑 策略 4: 第三遍 - 按 y 坐标排序每行的组索引
  604. for row_idx in mapping:
  605. if mapping[row_idx]:
  606. mapping[row_idx].sort(key=lambda idx: grouped_boxes[idx]['y_center'])
  607. return mapping
  608. def _calculate_similarity(self, text1: str, text2: str) -> float:
  609. """
  610. 计算两个文本的相似度,结合字符覆盖率和序列相似度
  611. """
  612. if not text1 or not text2:
  613. return 0.0
  614. # 1. 字符覆盖率 (Character Overlap) - 解决乱序/交错问题
  615. from collections import Counter
  616. c1 = Counter(text1)
  617. c2 = Counter(text2)
  618. # 计算交集字符数
  619. intersection = c1 & c2
  620. overlap_count = sum(intersection.values())
  621. # 覆盖率:paddle 文本中有多少是 html 文本需要的
  622. coverage = overlap_count / len(text1) if len(text1) > 0 else 0
  623. # 2. 序列相似度 (Sequence Similarity) - 解决完全不相关但字符相似的问题
  624. from fuzzywuzzy import fuzz
  625. # 使用 token_sort_ratio 来容忍一定的乱序
  626. seq_score = fuzz.token_sort_ratio(text1, text2) / 100.0
  627. # 综合评分:侧重覆盖率,因为对于 OCR 结果合并,内容完整性比顺序更重要
  628. return (coverage * 0.7) + (seq_score * 0.3)
  629. def _match_cell_sequential(self, cell_text: str,
  630. boxes: List[Dict],
  631. col_boundaries: List[Tuple[int, int]],
  632. start_idx: int) -> Optional[Dict]:
  633. """
  634. 🎯 顺序匹配单元格:从指定位置开始,逐步合并 boxes 直到匹配
  635. 策略:
  636. 1. 找到第一个未使用的 box
  637. 2. 尝试单个 box 精确匹配
  638. 3. 如果失败,尝试合并多个 boxes
  639. Args:
  640. cell_text: HTML 单元格文本
  641. boxes: 候选 boxes(已按 x 坐标排序)
  642. col_boundaries: 列边界列表
  643. start_idx: 起始索引
  644. Returns:
  645. {'bbox': [x1,y1,x2,y2], 'text': str, 'score': float,
  646. 'paddle_indices': [idx1, idx2], 'used_boxes': [box1, box2],
  647. 'last_used_index': int}
  648. """
  649. from fuzzywuzzy import fuzz
  650. cell_text_normalized = self.text_matcher.normalize_text(cell_text)
  651. if len(cell_text_normalized) < 2:
  652. return None
  653. # 🔑 找到第一个未使用的 box
  654. first_unused_idx = start_idx
  655. while first_unused_idx < len(boxes) and boxes[first_unused_idx].get('used'):
  656. first_unused_idx += 1
  657. if first_unused_idx >= len(boxes):
  658. return None
  659. # 🔑 策略 1: 单个 box 精确匹配
  660. for box in boxes[first_unused_idx:]:
  661. if box.get('used'):
  662. continue
  663. box_text = self.text_matcher.normalize_text(box['text'])
  664. if cell_text_normalized == box_text:
  665. return self._build_match_result([box], box['text'], 100.0, boxes.index(box))
  666. # 🔑 策略 2: 多个 boxes 合并匹配
  667. unused_boxes = [b for b in boxes if not b.get('used')]
  668. # 合并同列的 boxes 合并
  669. merged_bboxes = []
  670. for col_idx in range(len(col_boundaries)):
  671. combo_boxes = self._get_boxes_in_column(unused_boxes, col_boundaries, col_idx)
  672. if len(combo_boxes) > 0:
  673. sorted_combo = sorted(combo_boxes, key=lambda b: (b['bbox'][1], b['bbox'][0]))
  674. merged_text = ''.join([b['text'] for b in sorted_combo])
  675. merged_bboxes.append({
  676. 'text': merged_text,
  677. 'sorted_combo': sorted_combo
  678. })
  679. for box in merged_bboxes:
  680. # 1. 精确匹配
  681. merged_text_normalized = self.text_matcher.normalize_text(box['text'])
  682. if cell_text_normalized == merged_text_normalized:
  683. last_sort_idx = boxes.index(box['sorted_combo'][-1])
  684. return self._build_match_result(box['sorted_combo'], box['text'], 100.0, last_sort_idx)
  685. # 2. 子串匹配
  686. is_substring = (cell_text_normalized in merged_text_normalized or
  687. merged_text_normalized in cell_text_normalized)
  688. # 3. 模糊匹配
  689. similarity = fuzz.partial_ratio(cell_text_normalized, merged_text_normalized)
  690. # 🎯 子串匹配加分
  691. if is_substring:
  692. similarity = min(100, similarity + 10)
  693. if similarity >= self.text_matcher.similarity_threshold:
  694. print(f" ✓ 匹配成功: '{cell_text[:15]}' vs '{merged_text[:15]}' (相似度: {similarity})")
  695. return self._build_match_result(box['sorted_combo'], box['text'], similarity, start_idx)
  696. print(f" ✗ 匹配失败: '{cell_text[:15]}'")
  697. return None
  698. def _build_match_result(self, boxes: List[Dict], text: str,
  699. score: float, last_index: int) -> Dict:
  700. """构建匹配结果(使用原始坐标)"""
  701. # 🔑 关键修复:使用 original_bbox(如果存在)
  702. def get_original_bbox(box: Dict) -> List[int]:
  703. return box.get('original_bbox', box['bbox'])
  704. original_bboxes = [get_original_bbox(b) for b in boxes]
  705. merged_bbox = [
  706. min(b[0] for b in original_bboxes),
  707. min(b[1] for b in original_bboxes),
  708. max(b[2] for b in original_bboxes),
  709. max(b[3] for b in original_bboxes)
  710. ]
  711. return {
  712. 'bbox': merged_bbox, # ✅ 使用原始坐标
  713. 'text': text,
  714. 'score': score,
  715. 'paddle_indices': [b['paddle_bbox_index'] for b in boxes],
  716. 'used_boxes': boxes,
  717. 'last_used_index': last_index
  718. }