merge_mineru_paddle_ocr.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. """
  2. 合并 MinerU 和 PaddleOCR 的结果
  3. 使用 MinerU 的表格结构识别 + PaddleOCR 的文字框坐标
  4. """
  5. import json
  6. import re
  7. import argparse
  8. from pathlib import Path
  9. from typing import List, Dict, Tuple, Optional
  10. from bs4 import BeautifulSoup
  11. from fuzzywuzzy import fuzz
  12. class MinerUPaddleOCRMerger:
  13. """合并 MinerU 和 PaddleOCR 的结果"""
  14. def __init__(self, look_ahead_window: int = 10, similarity_threshold: int = 80):
  15. """
  16. Args:
  17. look_ahead_window: 向前查找的窗口大小
  18. similarity_threshold: 文本相似度阈值
  19. """
  20. self.look_ahead_window = look_ahead_window
  21. self.similarity_threshold = similarity_threshold
  22. def merge_table_with_bbox(self, mineru_json_path: str, paddle_json_path: str) -> List[Dict]:
  23. """
  24. 合并 MinerU 和 PaddleOCR 的结果
  25. Args:
  26. mineru_json_path: MinerU 输出的 JSON 路径
  27. paddle_json_path: PaddleOCR 输出的 JSON 路径
  28. output_path: 输出路径(可选)
  29. Returns:
  30. 合并后的结果字典
  31. """
  32. merged_data = None
  33. # 加载数据
  34. with open(mineru_json_path, 'r', encoding='utf-8') as f:
  35. mineru_data = json.load(f)
  36. with open(paddle_json_path, 'r', encoding='utf-8') as f:
  37. paddle_data = json.load(f)
  38. # 提取 PaddleOCR 的文字框信息
  39. paddle_text_boxes = self._extract_paddle_text_boxes(paddle_data)
  40. # 处理 MinerU 的数据
  41. merged_data = self._process_mineru_data(mineru_data, paddle_text_boxes)
  42. return merged_data
  43. def _extract_paddle_text_boxes(self, paddle_data: Dict) -> List[Dict]:
  44. """提取 PaddleOCR 的文字框信息"""
  45. text_boxes = []
  46. if 'overall_ocr_res' in paddle_data:
  47. ocr_res = paddle_data['overall_ocr_res']
  48. rec_texts = ocr_res.get('rec_texts', [])
  49. rec_polys = ocr_res.get('rec_polys', [])
  50. rec_scores = ocr_res.get('rec_scores', [])
  51. for i, (text, poly, score) in enumerate(zip(rec_texts, rec_polys, rec_scores)):
  52. if text and text.strip():
  53. # 计算 bbox (x_min, y_min, x_max, y_max)
  54. xs = [p[0] for p in poly]
  55. ys = [p[1] for p in poly]
  56. bbox = [min(xs), min(ys), max(xs), max(ys)]
  57. text_boxes.append({
  58. 'text': text,
  59. 'bbox': bbox,
  60. 'poly': poly,
  61. 'score': score,
  62. 'paddle_bbox_index': i,
  63. 'used': False # 标记是否已被使用
  64. })
  65. return text_boxes
  66. def _process_mineru_data(self, mineru_data: List[Dict],
  67. paddle_text_boxes: List[Dict]) -> List[Dict]:
  68. """处理 MinerU 数据,添加 bbox 信息"""
  69. merged_data = []
  70. cells = None # 存储所有表格单元格信息
  71. paddle_pointer = 0 # PaddleOCR 文字框指针
  72. for item in mineru_data:
  73. if item['type'] == 'table':
  74. # 处理表格
  75. merged_item = item.copy()
  76. table_html = item.get('table_body', '')
  77. # 解析 HTML 表格并添加 bbox
  78. enhanced_html, cells, paddle_pointer = self._enhance_table_html_with_bbox(
  79. table_html, paddle_text_boxes, paddle_pointer
  80. )
  81. merged_item['table_body'] = enhanced_html
  82. merged_item['table_body_with_bbox'] = enhanced_html
  83. merged_item['bbox_mapping'] = 'merged_from_paddle_ocr'
  84. merged_data.append(merged_item)
  85. elif item['type'] in ['text', 'header']:
  86. # 处理普通文本
  87. merged_item = item.copy()
  88. text = item.get('text', '')
  89. # 查找匹配的 bbox
  90. matched_bbox, paddle_pointer = self._find_matching_bbox(
  91. text, paddle_text_boxes, paddle_pointer
  92. )
  93. if matched_bbox:
  94. merged_item['bbox'] = matched_bbox['bbox']
  95. merged_item['bbox_source'] = 'paddle_ocr'
  96. merged_item['text_score'] = matched_bbox['score']
  97. # 标记为已使用
  98. matched_bbox['used'] = True
  99. merged_data.append(merged_item)
  100. else:
  101. # 其他类型直接复制
  102. merged_data.append(item.copy())
  103. if cells:
  104. merged_data.extend(cells)
  105. return merged_data
  106. def _enhance_table_html_with_bbox(self, html: str, paddle_text_boxes: List[Dict],
  107. start_pointer: int) -> Tuple[str, List[Dict], int]:
  108. """
  109. 为 HTML 表格添加 bbox 信息
  110. Args:
  111. html: 原始 HTML 表格
  112. paddle_text_boxes: PaddleOCR 文字框列表
  113. start_pointer: 起始指针位置
  114. Returns:
  115. (增强后的 HTML, 单元格数组, 新的指针位置)
  116. """
  117. soup = BeautifulSoup(html, 'html.parser')
  118. current_pointer = start_pointer
  119. cells = [] # 存储单元格的 bbox 信息
  120. # 遍历所有单元格
  121. for cell in soup.find_all(['td', 'th']):
  122. cell_text = cell.get_text(strip=True)
  123. if not cell_text:
  124. continue
  125. # 查找匹配的 bbox
  126. matched_bbox, current_pointer = self._find_matching_bbox(
  127. cell_text, paddle_text_boxes, current_pointer
  128. )
  129. if matched_bbox:
  130. # 添加 data-bbox 属性
  131. bbox = matched_bbox['bbox']
  132. cell['data-bbox'] = f"[{bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}]"
  133. cell['data-score'] = f"{matched_bbox['score']:.4f}"
  134. cell['data-paddle-index'] = str(matched_bbox['paddle_bbox_index'])
  135. cells.append({
  136. 'type': 'table_cell',
  137. 'text': cell_text,
  138. 'bbox': bbox,
  139. 'score': matched_bbox['score'],
  140. 'paddle_bbox_index': matched_bbox['paddle_bbox_index']
  141. })
  142. # 标记为已使用
  143. matched_bbox['used'] = True
  144. return str(soup), cells, current_pointer
  145. def _find_matching_bbox(self, target_text: str, text_boxes: List[Dict],
  146. start_index: int) -> tuple[Optional[Dict], int]:
  147. """
  148. 查找匹配的文字框
  149. Args:
  150. target_text: 目标文本
  151. text_boxes: 文字框列表
  152. start_index: 起始索引
  153. Returns:
  154. (匹配的文字框信息, 新的指针位置)
  155. """
  156. target_text = self._normalize_text(target_text)
  157. # 在窗口范围内查找
  158. search_end = min(start_index + self.look_ahead_window, len(text_boxes))
  159. best_match = None
  160. best_index = start_index
  161. best_similarity = 0
  162. for i in range(start_index, search_end):
  163. if text_boxes[i]['used']:
  164. continue
  165. box_text = self._normalize_text(text_boxes[i]['text'])
  166. # 计算相似度
  167. similarity = fuzz.token_set_ratio(target_text, box_text)
  168. # 精确匹配优先
  169. if target_text == box_text:
  170. return text_boxes[i], i + 1
  171. # 记录最佳匹配
  172. if similarity > best_similarity and similarity >= self.similarity_threshold:
  173. best_similarity = similarity
  174. best_match = text_boxes[i]
  175. best_index = i + 1
  176. return best_match, best_index
  177. def _normalize_text(self, text: str) -> str:
  178. """标准化文本(去除空格、标点等)"""
  179. # 移除所有空白字符
  180. text = re.sub(r'\s+', '', text)
  181. # 转换全角数字和字母为半角
  182. text = self._full_to_half(text)
  183. return text.lower()
  184. def _full_to_half(self, text: str) -> str:
  185. """全角转半角"""
  186. result = []
  187. for char in text:
  188. code = ord(char)
  189. if code == 0x3000: # 全角空格
  190. code = 0x0020
  191. elif 0xFF01 <= code <= 0xFF5E: # 全角字符
  192. code -= 0xFEE0
  193. result.append(chr(code))
  194. return ''.join(result)
  195. def generate_enhanced_markdown(self, merged_data: List[Dict],
  196. output_path: Optional[str] = None) -> str:
  197. """
  198. 生成增强的 Markdown(包含 bbox 信息的注释)
  199. Args:
  200. merged_data: 合并后的数据
  201. output_path: 输出路径(可选)
  202. Returns:
  203. Markdown 内容
  204. """
  205. md_lines = []
  206. for item in merged_data:
  207. if item['type'] == 'header':
  208. text = item.get('text', '')
  209. bbox = item.get('bbox', [])
  210. md_lines.append(f"<!-- bbox: {bbox} -->")
  211. md_lines.append(f"# {text}\n")
  212. elif item['type'] == 'text':
  213. text = item.get('text', '')
  214. bbox = item.get('bbox', [])
  215. if bbox:
  216. md_lines.append(f"<!-- bbox: {bbox} -->")
  217. md_lines.append(f"{text}\n")
  218. elif item['type'] == 'table':
  219. md_lines.append("\n## 表格\n")
  220. md_lines.append("<!-- 表格单元格包含 data-bbox 属性 -->\n")
  221. md_lines.append(item.get('table_body_with_bbox', item.get('table_body', '')))
  222. md_lines.append("\n")
  223. markdown_content = '\n'.join(md_lines)
  224. if output_path:
  225. with open(output_path, 'w', encoding='utf-8') as f:
  226. f.write(markdown_content)
  227. return markdown_content
  228. def extract_table_cells_with_bbox(self, merged_data: List[Dict]) -> List[Dict]:
  229. """
  230. 提取所有表格单元格及其 bbox 信息
  231. Returns:
  232. 单元格列表,每个包含 text, bbox, row, col 等信息
  233. """
  234. cells = []
  235. for item in merged_data:
  236. if item['type'] != 'table':
  237. continue
  238. html = item.get('table_body_with_bbox', item.get('table_body', ''))
  239. soup = BeautifulSoup(html, 'html.parser')
  240. # 遍历所有行
  241. for row_idx, row in enumerate(soup.find_all('tr')):
  242. # 遍历所有单元格
  243. for col_idx, cell in enumerate(row.find_all(['td', 'th'])):
  244. cell_text = cell.get_text(strip=True)
  245. bbox_str = cell.get('data-bbox', '')
  246. if bbox_str:
  247. try:
  248. bbox = json.loads(bbox_str)
  249. cells.append({
  250. 'text': cell_text,
  251. 'bbox': bbox,
  252. 'row': row_idx,
  253. 'col': col_idx,
  254. 'score': float(cell.get('data-score', 0)),
  255. 'paddle_index': int(cell.get('data-paddle-index', -1))
  256. })
  257. except (json.JSONDecodeError, ValueError):
  258. pass
  259. return cells
  260. def merge_single_file(mineru_file: Path, paddle_file: Path, output_dir: Path,
  261. merger: MinerUPaddleOCRMerger) -> bool:
  262. """
  263. 合并单个文件
  264. Args:
  265. mineru_file: MinerU JSON 文件路径
  266. paddle_file: PaddleOCR JSON 文件路径
  267. output_dir: 输出目录
  268. merger: 合并器实例
  269. Returns:
  270. 是否成功
  271. """
  272. print(f"📄 处理: {mineru_file.name}")
  273. # 输出文件路径
  274. merged_json_path = output_dir / f"{mineru_file.stem}.json"
  275. try:
  276. # 合并数据
  277. merged_data = merger.merge_table_with_bbox(
  278. str(mineru_file),
  279. str(paddle_file)
  280. )
  281. # 生成 Markdown
  282. # merger.generate_enhanced_markdown(merged_data, str(merged_md_path))
  283. # 提取单元格信息
  284. # cells = merger.extract_table_cells_with_bbox(merged_data)
  285. with open(merged_json_path, 'w', encoding='utf-8') as f:
  286. json.dump(merged_data, f, ensure_ascii=False, indent=2)
  287. print(f" ✅ 合并完成")
  288. print(f" 📊 共处理了 {len(merged_data)} 个对象")
  289. print(f" 💾 输出文件:")
  290. print(f" - {merged_json_path.name}")
  291. return True
  292. except Exception as e:
  293. print(f" ❌ 处理失败: {e}")
  294. import traceback
  295. traceback.print_exc()
  296. return False
  297. def merge_mineru_paddle_batch(mineru_dir: str, paddle_dir: str, output_dir: str,
  298. look_ahead_window: int = 10,
  299. similarity_threshold: int = 80):
  300. """
  301. 批量合并 MinerU 和 PaddleOCR 的结果
  302. Args:
  303. mineru_dir: MinerU 结果目录
  304. paddle_dir: PaddleOCR 结果目录
  305. output_dir: 输出目录
  306. look_ahead_window: 向前查找窗口大小
  307. similarity_threshold: 相似度阈值
  308. """
  309. mineru_path = Path(mineru_dir)
  310. paddle_path = Path(paddle_dir)
  311. output_path = Path(output_dir)
  312. output_path.mkdir(parents=True, exist_ok=True)
  313. merger = MinerUPaddleOCRMerger(
  314. look_ahead_window=look_ahead_window,
  315. similarity_threshold=similarity_threshold
  316. )
  317. # 查找所有 MinerU 的 JSON 文件
  318. mineru_files = list(mineru_path.glob('*_page_*[0-9].json'))
  319. mineru_files.sort()
  320. print(f"\n🔍 找到 {len(mineru_files)} 个 MinerU 文件")
  321. print(f"📂 MinerU 目录: {mineru_dir}")
  322. print(f"📂 PaddleOCR 目录: {paddle_dir}")
  323. print(f"📂 输出目录: {output_dir}")
  324. print(f"⚙️ 查找窗口: {look_ahead_window}")
  325. print(f"⚙️ 相似度阈值: {similarity_threshold}%\n")
  326. success_count = 0
  327. failed_count = 0
  328. for mineru_file in mineru_files:
  329. # 查找对应的 PaddleOCR 文件
  330. paddle_file = paddle_path / mineru_file.name
  331. if not paddle_file.exists():
  332. print(f"⚠️ 跳过: 未找到对应的 PaddleOCR 文件: {paddle_file.name}\n")
  333. failed_count += 1
  334. continue
  335. if merge_single_file(mineru_file, paddle_file, output_path, merger):
  336. success_count += 1
  337. else:
  338. failed_count += 1
  339. print() # 空行分隔
  340. # 打印统计信息
  341. print("=" * 60)
  342. print(f"✅ 处理完成!")
  343. print(f"📊 统计信息:")
  344. print(f" - 总文件数: {len(mineru_files)}")
  345. print(f" - 成功: {success_count}")
  346. print(f" - 失败: {failed_count}")
  347. print("=" * 60)
  348. def main():
  349. """主函数"""
  350. parser = argparse.ArgumentParser(
  351. description='合并 MinerU 和 PaddleOCR 的识别结果,添加 bbox 坐标信息',
  352. formatter_class=argparse.RawDescriptionHelpFormatter,
  353. epilog="""
  354. 示例用法:
  355. 1. 批量处理整个目录:
  356. python merge_mineru_paddle_ocr.py \\
  357. --mineru-dir /path/to/mineru/results \\
  358. --paddle-dir /path/to/paddle/results \\
  359. --output-dir /path/to/output
  360. 2. 处理单个文件:
  361. python merge_mineru_paddle_ocr.py \\
  362. --mineru-file /path/to/file_page_001.json \\
  363. --paddle-file /path/to/file_page_001.json \\
  364. --output-dir /path/to/output
  365. 3. 自定义参数:
  366. python merge_mineru_paddle_ocr.py \\
  367. --mineru-dir /path/to/mineru \\
  368. --paddle-dir /path/to/paddle \\
  369. --output-dir /path/to/output \\
  370. --window 15 \\
  371. --threshold 85
  372. """
  373. )
  374. # 文件/目录参数
  375. file_group = parser.add_argument_group('文件参数')
  376. file_group.add_argument(
  377. '--mineru-file',
  378. type=str,
  379. help='MinerU 输出的 JSON 文件路径(单文件模式)'
  380. )
  381. file_group.add_argument(
  382. '--paddle-file',
  383. type=str,
  384. help='PaddleOCR 输出的 JSON 文件路径(单文件模式)'
  385. )
  386. dir_group = parser.add_argument_group('目录参数')
  387. dir_group.add_argument(
  388. '--mineru-dir',
  389. type=str,
  390. help='MinerU 结果目录(批量模式)'
  391. )
  392. dir_group.add_argument(
  393. '--paddle-dir',
  394. type=str,
  395. help='PaddleOCR 结果目录(批量模式)'
  396. )
  397. # 输出参数
  398. output_group = parser.add_argument_group('输出参数')
  399. output_group.add_argument(
  400. '-o', '--output-dir',
  401. type=str,
  402. required=True,
  403. help='输出目录(必需)'
  404. )
  405. # 算法参数
  406. algo_group = parser.add_argument_group('算法参数')
  407. algo_group.add_argument(
  408. '-w', '--window',
  409. type=int,
  410. default=10,
  411. help='向前查找的窗口大小(默认: 10)'
  412. )
  413. algo_group.add_argument(
  414. '-t', '--threshold',
  415. type=int,
  416. default=80,
  417. help='文本相似度阈值(0-100,默认: 80)'
  418. )
  419. args = parser.parse_args()
  420. # 验证参数
  421. if args.mineru_file and args.paddle_file:
  422. # 单文件模式
  423. mineru_file = Path(args.mineru_file)
  424. paddle_file = Path(args.paddle_file)
  425. output_dir = Path(args.output_dir)
  426. if not mineru_file.exists():
  427. print(f"❌ 错误: MinerU 文件不存在: {mineru_file}")
  428. return
  429. if not paddle_file.exists():
  430. print(f"❌ 错误: PaddleOCR 文件不存在: {paddle_file}")
  431. return
  432. output_dir.mkdir(parents=True, exist_ok=True)
  433. print("\n🔧 单文件处理模式")
  434. print(f"📄 MinerU 文件: {mineru_file}")
  435. print(f"📄 PaddleOCR 文件: {paddle_file}")
  436. print(f"📂 输出目录: {output_dir}")
  437. print(f"⚙️ 查找窗口: {args.window}")
  438. print(f"⚙️ 相似度阈值: {args.threshold}%\n")
  439. merger = MinerUPaddleOCRMerger(
  440. look_ahead_window=args.window,
  441. similarity_threshold=args.threshold
  442. )
  443. success = merge_single_file(mineru_file, paddle_file, output_dir, merger)
  444. if success:
  445. print("\n✅ 处理完成!")
  446. else:
  447. print("\n❌ 处理失败!")
  448. elif args.mineru_dir and args.paddle_dir:
  449. # 批量模式
  450. if not Path(args.mineru_dir).exists():
  451. print(f"❌ 错误: MinerU 目录不存在: {args.mineru_dir}")
  452. return
  453. if not Path(args.paddle_dir).exists():
  454. print(f"❌ 错误: PaddleOCR 目录不存在: {args.paddle_dir}")
  455. return
  456. print("\n🔧 批量处理模式")
  457. merge_mineru_paddle_batch(
  458. args.mineru_dir,
  459. args.paddle_dir,
  460. args.output_dir,
  461. look_ahead_window=args.window,
  462. similarity_threshold=args.threshold
  463. )
  464. else:
  465. parser.print_help()
  466. print("\n❌ 错误: 请指定单文件模式或批量模式的参数")
  467. print(" 单文件模式: --mineru-file 和 --paddle-file")
  468. print(" 批量模式: --mineru-dir 和 --paddle-dir")
  469. if __name__ == "__main__":
  470. print("🚀 启动 MinerU + PaddleOCR 合并程序...")
  471. import sys
  472. if len(sys.argv) == 1:
  473. # 如果没有命令行参数,使用默认配置运行
  474. print("ℹ️ 未提供命令行参数,使用默认配置运行...")
  475. # 默认配置
  476. default_config = {
  477. "mineru-dir": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/mineru-vlm-2.5.3_Results",
  478. "paddle-dir": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/data_PPStructureV3_Results",
  479. "output-dir": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/merged_results",
  480. "window": "15",
  481. "threshold": "85"
  482. }
  483. print(f"📂 MinerU 目录: {default_config['mineru-dir']}")
  484. print(f"📂 PaddleOCR 目录: {default_config['paddle-dir']}")
  485. print(f"📂 输出目录: {default_config['output-dir']}")
  486. print(f"⚙️ 查找窗口: {default_config['window']}")
  487. print(f"⚙️ 相似度阈值: {default_config['threshold']}%\n")
  488. # 构造参数
  489. sys.argv = [sys.argv[0]]
  490. for key, value in default_config.items():
  491. sys.argv.extend([f"--{key}", str(value)])
  492. sys.exit(main())