Răsfoiți Sursa

feat: Implement universal document parser with enhanced processing capabilities

- Introduced a new universal document parser module supporting various models (MinerU, PaddleX, DotsOCR) for document parsing.
- Added core components including pipeline managers, configuration management, and model factories for flexible processing.
- Implemented streaming processing mode to optimize memory usage during document handling.
- Included comprehensive documentation for usage, configuration, and output formats.
- Added multiple configuration files for different document processing scenarios, enhancing adaptability.
zhch158_admin 2 luni în urmă
părinte
comite
565ef479fa
34 a modificat fișierele cu 9091 adăugiri și 0 ștergeri
  1. 278 0
      ocr_tools/universal_doc_parser/Layout后处理-文本转表格.md
  2. 260 0
      ocr_tools/universal_doc_parser/OCR识别差异分析与改进方案.md
  3. 4 0
      ocr_tools/universal_doc_parser/VLM服务地址.md
  4. 30 0
      ocr_tools/universal_doc_parser/__init__.py
  5. 148 0
      ocr_tools/universal_doc_parser/config/bank_statement_mineru_v2.yaml
  6. 85 0
      ocr_tools/universal_doc_parser/config/bank_statement_mineru_vl.yaml
  7. 86 0
      ocr_tools/universal_doc_parser/config/bank_statement_paddle_vl.yaml
  8. 55 0
      ocr_tools/universal_doc_parser/config/bank_statement_wired_unet.yaml
  9. 150 0
      ocr_tools/universal_doc_parser/config/bank_statement_yusys_v2.yaml
  10. 149 0
      ocr_tools/universal_doc_parser/core/config_manager.py
  11. 765 0
      ocr_tools/universal_doc_parser/core/coordinate_utils.py
  12. 750 0
      ocr_tools/universal_doc_parser/core/element_processors.py
  13. 518 0
      ocr_tools/universal_doc_parser/core/layout_utils.py
  14. 98 0
      ocr_tools/universal_doc_parser/core/model_factory.py
  15. 22 0
      ocr_tools/universal_doc_parser/core/pdf_utils.py
  16. 686 0
      ocr_tools/universal_doc_parser/core/pipeline_manager_v2.py
  17. 315 0
      ocr_tools/universal_doc_parser/core/pipeline_manager_v2_streaming.py
  18. 454 0
      ocr_tools/universal_doc_parser/main_v2.py
  19. 116 0
      ocr_tools/universal_doc_parser/models/adapters/__init__.py
  20. 92 0
      ocr_tools/universal_doc_parser/models/adapters/base.py
  21. 667 0
      ocr_tools/universal_doc_parser/models/adapters/docling_layout_adapter.py
  22. 510 0
      ocr_tools/universal_doc_parser/models/adapters/mineru_adapter.py
  23. 274 0
      ocr_tools/universal_doc_parser/models/adapters/mineru_wired_table.py
  24. 693 0
      ocr_tools/universal_doc_parser/models/adapters/paddle_layout_detector.py
  25. 107 0
      ocr_tools/universal_doc_parser/models/adapters/paddle_vl_adapter.py
  26. BIN
      ocr_tools/universal_doc_parser/tests/2023年度报告母公司_page_003_270.png
  27. 153 0
      ocr_tools/universal_doc_parser/tests/test_doclayoutyolo.py
  28. 128 0
      ocr_tools/universal_doc_parser/tests/test_layout_detector.py
  29. 184 0
      ocr_tools/universal_doc_parser/tests/test_table_routing.py
  30. 288 0
      ocr_tools/universal_doc_parser/unclip_ratio参数说明.md
  31. 184 0
      ocr_tools/universal_doc_parser/utils/README_OUTPUT_FORMAT.md
  32. 38 0
      ocr_tools/universal_doc_parser/utils/__init__.py
  33. 521 0
      ocr_tools/universal_doc_parser/模型统一框架.md
  34. 283 0
      ocr_tools/universal_doc_parser/流式处理模式说明.md

+ 278 - 0
ocr_tools/universal_doc_parser/Layout后处理-文本转表格.md

@@ -0,0 +1,278 @@
+# Layout后处理:大面积文本块转表格
+
+## 📋 功能说明
+
+当Layout检测将大面积的表格区域误识别为文本框时,可以通过后处理自动将其转换为表格类型。
+
+## 🎯 使用场景
+
+### 典型问题
+
+从 `page_022.json` 可以看到:
+- 一个很大的文本框(bbox: [226, 288, 1495, 1685])
+- 包含了多个表格的内容(账龄分析、主要单位往来、存货等)
+- 但被识别为 `type: "text"`,而不是 `type: "table"`
+
+### 解决方案
+
+通过后处理规则,自动将满足条件的大文本块转换为表格:
+- ✅ 面积占比超过阈值(默认25%)
+- ✅ 宽度和高度都超过一定比例(避免细长条)
+- ✅ 页面中没有其他表格(避免误判)
+
+## ⚙️ 配置选项
+
+### 配置文件示例
+
+```yaml
+# layout后处理配置
+layout:
+  # 将大面积文本块转换为表格(后处理)
+  convert_large_text_to_table: true  # 是否启用
+  min_text_area_ratio: 0.25           # 最小面积占比(25%)
+  min_text_width_ratio: 0.4          # 最小宽度占比(40%)
+  min_text_height_ratio: 0.3         # 最小高度占比(30%)
+```
+
+### 参数说明
+
+| 参数 | 类型 | 默认值 | 说明 |
+|-----|------|--------|------|
+| `convert_large_text_to_table` | bool | `true` | 是否启用文本转表格功能 |
+| `min_text_area_ratio` | float | `0.25` | 最小面积占比(0-1),文本块面积需占页面25%以上 |
+| `min_text_width_ratio` | float | `0.4` | 最小宽度占比(0-1),文本块宽度需占页面40%以上 |
+| `min_text_height_ratio` | float | `0.3` | 最小高度占比(0-1),文本块高度需占页面30%以上 |
+
+## 🔍 判断规则
+
+### 1. 面积占比判断
+
+```python
+area_ratio = text_block_area / page_area
+if area_ratio >= min_text_area_ratio:  # 默认 >= 0.25
+    # 满足面积条件
+```
+
+**示例**:
+- 页面尺寸:2338 × 1654 = 3,867,052 像素²
+- 文本块:1207 × 1397 = 1,686,179 像素²
+- 面积占比:1,686,179 / 3,867,052 = **43.6%** ✅ 满足条件
+
+### 2. 尺寸比例判断
+
+```python
+width_ratio = text_block_width / page_width
+height_ratio = text_block_height / page_height
+
+if (width_ratio >= min_text_width_ratio and 
+    height_ratio >= min_text_height_ratio):
+    # 满足尺寸条件
+```
+
+**示例**:
+- 页面宽度:1654px,文本块宽度:1269px → 宽度占比:**76.7%** ✅
+- 页面高度:2338px,文本块高度:1397px → 高度占比:**59.8%** ✅
+
+### 3. 表格冲突检查
+
+```python
+has_table = any(
+    item.get('category', '').lower() in ['table', 'table_body']
+    for item in layout_results
+)
+
+if has_table:
+    # 页面已有表格,不进行转换(避免误判)
+    return layout_results
+```
+
+**逻辑**:
+- 如果页面中已经有表格元素,说明Layout检测正常工作
+- 此时不进行转换,避免将普通文本误判为表格
+
+## 📊 转换效果
+
+### 转换前
+
+```json
+{
+  "bbox": [226, 288, 1495, 1685],
+  "category": "text",
+  "text": "1、账龄分析\n账龄 期末余额\n1年以内 178,051,695.35\n..."
+}
+```
+
+### 转换后
+
+```json
+{
+  "bbox": [226, 288, 1495, 1685],
+  "category": "table",
+  "original_category": "text",  // 保留原始类别
+  "text": "1、账龄分析\n账龄 期末余额\n1年以内 178,051,695.35\n..."
+}
+```
+
+## 🎯 调优建议
+
+### 场景1:财务报表(多表格)
+
+```yaml
+layout:
+  convert_large_text_to_table: true
+  min_text_area_ratio: 0.25      # 25%面积
+  min_text_width_ratio: 0.4      # 40%宽度
+  min_text_height_ratio: 0.3     # 30%高度
+```
+
+### 场景2:密集表格(小表格多)
+
+```yaml
+layout:
+  convert_large_text_to_table: true
+  min_text_area_ratio: 0.15      # 降低到15%(更敏感)
+  min_text_width_ratio: 0.3      # 降低到30%
+  min_text_height_ratio: 0.2     # 降低到20%
+```
+
+### 场景3:宽松检测(避免误判)
+
+```yaml
+layout:
+  convert_large_text_to_table: true
+  min_text_area_ratio: 0.35      # 提高到35%(更严格)
+  min_text_width_ratio: 0.5      # 提高到50%
+  min_text_height_ratio: 0.4     # 提高到40%
+```
+
+### 场景4:关闭功能
+
+```yaml
+layout:
+  convert_large_text_to_table: false  # 完全关闭
+```
+
+## ⚠️ 注意事项
+
+### 1. **误判风险**
+
+- **风险**:可能将大段普通文本误判为表格
+- **缓解**:通过面积和尺寸比例阈值控制
+- **建议**:根据实际文档类型调整阈值
+
+### 2. **已有表格检查**
+
+- **逻辑**:如果页面已有表格,不进行转换
+- **原因**:说明Layout检测正常工作,不需要后处理
+- **例外**:如果Layout检测完全失败,可能无法检测到表格
+
+### 3. **转换时机**
+
+- **位置**:在Layout检测之后,元素分类之前
+- **顺序**:
+  1. Layout检测
+  2. 重叠框去重
+  3. **文本转表格(后处理)** ← 这里
+  4. 元素分类
+  5. 元素处理
+
+### 4. **保留原始信息**
+
+- 转换后的元素会保留 `original_category` 字段
+- 便于调试和追溯转换历史
+
+## 🔧 代码实现
+
+### 核心函数
+
+```python
+@staticmethod
+def convert_large_text_to_table(
+    layout_results: List[Dict[str, Any]],
+    image_shape: Tuple[int, int],
+    min_area_ratio: float = 0.25,
+    min_width_ratio: float = 0.4,
+    min_height_ratio: float = 0.3
+) -> List[Dict[str, Any]]:
+    """
+    将大面积的文本块转换为表格
+    
+    判断规则:
+    1. 面积占比:占页面面积超过 min_area_ratio(默认25%)
+    2. 尺寸比例:宽度和高度都超过一定比例(避免细长条)
+    3. 不与其他表格重叠:如果已有表格,不转换
+    """
+    # 实现逻辑...
+```
+
+### 调用位置
+
+```python
+# 在 pipeline_manager_v2.py 中
+# 2.6 将大面积文本块转换为表格(后处理)
+if layout_results:
+    convert_large_text = self.config.get('layout', {}).get('convert_large_text_to_table', True)
+    if convert_large_text:
+        image_shape = (detection_image.shape[0], detection_image.shape[1])
+        layout_results = LayoutUtils.convert_large_text_to_table(
+            layout_results,
+            image_shape,
+            min_area_ratio=self.config.get('layout', {}).get('min_text_area_ratio', 0.25),
+            min_width_ratio=self.config.get('layout', {}).get('min_text_width_ratio', 0.4),
+            min_height_ratio=self.config.get('layout', {}).get('min_text_height_ratio', 0.3)
+        )
+```
+
+## 📈 效果评估
+
+### 转换前的问题
+
+- ❌ 大表格被识别为文本
+- ❌ 无法使用VLM进行表格结构识别
+- ❌ 表格内容无法正确提取
+
+### 转换后的改进
+
+- ✅ 大表格被正确识别为表格类型
+- ✅ 可以使用VLM进行表格结构识别
+- ✅ 表格内容可以正确提取和处理
+
+## 🎯 最佳实践
+
+1. **默认启用**:对于财务报表等包含大表格的文档,建议启用
+2. **阈值调优**:根据实际文档类型调整阈值
+3. **监控日志**:查看转换日志,确认转换效果
+4. **验证结果**:检查转换后的表格是否正确处理
+
+## 📝 日志输出
+
+转换时会输出详细日志:
+
+```
+🔄 Converted large text block to table: area=43.6%, size=76.7%×59.8%, bbox=[226, 288, 1495, 1685]
+✅ Converted 1 large text block(s) to table(s)
+```
+
+## 🔄 与其他功能的关系
+
+### 与VLM表格识别
+
+- 转换后的表格会进入VLM处理流程
+- VLM会识别表格结构(HTML格式)
+- OCR会提取单元格文本和坐标
+
+### 与跨页表格合并
+
+- 转换后的表格可以参与跨页合并
+- 需要确保转换后的表格类型正确
+
+### 与元素分类
+
+- 转换在元素分类之前进行
+- 转换后的表格会被正确分类到 `TABLE_BODY_CATEGORIES`
+
+## 📚 相关文档
+
+- [模型统一框架.md](./模型统一框架.md) - 整体架构说明
+- [OCR识别差异分析与改进方案.md](./OCR识别差异分析与改进方案.md) - OCR优化说明
+

+ 260 - 0
ocr_tools/universal_doc_parser/OCR识别差异分析与改进方案.md

@@ -0,0 +1,260 @@
+# OCR识别差异分析与改进方案
+
+## 📊 问题描述
+
+对比两个OCR识别结果:
+- **PPStructureV3**:准确率高,文本识别完整
+- **统一OCR框架(MinerU封装)**:很多文本未识别,部分文本块检测错误
+
+### 典型错误示例
+
+从 `bank_statement_yusys_v2/2023年度报告母公司_page_003.json` 中发现的错误:
+
+| 正确文本 | 识别结果 | 匹配分数 | 问题 |
+|---------|---------|---------|------|
+| 合同资产 | 货资产 | 80.0% | 检测框不完整 |
+| 其他应付款 | 税款 | 66.67% | 检测框错误 |
+| 长期待摊费用 | 效用 | 66.67% | 检测框错误 |
+| 其他非流动资产 | 非其非产动产 | 61.54% | 检测框合并错误 |
+| 资本公积 | 资公存股 | 66.67% | 检测框合并错误 |
+
+## 🔍 根本原因分析
+
+### 1. **检测参数差异**
+
+| 参数 | PPStructureV3 | MinerU默认 | 影响 |
+|-----|--------------|-----------|------|
+| `box_thresh` | **0.6** | **0.3** | 阈值过低导致检测到大量噪声框 |
+| `unclip_ratio` | **1.5** | **1.8** | 扩展比例过高,框不够精确 |
+| `thresh` | 0.3 | 0.3 | 相同 |
+
+**问题**:
+- `box_thresh=0.3` 太低,会检测到很多低置信度的噪声框
+- 这些噪声框可能被错误地合并到正确的文本框中
+- 导致最终识别结果不准确
+
+### 2. **检测框合并策略**
+
+| 特性 | PPStructureV3 | MinerU | 影响 |
+|-----|--------------|--------|------|
+| 框合并 | 无(或更保守) | `enable_merge_det_boxes=True` | 可能错误合并相邻文本块 |
+
+**问题**:
+- `enable_merge_det_boxes=True` 会合并相邻的检测框
+- 对于表格等密集文本,可能将不同单元格的文本错误合并
+- 例如:"其他非流动资产" 被合并成 "非其非产动产"
+
+### 3. **识别置信度过滤**
+
+| 参数 | PPStructureV3 | MinerU | 影响 |
+|-----|--------------|--------|------|
+| `score_thresh` | **0.0** | **0.5** | 丢弃低置信度结果 |
+
+**问题**:
+- `drop_score=0.5` 会丢弃置信度 < 0.5 的识别结果
+- 某些正确但置信度较低的文本可能被丢弃
+- PPStructureV3 使用 `score_thresh=0.0`,保留所有结果
+
+### 4. **文本行方向识别**
+
+| 特性 | PPStructureV3 | MinerU | 影响 |
+|-----|--------------|--------|------|
+| 文本行方向识别 | ✅ `use_textline_orientation: True` | ❌ 无 | 倾斜文本识别错误 |
+
+**问题**:
+- PPStructureV3 有专门的文本行方向识别模块
+- 可以处理倾斜的文本行,提高识别准确率
+- MinerU 缺少此功能
+
+### 5. **模型版本差异**
+
+| 组件 | PPStructureV3 | MinerU | 影响 |
+|-----|--------------|--------|------|
+| 检测模型 | PP-OCRv5_server_det | ch/ch_lite | 可能使用较旧版本 |
+| 识别模型 | PP-OCRv5_server_rec | ch/ch_lite | 可能使用较旧版本 |
+
+**问题**:
+- PP-OCRv5_server 是较新的模型,准确率更高
+- MinerU 可能使用较旧版本的模型
+
+## 💡 改进方案
+
+### 方案1:调整OCR参数(推荐,快速改进)
+
+修改 `MinerUOCRRecognizer` 的初始化参数,使其更接近 PPStructureV3:
+
+```python
+# 在 mineru_adapter.py 中修改
+self.ocr_model = self.atom_model_manager.get_atom_model(
+    atom_model_name=AtomicModel.OCR,
+    det_db_box_thresh=0.6,  # 从 0.3 提高到 0.6
+    lang=self.config.get('language', 'ch'),
+    det_db_unclip_ratio=1.5,  # 从 1.8 降低到 1.5
+    enable_merge_det_boxes=False,  # 从 True 改为 False(表格场景)
+)
+```
+
+**优点**:
+- ✅ 快速实施,无需修改核心代码
+- ✅ 可以显著提高检测准确率
+- ✅ 减少错误合并
+
+**缺点**:
+- ⚠️ 可能漏检一些低置信度的文本(但通常这些是噪声)
+- ⚠️ 对于非表格场景,可能需要 `enable_merge_det_boxes=True`
+
+### 方案2:降低识别置信度阈值
+
+修改 `drop_score` 参数,保留更多识别结果:
+
+```python
+# 需要修改 PytorchPaddleOCR 的初始化
+kwargs['drop_score'] = 0.3  # 从默认 0.5 降低到 0.3
+```
+
+**优点**:
+- ✅ 保留更多识别结果
+- ✅ 减少误丢弃
+
+**缺点**:
+- ⚠️ 可能引入更多噪声结果
+- ⚠️ 需要修改 MinerU 核心代码
+
+### 方案3:根据场景动态调整参数(最佳方案)
+
+根据文档类型(表格/文本)和PDF类型(扫描件/数字PDF)动态调整参数:
+
+```python
+def get_ocr_config(pdf_type: str, has_tables: bool) -> Dict[str, Any]:
+    """根据场景返回OCR配置"""
+    if has_tables:
+        # 表格场景:更严格的检测,不合并框
+        return {
+            'det_db_box_thresh': 0.6,
+            'det_db_unclip_ratio': 1.5,
+            'enable_merge_det_boxes': False,
+            'drop_score': 0.3,
+        }
+    elif pdf_type == 'txt':
+        # 数字PDF:可以合并框,提高检测阈值
+        return {
+            'det_db_box_thresh': 0.5,
+            'det_db_unclip_ratio': 1.6,
+            'enable_merge_det_boxes': True,
+            'drop_score': 0.3,
+        }
+    else:
+        # 扫描件:平衡检测和合并
+        return {
+            'det_db_box_thresh': 0.4,
+            'det_db_unclip_ratio': 1.6,
+            'enable_merge_det_boxes': True,
+            'drop_score': 0.3,
+        }
+```
+
+**优点**:
+- ✅ 针对不同场景优化
+- ✅ 兼顾准确率和召回率
+
+**缺点**:
+- ⚠️ 实现复杂度较高
+- ⚠️ 需要场景判断逻辑
+
+### 方案4:集成文本行方向识别(长期改进)
+
+参考 PPStructureV3,添加文本行方向识别模块:
+
+1. 在 OCR 识别前,先进行文本行方向识别
+2. 根据识别结果旋转文本行
+3. 再进行 OCR 识别
+
+**优点**:
+- ✅ 显著提高倾斜文本识别准确率
+- ✅ 与 PPStructureV3 能力对齐
+
+**缺点**:
+- ⚠️ 需要额外的模型和计算资源
+- ⚠️ 实现复杂度高
+
+## 🎯 推荐实施步骤
+
+### 阶段1:快速改进(立即实施)
+
+1. **调整检测参数**:
+   - `det_db_box_thresh`: 0.3 → **0.6**
+   - `det_db_unclip_ratio`: 1.8 → **1.5**
+   - `enable_merge_det_boxes`: True → **False**(表格场景)
+
+2. **降低识别阈值**:
+   - `drop_score`: 0.5 → **0.3**
+
+### 阶段2:场景优化(短期)
+
+3. **实现动态参数调整**:
+   - 根据文档类型和PDF类型选择参数
+   - 表格场景:严格检测,不合并
+   - 文本场景:平衡检测和合并
+
+### 阶段3:能力增强(长期)
+
+4. **集成文本行方向识别**:
+   - 添加文本行方向识别模块
+   - 提高倾斜文本识别准确率
+
+## 📝 配置建议
+
+### 表格场景(当前问题场景)
+
+```yaml
+ocr:
+  det_db_box_thresh: 0.6      # 提高检测阈值
+  det_db_unclip_ratio: 1.5    # 降低扩展比例
+  enable_merge_det_boxes: false  # 不合并框
+  drop_score: 0.3             # 降低识别阈值
+```
+
+### 文本场景
+
+```yaml
+ocr:
+  det_db_box_thresh: 0.4      # 中等检测阈值
+  det_db_unclip_ratio: 1.6    # 中等扩展比例
+  enable_merge_det_boxes: true   # 允许合并
+  drop_score: 0.3             # 降低识别阈值
+```
+
+## 🔧 代码修改位置
+
+1. **`models/adapters/mineru_adapter.py`**:
+   - `MinerUOCRRecognizer.__init__()` - 修改初始化参数
+   - 添加场景判断逻辑
+
+2. **`core/pipeline_manager_v2.py`**:
+   - `_process_single_page()` - 检测是否有表格
+   - 传递场景信息给 OCR 识别器
+
+3. **配置文件**:
+   - 添加 OCR 参数配置选项
+
+## 📈 预期效果
+
+实施阶段1改进后:
+- ✅ 检测准确率提升:减少噪声框和错误合并
+- ✅ 识别完整度提升:保留更多低置信度但正确的结果
+- ✅ 表格识别准确率:预计从 60-70% 提升到 85-90%
+
+## ⚠️ 注意事项
+
+1. **参数调优**:
+   - 不同文档可能需要不同的参数
+   - 建议通过测试集验证最优参数
+
+2. **性能影响**:
+   - 提高 `box_thresh` 可能略微降低召回率
+   - 需要平衡准确率和召回率
+
+3. **向后兼容**:
+   - 保持默认参数不变,通过配置覆盖
+   - 确保现有功能不受影响
+

+ 4 - 0
ocr_tools/universal_doc_parser/VLM服务地址.md

@@ -0,0 +1,4 @@
+mineru_vllm 10.192.72.11:20006
+paddleocr_vllm 10.192.72.11:20016
+ppstructure_v3 10.192.72.11:20026
+dots_vllm 10.192.72.11:8101

+ 30 - 0
ocr_tools/universal_doc_parser/__init__.py

@@ -0,0 +1,30 @@
+"""
+Universal Document Parser
+
+统一文档处理流水线,支持多种模型(MinerU、PaddleX、DotsOCR等)进行文档解析。
+提供完整的处理流程:PDF分类、页面方向识别、Layout检测、OCR识别、表格VLM识别等。
+"""
+
+from .core.pipeline_manager_v2 import EnhancedDocPipeline
+from .core.pipeline_manager_v2_streaming import StreamingDocPipeline
+from .core.config_manager import ConfigManager
+from .core.model_factory import ModelFactory
+
+# 从 ocr_utils 导入工具函数
+try:
+    from ocr_utils import OutputFormatterV2, save_mineru_format
+except ImportError:
+    # 降级:从 utils 导入(向后兼容)
+    from .utils import OutputFormatterV2, save_mineru_format
+
+__all__ = [
+    'EnhancedDocPipeline',
+    'StreamingDocPipeline',
+    'ConfigManager',
+    'ModelFactory',
+    'OutputFormatterV2',
+    'save_mineru_format',
+]
+
+__version__ = "2.0.0"
+

+ 148 - 0
ocr_tools/universal_doc_parser/config/bank_statement_mineru_v2.yaml

@@ -0,0 +1,148 @@
+# 银行交易流水场景配置 v2
+# 支持完整的处理流程:PDF分类 → 方向识别 → Layout检测 → OCR/VLM并行处理 → 坐标匹配
+
+scene_name: "bank_statement"
+description: "银行交易流水、对账单等场景 - 增强版"
+
+# ============================================================
+# 输入配置
+# ============================================================
+input:
+  supported_formats: [".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
+  dpi: 200  # PDF转图片的DPI
+
+# ============================================================
+# 预处理配置(方向识别)
+# ============================================================
+preprocessor:
+  module: "mineru"
+  orientation_classifier:
+    enabled: true  # 扫描件自动开启,数字PDF自动跳过
+    model_name: "paddle_orientation_classification"
+    model_dir: null  # 使用默认路径
+  unwarping:
+    enabled: false  # 图像矫正(可选)
+
+# ============================================================
+# 版式检测配置
+# ============================================================
+layout_detection:
+  module: "mineru"
+  model_name: "layout"
+  model_dir: null  # 使用默认路径,自动下载 doclayout_yolo_docstructbench_imgsz1280_2501.pt
+  device: "cpu"  # 可选: "cpu", "cuda", "mps"
+  # batch_size: 4
+  # conf: 0.25
+  # iou: 0.45
+
+# ============================================================
+# Layout后处理配置
+# ============================================================
+layout:
+  # 将大面积文本块转换为表格(后处理)
+  convert_large_text_to_table: true  # 是否启用
+  min_text_area_ratio: 0.25         # 最小面积占比(25%)
+  min_text_width_ratio: 0.4         # 最小宽度占比(40%)
+  min_text_height_ratio: 0.3        # 最小高度占比(30%)
+
+# ============================================================
+# VL识别配置(表格、公式)
+# ============================================================
+vl_recognition:
+  # 可选: "mineru" (MinerU VLM) 或 "paddle" (PaddleOCR-VL)
+  module: "mineru"
+  
+  # 后端配置
+  backend: "http-client"  # 可选: "http-client", "vllm-engine", "transformers"
+  server_url: "http://10.192.72.11:20006"  # MinerU VLM 服务地址
+  
+  # 图片尺寸限制(避免序列长度超限)
+  max_image_size: 4096
+  resize_mode: 'max'  # 'max' 保持宽高比, 'fixed' 固定尺寸
+  
+  device: "cpu"
+  batch_size: 1
+  
+  model_params:
+    max_concurrency: 10
+    http_timeout: 600
+  
+  # 表格识别特定配置
+  table_recognition:
+    return_cells_coordinate: true  # 返回单元格坐标
+    bank_statement_mode: true      # 银行流水优化模式
+
+# ============================================================
+# OCR识别配置(文本检测+识别)
+# ============================================================
+ocr_recognition:
+  module: "mineru"
+  language: "ch"  # 语言: ch, ch_lite, en, japan 等
+  det_threshold: 0.6  # 检测阈值
+  unclip_ratio: 1.5   # 文本框扩展比例
+  enable_merge_det_boxes: false  # 不合并框
+  batch_size: 8
+  device: "cpu"
+
+# ============================================================
+# 输出配置
+# ============================================================
+output:
+  # 基础输出
+  save_json: true           # 保存 middle.json(MinerU标准格式)
+  save_markdown: true       # 保存 Markdown 文件
+  save_html: true           # 保存表格 HTML 文件
+  
+  # Debug 输出(通过命令行 --debug 开启)
+  save_layout_image: false  # 保存 layout 可视化图片
+  save_ocr_image: false     # 保存 OCR 可视化图片
+  draw_type_label: true     # 在可视化图片上标注类型
+  draw_bbox_number: true    # 在可视化图片上标注序号
+  
+  # 增强输出
+  save_enhanced_json: true  # 保存增强版 JSON(包含单元格坐标)
+  coordinate_precision: 2   # 坐标精度(小数位数)
+
+# ============================================================
+# 场景特定配置
+# ============================================================
+scene_config:
+  bank_statement:
+    # 表格结构特征
+    table_structure: "single_column_list"  # 单栏列表形式
+    merged_cells: false                     # 无合并单元格
+    
+    # 预期列名(用于验证)
+    expected_columns: ["日期", "摘要", "收入", "支出", "余额"]
+    
+    # 验证规则
+    amount_validation: true   # 金额格式验证
+    date_validation: true     # 日期格式验证
+    balance_validation: true  # 余额一致性验证
+    
+  processing_rules:
+    # 表格处理规则
+    table_rules:
+      - detect_table_type: ["wired", "wireless"]  # 检测有线/无线表格
+      - extract_header_automatically: true         # 自动提取表头
+      - validate_amount_format: true               # 验证金额格式
+      - merge_continuation_rows: true              # 合并续行
+      
+    # OCR后处理规则
+    ocr_rules:
+      - filter_low_confidence: 0.7      # 过滤低置信度结果
+      - merge_adjacent_text: true       # 合并相邻文本
+      - number_format_normalization: true  # 数字格式标准化
+
+# ============================================================
+# 跨页表格合并配置
+# ============================================================
+cross_page_merge:
+  enabled: true
+  # 判断表格是否跨页的条件
+  conditions:
+    - table_at_page_bottom: true    # 表格位于页面底部
+    - table_at_page_top: true       # 下一页表格位于顶部
+    - similar_column_count: true    # 列数相似
+    - header_match: false           # 表头匹配(跨页表格通常没有重复表头)
+

+ 85 - 0
ocr_tools/universal_doc_parser/config/bank_statement_mineru_vl.yaml

@@ -0,0 +1,85 @@
+# 银行交易流水场景配置(增强版)
+scene_name: "bank_statement"
+description: "银行交易流水、对账单等场景"
+
+input:
+  supported_formats: [".pdf", ".png", ".jpg"]
+  dpi: 200
+
+preprocessor:
+  module: "mineru"
+  orientation_classifier:
+    enabled: true
+    model_name: "paddle_orientation_classification"
+    model_dir: null  # 使用默认路径
+  unwarping:
+    enabled: false
+
+layout_detection:
+  # module: "paddle"
+  # model_name: "RT-DETR-H_layout_17cls"
+  # model_dir: /Users/zhch158/workspace/repository.git/PaddleX/zhch/unified_pytorch_models/Layout/RT-DETR-H_layout_17cls.onnx  # 使用默认路径,或指定: "./Layout/RT-DETR-H_layout_17cls.onnx"
+  module: "mineru"
+  model_name: "layout"
+  model_dir: null  # 使用默认路径
+  device: "cpu"
+  # batch_size: 4
+  # conf: 0.1
+  # iou: 0.45
+
+vl_recognition:
+  module: "mineru"
+  backend: "http-client"
+  server_url: "http://10.192.72.11:8121"
+  max_image_size: 4096  # 🔧 添加:最大图片尺寸
+  resize_mode: 'max'    # 🔧 添加:缩放模式 ('max' 保持宽高比, 'fixed' 固定尺寸)
+  device: "cpu"
+  batch_size: 1
+  model_params:
+    max_concurrency: 10
+    http_timeout: 600
+  
+  # 场景特定配置
+  table_recognition:
+    return_cells_coordinate: true
+    bank_statement_mode: true
+    
+ocr_recognition:
+  module: "mineru" 
+  language: "ch"
+  det_threshold: 0.3
+  unclip_ratio: 1.8
+  batch_size: 8
+  device: "cpu"
+
+output:
+  save_json: true
+  save_markdown: true
+  save_html: true
+  save_layout_image: true
+  save_ocr_image: true
+  draw_type_label: true
+  draw_bbox_number: true
+  
+# 场景特定配置
+scene_config:
+  bank_statement:
+    table_structure: "single_column_list"
+    merged_cells: false
+    expected_columns: ["日期", "摘要", "收入", "支出", "余额"]
+    amount_validation: true
+    date_validation: true
+    
+  processing_rules:
+    # 表格处理规则
+    table_rules:
+      - detect_table_type: ["wired", "wireless"]  
+      - extract_header_automatically: true
+      - validate_amount_format: true
+      - merge_continuation_rows: true
+      
+    # OCR后处理规则  
+    ocr_rules:
+      - filter_low_confidence: 0.7
+      - merge_adjacent_text: true
+      - number_format_normalization: true

+ 86 - 0
ocr_tools/universal_doc_parser/config/bank_statement_paddle_vl.yaml

@@ -0,0 +1,86 @@
+# 银行交易流水场景配置(增强版)
+scene_name: "bank_statement"
+description: "银行交易流水、对账单等场景"
+
+input:
+  supported_formats: [".pdf", ".png", ".jpg"]
+  dpi: 200
+
+preprocessor:
+  module: "mineru"
+  orientation_classifier:
+    enabled: true
+    model_name: "paddle_orientation_classification"
+    model_dir: null  # 使用默认路径
+  unwarping:
+    enabled: false
+
+layout_detection:
+  # module: "paddle"
+  # model_name: "RT-DETR-H_layout_17cls"
+  # model_dir: /Users/zhch158/workspace/repository.git/PaddleX/zhch/unified_pytorch_models/Layout/RT-DETR-H_layout_17cls.onnx  # 使用默认路径,或指定: "./Layout/RT-DETR-H_layout_17cls.onnx"
+  module: "mineru"
+  model_name: "layout"
+  model_dir: null  # 使用默认路径
+  device: "cpu"
+  # batch_size: 4
+  # conf: 0.1
+  # iou: 0.45
+
+vl_recognition:
+  module: "paddle"
+  backend: "http-client"
+  model_name: "PaddleOCR-VL-0.9B"
+  server_url: "http://10.192.72.11:8110"
+  max_image_size: 4096  # 🔧 添加:最大图片尺寸
+  resize_mode: 'max'    # 🔧 添加:缩放模式 ('max' 保持宽高比, 'fixed' 固定尺寸)
+  device: "cpu"
+  batch_size: 1
+  model_params:
+    max_concurrency: 10
+    http_timeout: 600
+  
+  # 场景特定配置
+  table_recognition:
+    return_cells_coordinate: true
+    bank_statement_mode: true
+    
+ocr_recognition:
+  module: "mineru" 
+  language: "ch"
+  det_threshold: 0.3
+  unclip_ratio: 1.8
+  batch_size: 8
+  device: "cpu"
+
+output:
+  save_json: true
+  save_markdown: true
+  save_html: true
+  save_layout_image: true
+  save_ocr_image: true
+  draw_type_label: true
+  draw_bbox_number: true
+  
+# 场景特定配置
+scene_config:
+  bank_statement:
+    table_structure: "single_column_list"
+    merged_cells: false
+    expected_columns: ["日期", "摘要", "收入", "支出", "余额"]
+    amount_validation: true
+    date_validation: true
+    
+  processing_rules:
+    # 表格处理规则
+    table_rules:
+      - detect_table_type: ["wired", "wireless"]  
+      - extract_header_automatically: true
+      - validate_amount_format: true
+      - merge_continuation_rows: true
+      
+    # OCR后处理规则  
+    ocr_rules:
+      - filter_low_confidence: 0.7
+      - merge_adjacent_text: true
+      - number_format_normalization: true

+ 55 - 0
ocr_tools/universal_doc_parser/config/bank_statement_wired_unet.yaml

@@ -0,0 +1,55 @@
+# 银行交易流水(单栏、无合并、有线优先)
+scene_name: "bank_statement_wired_unet"
+
+description: "银行流水:docling layout + PaddleOCR + MinerU UNet(有线优先,细线表格)"
+
+input:
+  supported_formats: [".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
+  dpi: 200
+
+preprocessor:
+  module: "mineru"
+  orientation_classifier:
+    enabled: true
+
+layout_detection:
+  module: "docling"
+  model_name: "docling-layout-old"
+  model_dir: ds4sd/docling-layout-old
+  device: "cpu"
+  conf: 0.3
+  num_threads: 4
+
+ocr_recognition:
+  module: "mineru"
+  language: "ch"
+  det_threshold: 0.5
+  unclip_ratio: 1.8
+  enable_merge_det_boxes: false
+  batch_size: 8
+  device: "cpu"
+
+# 有线表格识别专用配置
+table_recognition_wired:
+  use_wired_unet: true
+  upscale_ratio: 3.333
+  need_ocr: true
+  row_threshold: 10
+  col_threshold: 15
+  ocr_conf_threshold: 0.5
+  cell_crop_margin: 2
+
+output:
+  create_subdir: false
+  save_json: true
+  save_page_json: true
+  save_markdown: true
+  save_page_markdown: true
+  save_html: true
+  save_layout_image: false
+  save_ocr_image: false
+  draw_type_label: true
+  draw_bbox_number: true
+  save_enhanced_json: true
+  coordinate_precision: 2
+  normalize_numbers: true

+ 150 - 0
ocr_tools/universal_doc_parser/config/bank_statement_yusys_v2.yaml

@@ -0,0 +1,150 @@
+# 银行交易流水场景配置 v2
+# 支持完整的处理流程:PDF分类 → 方向识别 → Layout检测 → OCR/VLM并行处理 → 坐标匹配
+
+scene_name: "bank_statement"
+description: "银行交易流水、对账单等场景 - 增强版"
+
+# ============================================================
+# 输入配置
+# ============================================================
+input:
+  supported_formats: [".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
+  dpi: 200  # PDF转图片的DPI
+
+# ============================================================
+# 预处理配置(方向识别)
+# ============================================================
+preprocessor:
+  module: "mineru"
+  orientation_classifier:
+    enabled: true  # 扫描件自动开启,数字PDF自动跳过
+    model_name: "paddle_orientation_classification"
+    model_dir: null  # 使用默认路径
+  unwarping:
+    enabled: false  # 图像矫正(可选)
+
+# ============================================================
+# 版式检测配置
+# ============================================================
+layout_detection:
+  module: "docling"
+  model_name: "docling-layout-old"
+  model_dir: ds4sd/docling-layout-old  # 使用默认路径,自动下载 doclayout_yolo_docstructbench_imgsz1280_2501.pt
+  device: "cpu"  # 可选: "cpu", "cuda", "mps"
+  conf: 0.3
+  num_threads: 4
+
+# ============================================================
+# Layout后处理配置
+# ============================================================
+layout:
+  # 将大面积文本块转换为表格(后处理)
+  convert_large_text_to_table: true  # 是否启用
+  min_text_area_ratio: 0.25         # 最小面积占比(25%)
+  min_text_width_ratio: 0.4         # 最小宽度占比(40%)
+  min_text_height_ratio: 0.3        # 最小高度占比(30%)
+
+# ============================================================
+# VL识别配置(表格、公式)
+# ============================================================
+vl_recognition:
+  # 可选: "mineru" (MinerU VLM) 或 "paddle" (PaddleOCR-VL)
+  module: "paddle"
+  
+  # 后端配置
+  backend: "http-client"  # 可选: "http-client", "vllm-engine", "transformers"
+  server_url: "http://10.192.72.11:20016"  # PaddleOCR-VL 服务地址
+  
+  # 图片尺寸限制(避免序列长度超限)
+  max_image_size: 4096
+  resize_mode: 'max'  # 'max' 保持宽高比, 'fixed' 固定尺寸
+  
+  device: "cpu"
+  batch_size: 1
+  
+  model_params:
+    max_concurrency: 10
+    http_timeout: 600
+  
+  # 表格识别特定配置
+  table_recognition:
+    return_cells_coordinate: true  # 返回单元格坐标
+    bank_statement_mode: true      # 银行流水优化模式
+
+# ============================================================
+# OCR识别配置(文本检测+识别)
+# ============================================================
+ocr_recognition:
+  module: "mineru"
+  language: "ch"  # 语言: ch, ch_lite, en, japan 等
+  det_threshold: 0.6  # 检测阈值
+  unclip_ratio: 1.5   # 文本框扩展比例
+  enable_merge_det_boxes: false  # 不合并框
+  batch_size: 8
+  device: "cpu"
+
+# ============================================================
+# 输出配置
+# ============================================================
+output:
+  # 基础输出
+  create_subdir: false       # 创建子目录
+  save_json: true           # 保存 middle.json(MinerU标准格式)
+  save_markdown: true       # 保存 Markdown 文件
+  save_html: true           # 保存表格 HTML 文件
+  
+  # Debug 输出(通过命令行 --debug 开启)
+  save_layout_image: false  # 保存 layout 可视化图片
+  save_ocr_image: false     # 保存 OCR 可视化图片
+  draw_type_label: true     # 在可视化图片上标注类型
+  draw_bbox_number: true    # 在可视化图片上标注序号
+  
+  # 增强输出
+  save_enhanced_json: true  # 保存增强版 JSON(包含单元格坐标)
+  coordinate_precision: 2   # 坐标精度(小数位数)
+
+  normalize_numbers: true  # 金额数字标准化(全角→半角)
+
+# ============================================================
+# 场景特定配置
+# ============================================================
+scene_config:
+  bank_statement:
+    # 表格结构特征
+    table_structure: "single_column_list"  # 单栏列表形式
+    merged_cells: false                     # 无合并单元格
+    
+    # 预期列名(用于验证)
+    expected_columns: ["日期", "摘要", "收入", "支出", "余额"]
+    
+    # 验证规则
+    amount_validation: true   # 金额格式验证
+    date_validation: true     # 日期格式验证
+    balance_validation: true  # 余额一致性验证
+    
+  processing_rules:
+    # 表格处理规则
+    table_rules:
+      - detect_table_type: ["wired", "wireless"]  # 检测有线/无线表格
+      - extract_header_automatically: true         # 自动提取表头
+      - validate_amount_format: true               # 验证金额格式
+      - merge_continuation_rows: true              # 合并续行
+      
+    # OCR后处理规则
+    ocr_rules:
+      - filter_low_confidence: 0.7      # 过滤低置信度结果
+      - merge_adjacent_text: true       # 合并相邻文本
+      - number_format_normalization: true  # 数字格式标准化
+
+# ============================================================
+# 跨页表格合并配置
+# ============================================================
+cross_page_merge:
+  enabled: true
+  # 判断表格是否跨页的条件
+  conditions:
+    - table_at_page_bottom: true    # 表格位于页面底部
+    - table_at_page_top: true       # 下一页表格位于顶部
+    - similar_column_count: true    # 列数相似
+    - header_match: false           # 表头匹配(跨页表格通常没有重复表头)
+

+ 149 - 0
ocr_tools/universal_doc_parser/core/config_manager.py

@@ -0,0 +1,149 @@
+"""配置管理器 - 加载和验证配置文件"""
+import yaml
+from pathlib import Path
+from typing import Dict, Any, Optional
+
+class ConfigManager:
+    """配置管理器"""
+    
+    _config_cache = {}
+    
+    @classmethod
+    def load_config(cls, config_path: str) -> Dict[str, Any]:
+        """加载配置文件"""
+        config_path = Path(config_path)
+        
+        # 缓存机制
+        cache_key = str(config_path.absolute())
+        if cache_key in cls._config_cache:
+            return cls._config_cache[cache_key]
+        
+        if not config_path.exists():
+            raise FileNotFoundError(f"Config file not found: {config_path}")
+        
+        with open(config_path, 'r', encoding='utf-8') as f:
+            config = yaml.safe_load(f)
+        
+        # 配置验证和默认值设置
+        config = cls._validate_and_set_defaults(config)
+        
+        # 缓存配置
+        cls._config_cache[cache_key] = config
+        
+        return config
+    
+    @classmethod
+    def _validate_and_set_defaults(cls, config: Dict[str, Any]) -> Dict[str, Any]:
+        """验证配置并设置默认值"""
+        # 设置默认场景名称
+        if 'scene_name' not in config:
+            config['scene_name'] = 'unknown'
+        
+        # 验证必需的配置项
+        required_sections = ['preprocessor', 'layout_detection', 'ocr_recognition']
+        for section in required_sections:
+            if section not in config:
+                config[section] = {'module': 'mineru'}
+        
+        # 设置预处理器默认配置
+        preprocessor_defaults = {
+            'module': 'mineru',
+            'orientation_classifier': {'enabled': True},
+            'unwarping': {'enabled': False}
+        }
+        config['preprocessor'] = cls._merge_defaults(
+            config.get('preprocessor', {}), preprocessor_defaults
+        )
+        
+        # 设置版式检测默认配置
+        layout_defaults = {
+            'module': 'mineru',
+            'model_name': 'layout',
+            'device': 'cpu',
+            'batch_size': 1,
+            'conf': 0.25,
+            'iou': 0.45
+        }
+        config['layout_detection'] = cls._merge_defaults(
+            config.get('layout_detection', {}), layout_defaults
+        )
+        
+        # 设置VL识别默认配置
+        # vl_defaults = {
+        #     'module': 'mineru',
+        #     'backend': 'http-client',
+        #     'server_url': 'http://localhost:8111/v1',
+        #     'device': 'cpu',
+        #     'batch_size': 1,
+        #     'model_params': {'max_concurrency': 10, 'http_timeout': 600}
+        # }
+        vl_defaults = {}
+        config['vl_recognition'] = cls._merge_defaults(
+            config.get('vl_recognition', {}), vl_defaults
+        )
+        
+        # 设置OCR默认配置
+        ocr_defaults = {
+            'module': 'mineru',
+            'language': 'ch',
+            'det_threshold': 0.3,
+            'unclip_ratio': 1.8,
+            'batch_size': 8,
+            'device': 'cpu'
+        }
+        config['ocr_recognition'] = cls._merge_defaults(
+            config.get('ocr_recognition', {}), ocr_defaults
+        )
+
+        # 设置有线表格识别默认配置(可选)
+        table_wired_defaults = {
+            'use_wired_unet': False,
+            'upscale_ratio': 10 / 3,
+            'need_ocr': True,
+            'row_threshold': 10,
+            'col_threshold': 15,
+            'ocr_conf_threshold': 0.6,
+        }
+        config['table_recognition_wired'] = cls._merge_defaults(
+            config.get('table_recognition_wired', {}), table_wired_defaults
+        )
+        
+        # 设置输出默认配置
+        output_defaults = {
+            'format': 'enhanced_json',
+            'save_json': True,
+            'save_markdown': True,
+            'save_html': True,
+            'save_images': {'layout': True, 'ocr': True, 'table_cells': True},
+            'coordinate_precision': 2
+        }
+        config['output'] = cls._merge_defaults(
+            config.get('output', {}), output_defaults
+        )
+        
+        return config
+    
+    @classmethod
+    def _merge_defaults(cls, user_config: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]:
+        """合并用户配置和默认配置"""
+        result = defaults.copy()
+        for key, value in user_config.items():
+            if isinstance(value, dict) and key in result and isinstance(result[key], dict):
+                result[key] = cls._merge_defaults(value, result[key])
+            else:
+                result[key] = value
+        return result
+    
+    @classmethod
+    def save_config(cls, config: Dict[str, Any], config_path: str):
+        """保存配置文件"""
+        config_path = Path(config_path)
+        config_path.parent.mkdir(parents=True, exist_ok=True)
+        
+        with open(config_path, 'w', encoding='utf-8') as f:
+            yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
+    
+    @classmethod
+    def clear_cache(cls):
+        """清空配置缓存"""
+        cls._config_cache.clear()

+ 765 - 0
ocr_tools/universal_doc_parser/core/coordinate_utils.py

@@ -0,0 +1,765 @@
+"""
+坐标转换工具模块
+
+提供各种坐标转换功能:
+- 底层坐标计算(IoU、重叠比例)
+- 多边形/bbox 格式转换
+- 相对坐标 → 绝对坐标转换
+- OCR 格式转换
+- 旋转坐标逆变换
+- HTML data-bbox 坐标转换
+"""
+import re
+import json
+from typing import Dict, List, Any, Optional, Tuple, Union
+import numpy as np
+from loguru import logger
+
+# 导入 merger 组件
+try:
+    from merger import BBoxExtractor
+    MERGER_AVAILABLE = True
+except ImportError:
+    MERGER_AVAILABLE = False
+    BBoxExtractor = None
+
+# 导入 MinerU 组件(用于 IoU 计算)
+try:
+    from mineru.utils.boxbase import calculate_iou as mineru_calculate_iou
+    from mineru.utils.boxbase import calculate_overlap_area_2_minbox_area_ratio
+    MINERU_BOXBASE_AVAILABLE = True
+except ImportError:
+    MINERU_BOXBASE_AVAILABLE = False
+    mineru_calculate_iou = None
+    calculate_overlap_area_2_minbox_area_ratio = None
+
+
+class CoordinateUtils:
+    """坐标转换工具类"""
+    
+    # ==================== 底层坐标计算方法 ====================
+    
+    @staticmethod
+    def calculate_iou(bbox1: List[float], bbox2: List[float]) -> float:
+        """
+        计算两个 bbox 的 IoU(交并比)
+        
+        Args:
+            bbox1: 第一个 bbox [x1, y1, x2, y2]
+            bbox2: 第二个 bbox [x1, y1, x2, y2]
+            
+        Returns:
+            IoU 值
+        """
+        if MINERU_BOXBASE_AVAILABLE and mineru_calculate_iou is not None:
+            return mineru_calculate_iou(bbox1, bbox2)
+        
+        # 备用实现
+        x_left = max(bbox1[0], bbox2[0])
+        y_top = max(bbox1[1], bbox2[1])
+        x_right = min(bbox1[2], bbox2[2])
+        y_bottom = min(bbox1[3], bbox2[3])
+        
+        if x_right < x_left or y_bottom < y_top:
+            return 0.0
+        
+        intersection_area = (x_right - x_left) * (y_bottom - y_top)
+        bbox1_area = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])
+        bbox2_area = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1])
+        
+        if bbox1_area == 0 or bbox2_area == 0:
+            return 0.0
+        
+        return intersection_area / float(bbox1_area + bbox2_area - intersection_area)
+    
+    @staticmethod
+    def calculate_overlap_ratio(bbox1: List[float], bbox2: List[float]) -> float:
+        """
+        计算重叠面积占小框面积的比例
+        
+        Args:
+            bbox1: 第一个 bbox [x1, y1, x2, y2]
+            bbox2: 第二个 bbox [x1, y1, x2, y2]
+            
+        Returns:
+            重叠比例
+        """
+        if MINERU_BOXBASE_AVAILABLE and calculate_overlap_area_2_minbox_area_ratio is not None:
+            return calculate_overlap_area_2_minbox_area_ratio(bbox1, bbox2)
+        
+        # 备用实现
+        x_left = max(bbox1[0], bbox2[0])
+        y_top = max(bbox1[1], bbox2[1])
+        x_right = min(bbox1[2], bbox2[2])
+        y_bottom = min(bbox1[3], bbox2[3])
+        
+        if x_right < x_left or y_bottom < y_top:
+            return 0.0
+        
+        intersection_area = (x_right - x_left) * (y_bottom - y_top)
+        area1 = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])
+        area2 = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1])
+        min_area = min(area1, area2)
+        
+        if min_area == 0:
+            return 0.0
+        
+        return intersection_area / min_area
+    
+    @staticmethod
+    def calculate_overlap_in_bbox1_ratio(
+        bbox1: List[float], 
+        bbox2: List[float]
+    ) -> float:
+        """
+        计算 bbox1 被 bbox2 覆盖的面积比例
+        
+        Args:
+            bbox1: 第一个 bbox [x1, y1, x2, y2]
+            bbox2: 第二个 bbox [x1, y1, x2, y2]
+            
+        Returns:
+            bbox1 被覆盖的比例
+        """
+        x_left = max(bbox1[0], bbox2[0])
+        y_top = max(bbox1[1], bbox2[1])
+        x_right = min(bbox1[2], bbox2[2])
+        y_bottom = min(bbox1[3], bbox2[3])
+        
+        if x_right < x_left or y_bottom < y_top:
+            return 0.0
+        
+        intersection_area = (x_right - x_left) * (y_bottom - y_top)
+        bbox1_area = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])
+        
+        if bbox1_area == 0:
+            return 0.0
+        
+        return intersection_area / bbox1_area
+    
+    @staticmethod
+    def poly_to_bbox(poly: Union[List, None]) -> List[float]:
+        """
+        将多边形坐标转换为 bbox 格式
+        
+        Args:
+            poly: 多边形坐标,支持以下格式:
+                - [[x1,y1], [x2,y1], [x2,y2], [x1,y2]] (4个点)
+                - [x1, y1, x2, y1, x2, y2, x1, y2] (8个值)
+                - [x1, y1, x2, y2] (4个值,已是bbox)
+                
+        Returns:
+            bbox [x1, y1, x2, y2]
+        """
+        if not poly:
+            return [0, 0, 0, 0]
+        
+        # 处理嵌套列表格式 [[x1,y1], [x2,y1], ...]
+        if isinstance(poly[0], (list, tuple)):
+            xs = [p[0] for p in poly]
+            ys = [p[1] for p in poly]
+            return [min(xs), min(ys), max(xs), max(ys)]
+        
+        # 处理平面列表格式
+        if len(poly) == 4:
+            # 已经是 bbox 格式
+            return list(poly)
+        elif len(poly) >= 8:
+            # 8点格式:[x1, y1, x2, y1, x2, y2, x1, y2]
+            xs = [poly[i] for i in range(0, len(poly), 2)]
+            ys = [poly[i] for i in range(1, len(poly), 2)]
+            return [min(xs), min(ys), max(xs), max(ys)]
+        
+        return [0, 0, 0, 0]
+    
+    @staticmethod
+    def bbox_to_poly(bbox: List[float]) -> List[List[float]]:
+        """
+        将 bbox 转换为多边形坐标
+        
+        Args:
+            bbox: [x1, y1, x2, y2]
+            
+        Returns:
+            [[x1,y1], [x2,y1], [x2,y2], [x1,y2]]
+        """
+        if not bbox or len(bbox) < 4:
+            return [[0, 0], [0, 0], [0, 0], [0, 0]]
+        
+        x1, y1, x2, y2 = bbox[:4]
+        return [
+            [float(x1), float(y1)],
+            [float(x2), float(y1)],
+            [float(x2), float(y2)],
+            [float(x1), float(y2)]
+        ]
+    
+    # ==================== 图像裁剪 ====================
+    
+    @staticmethod
+    def crop_region(image: np.ndarray, bbox: List[float]) -> np.ndarray:
+        """
+        裁剪图像区域
+        
+        Args:
+            image: 原始图像
+            bbox: 裁剪区域 [x1, y1, x2, y2]
+            
+        Returns:
+            裁剪后的图像
+        """
+        if len(bbox) < 4:
+            return image
+        
+        x1, y1, x2, y2 = map(int, bbox[:4])
+        h, w = image.shape[:2]
+        
+        x1 = max(0, min(x1, w))
+        y1 = max(0, min(y1, h))
+        x2 = max(x1, min(x2, w))
+        y2 = max(y1, min(y2, h))
+        
+        return image[y1:y2, x1:x2]
+    
+    @staticmethod
+    def bbox_overlap(bbox1: List[float], bbox2: List[float]) -> bool:
+        """
+        检查两个 bbox 是否重叠
+        
+        Args:
+            bbox1: 第一个 bbox [x1, y1, x2, y2]
+            bbox2: 第二个 bbox [x1, y1, x2, y2]
+            
+        Returns:
+            是否重叠
+        """
+        if len(bbox1) < 4 or len(bbox2) < 4:
+            return False
+        
+        x1_1, y1_1, x2_1, y2_1 = bbox1[:4]
+        x1_2, y1_2, x2_2, y2_2 = bbox2[:4]
+        
+        if x2_1 < x1_2 or x2_2 < x1_1:
+            return False
+        if y2_1 < y1_2 or y2_2 < y1_1:
+            return False
+        
+        return True
+    
+    @staticmethod
+    def convert_to_absolute_coords(
+        relative_bbox: List, 
+        region_bbox: List[float]
+    ) -> List:
+        """
+        将相对坐标转换为绝对坐标
+        
+        Args:
+            relative_bbox: 相对坐标
+            region_bbox: 区域的绝对坐标 [x1, y1, x2, y2]
+            
+        Returns:
+            绝对坐标
+        """
+        if not relative_bbox or len(region_bbox) < 4:
+            return relative_bbox
+        
+        bx1, by1 = region_bbox[0], region_bbox[1]
+        
+        # 处理4点坐标格式 [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
+        if isinstance(relative_bbox[0], (list, tuple)):
+            return [
+                [p[0] + bx1, p[1] + by1] for p in relative_bbox
+            ]
+        
+        # 处理4值坐标格式 [x1, y1, x2, y2]
+        if len(relative_bbox) >= 4:
+            return [
+                relative_bbox[0] + bx1,
+                relative_bbox[1] + by1,
+                relative_bbox[2] + bx1,
+                relative_bbox[3] + by1
+            ]
+        
+        return relative_bbox
+    
+    @staticmethod
+    def convert_ocr_to_matcher_format(
+        ocr_poly: List,
+        text: str,
+        confidence: float,
+        idx: int,
+        table_bbox: Optional[List[float]] = None
+    ) -> Optional[Dict[str, Any]]:
+        """
+        将 OCR 结果转换为 TableCellMatcher 期望的格式
+        
+        OCR 返回格式:
+            bbox: [[x1,y1], [x2,y1], [x2,y2], [x1,y2]]  # 4点多边形
+            text: str
+            confidence: float
+        
+        TableCellMatcher 期望格式:
+            text: str
+            bbox: [x_min, y_min, x_max, y_max]  # 4值矩形
+            poly: [[x1,y1], [x2,y1], [x2,y2], [x1,y2]]  # 4点多边形
+            score: float
+            paddle_bbox_index: int
+            used: bool
+        
+        Args:
+            ocr_poly: OCR返回的4点多边形坐标
+            text: 识别文本
+            confidence: 置信度
+            idx: 索引
+            table_bbox: 表格的绝对坐标,用于转换相对坐标
+        
+        Returns:
+            转换后的字典,或 None(如果无效)
+        """
+        if not ocr_poly or not text:
+            return None
+        
+        poly = []
+        
+        # 格式1: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] - 4点多边形
+        if isinstance(ocr_poly[0], (list, tuple)) and len(ocr_poly) == 4:
+            poly = [[float(p[0]), float(p[1])] for p in ocr_poly]
+        
+        # 格式2: [x1, y1, x2, y1, x2, y2, x1, y2] - 8值展平格式
+        elif len(ocr_poly) == 8 and isinstance(ocr_poly[0], (int, float)):
+            poly = [
+                [float(ocr_poly[0]), float(ocr_poly[1])],
+                [float(ocr_poly[2]), float(ocr_poly[3])],
+                [float(ocr_poly[4]), float(ocr_poly[5])],
+                [float(ocr_poly[6]), float(ocr_poly[7])]
+            ]
+        
+        # 格式3: [x1, y1, x2, y2] - 4值矩形格式
+        elif len(ocr_poly) == 4 and isinstance(ocr_poly[0], (int, float)):
+            x1, y1, x2, y2 = ocr_poly
+            poly = [
+                [float(x1), float(y1)],
+                [float(x2), float(y1)],
+                [float(x2), float(y2)],
+                [float(x1), float(y2)]
+            ]
+        else:
+            logger.warning(f"Unknown OCR bbox format: {ocr_poly}")
+            return None
+        
+        # 转换为绝对坐标(相对于整页图片)
+        if table_bbox and len(table_bbox) >= 2:
+            offset_x, offset_y = table_bbox[0], table_bbox[1]
+            poly = [[p[0] + offset_x, p[1] + offset_y] for p in poly]
+        
+        # 从多边形计算 bbox [x_min, y_min, x_max, y_max]
+        xs = [p[0] for p in poly]
+        ys = [p[1] for p in poly]
+        bbox = [min(xs), min(ys), max(xs), max(ys)]
+        
+        return {
+            'text': text,
+            'bbox': bbox,
+            'poly': poly,
+            'score': confidence,
+            'paddle_bbox_index': idx,
+            'used': False
+        }
+    
+    @staticmethod
+    def inverse_rotate_table_coords(
+        cells: List[Dict],
+        html: str,
+        rotation_angle: float,
+        orig_table_size: Tuple[int, int],
+        table_bbox: List[float]
+    ) -> Tuple[List[Dict], str]:
+        """
+        将旋转后的坐标逆向转换回原图坐标
+        
+        Args:
+            cells: 单元格列表(坐标是旋转后的)
+            html: HTML字符串(data-bbox是旋转后的)
+            rotation_angle: 旋转角度
+            orig_table_size: 原始表格尺寸 (width, height)
+            table_bbox: 表格在整页图片中的位置 [x1, y1, x2, y2]
+        
+        Returns:
+            (转换后的cells, 转换后的html)
+        """
+        if not MERGER_AVAILABLE or BBoxExtractor is None:
+            # 如果 merger 不可用,只添加偏移量
+            converted_cells = CoordinateUtils.add_table_offset_to_cells(cells, table_bbox)
+            converted_html = CoordinateUtils.add_table_offset_to_html(html, table_bbox)
+            return converted_cells, converted_html
+        
+        table_offset_x, table_offset_y = table_bbox[0], table_bbox[1]
+        
+        # 转换 cells 中的 bbox
+        converted_cells = []
+        for cell in cells:
+            cell_copy = cell.copy()
+            cell_bbox = cell.get('bbox', [])
+            if cell_bbox and len(cell_bbox) == 4:
+                # 先逆向旋转,再加上表格偏移量
+                orig_bbox = BBoxExtractor.inverse_rotate_box_coordinates(
+                    cell_bbox, rotation_angle, orig_table_size
+                )
+                # 加上表格偏移量转换为整页坐标
+                cell_copy['bbox'] = [
+                    orig_bbox[0] + table_offset_x,
+                    orig_bbox[1] + table_offset_y,
+                    orig_bbox[2] + table_offset_x,
+                    orig_bbox[3] + table_offset_y
+                ]
+            converted_cells.append(cell_copy)
+        
+        # 转换 HTML 中的 data-bbox
+        def replace_bbox(match):
+            bbox_str = match.group(1)
+            try:
+                bbox = json.loads(bbox_str)
+                if len(bbox) == 4:
+                    orig_bbox = BBoxExtractor.inverse_rotate_box_coordinates(
+                        bbox, rotation_angle, orig_table_size
+                    )
+                    new_bbox = [
+                        orig_bbox[0] + table_offset_x,
+                        orig_bbox[1] + table_offset_y,
+                        orig_bbox[2] + table_offset_x,
+                        orig_bbox[3] + table_offset_y
+                    ]
+                    return f'data-bbox="[{new_bbox[0]},{new_bbox[1]},{new_bbox[2]},{new_bbox[3]}]"'
+            except:
+                pass
+            return match.group(0)
+        
+        converted_html = re.sub(
+            r'data-bbox="\[([^\]]+)\]"',
+            replace_bbox,
+            html
+        )
+        
+        return converted_cells, converted_html
+    
+    @staticmethod
+    def add_table_offset_to_cells(
+        cells: List[Dict],
+        table_bbox: List[float]
+    ) -> List[Dict]:
+        """
+        为单元格坐标添加表格偏移量(无旋转情况)
+        
+        Args:
+            cells: 单元格列表
+            table_bbox: 表格位置 [x1, y1, x2, y2]
+        
+        Returns:
+            转换后的 cells
+        """
+        offset_x, offset_y = table_bbox[0], table_bbox[1]
+        
+        converted_cells = []
+        for cell in cells:
+            cell_copy = cell.copy()
+            cell_bbox = cell.get('bbox', [])
+            if cell_bbox and len(cell_bbox) == 4:
+                cell_copy['bbox'] = [
+                    cell_bbox[0] + offset_x,
+                    cell_bbox[1] + offset_y,
+                    cell_bbox[2] + offset_x,
+                    cell_bbox[3] + offset_y
+                ]
+            converted_cells.append(cell_copy)
+        
+        return converted_cells
+    
+    @staticmethod
+    def add_table_offset_to_html(
+        html: str,
+        table_bbox: List[float]
+    ) -> str:
+        """
+        为HTML中的data-bbox添加表格偏移量(无旋转情况)
+        
+        Args:
+            html: HTML字符串
+            table_bbox: 表格位置 [x1, y1, x2, y2]
+        
+        Returns:
+            转换后的 HTML
+        """
+        offset_x, offset_y = table_bbox[0], table_bbox[1]
+        
+        def replace_bbox(match):
+            bbox_str = match.group(1)
+            try:
+                bbox = json.loads(f"[{bbox_str}]")
+                if len(bbox) == 4:
+                    new_bbox = [
+                        bbox[0] + offset_x,
+                        bbox[1] + offset_y,
+                        bbox[2] + offset_x,
+                        bbox[3] + offset_y
+                    ]
+                    return f'data-bbox="[{new_bbox[0]},{new_bbox[1]},{new_bbox[2]},{new_bbox[3]}]"'
+            except:
+                pass
+            return match.group(0)
+        
+        converted_html = re.sub(
+            r'data-bbox="\[([^\]]+)\]"',
+            replace_bbox,
+            html
+        )
+        
+        return converted_html
+    
+    @staticmethod
+    def add_table_offset_to_ocr_boxes(
+        ocr_boxes: List[Dict],
+        table_bbox: List[float]
+    ) -> List[Dict]:
+        """
+        为 ocr_boxes 添加表格偏移量,将相对坐标转换为页面绝对坐标
+        
+        Args:
+            ocr_boxes: OCR 框列表
+            table_bbox: 表格在页面中的位置 [x1, y1, x2, y2]
+            
+        Returns:
+            转换后的 ocr_boxes
+        """
+        if not ocr_boxes or not table_bbox:
+            return ocr_boxes
+        
+        offset_x = table_bbox[0]
+        offset_y = table_bbox[1]
+        
+        converted_boxes = []
+        for box in ocr_boxes:
+            new_box = box.copy()
+            
+            # 转换 bbox [x1, y1, x2, y2]
+            if 'bbox' in new_box and new_box['bbox']:
+                bbox = new_box['bbox']
+                if len(bbox) >= 4:
+                    new_box['bbox'] = [
+                        bbox[0] + offset_x,
+                        bbox[1] + offset_y,
+                        bbox[2] + offset_x,
+                        bbox[3] + offset_y
+                    ]
+            
+            # 转换 poly [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
+            if 'poly' in new_box and new_box['poly']:
+                poly = new_box['poly']
+                new_poly = []
+                for point in poly:
+                    if isinstance(point, (list, tuple)) and len(point) >= 2:
+                        new_poly.append([point[0] + offset_x, point[1] + offset_y])
+                    else:
+                        new_poly.append(point)
+                new_box['poly'] = new_poly
+            
+            converted_boxes.append(new_box)
+        
+        return converted_boxes
+    
+    @staticmethod
+    def inverse_rotate_ocr_boxes(
+        ocr_boxes: List[Dict],
+        rotation_angle: float,
+        orig_table_size: Tuple[int, int],
+        table_bbox: List[float]
+    ) -> List[Dict]:
+        """
+        对 ocr_boxes 进行逆向旋转并添加表格偏移量
+        
+        Args:
+            ocr_boxes: OCR 框列表
+            rotation_angle: 表格旋转角度
+            orig_table_size: 原始表格尺寸 (width, height)
+            table_bbox: 表格在页面中的位置
+            
+        Returns:
+            转换后的 ocr_boxes
+        """
+        if not ocr_boxes:
+            return ocr_boxes
+        
+        if not MERGER_AVAILABLE or BBoxExtractor is None:
+            return CoordinateUtils.add_table_offset_to_ocr_boxes(ocr_boxes, table_bbox)
+        
+        offset_x = table_bbox[0]
+        offset_y = table_bbox[1]
+        
+        converted_boxes = []
+        for box in ocr_boxes:
+            new_box = box.copy()
+            
+            # 逆向旋转 bbox
+            if 'bbox' in new_box and new_box['bbox']:
+                bbox = new_box['bbox']
+                if len(bbox) >= 4:
+                    try:
+                        rotated_bbox = BBoxExtractor.inverse_rotate_box_coordinates(
+                            bbox, rotation_angle, orig_table_size
+                        )
+                        new_box['bbox'] = [
+                            rotated_bbox[0] + offset_x,
+                            rotated_bbox[1] + offset_y,
+                            rotated_bbox[2] + offset_x,
+                            rotated_bbox[3] + offset_y
+                        ]
+                    except Exception as e:
+                        logger.debug(f"Failed to inverse rotate ocr_box bbox: {e}")
+            
+            # 逆向旋转 poly
+            if 'poly' in new_box and new_box['poly']:
+                poly = new_box['poly']
+                try:
+                    poly_list = [[float(p[0]), float(p[1])] for p in poly]
+                    rotated_poly = BBoxExtractor.inverse_rotate_coordinates(
+                        poly_list, rotation_angle, orig_table_size
+                    )
+                    new_poly = []
+                    for point in rotated_poly:
+                        new_poly.append([point[0] + offset_x, point[1] + offset_y])
+                    new_box['poly'] = new_poly
+                except Exception as e:
+                    logger.debug(f"Failed to inverse rotate ocr_box poly: {e}")
+            
+            converted_boxes.append(new_box)
+        
+        return converted_boxes
+    
+    @staticmethod
+    def is_poly_format(bbox: Any) -> bool:
+        """
+        检测 bbox 是否为四点多边形格式
+        
+        四点格式: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
+        矩形格式: [x_min, y_min, x_max, y_max]
+        """
+        if not bbox or not isinstance(bbox, list):
+            return False
+        if len(bbox) != 4:
+            return False
+        return isinstance(bbox[0], (list, tuple))
+    
+    @staticmethod
+    def transform_coords_to_original(
+        element: Dict[str, Any],
+        rotate_angle: int,
+        rotated_shape: Tuple,
+        original_shape: Tuple
+    ) -> Dict[str, Any]:
+        """
+        将坐标从旋转后的图片坐标系转换回原始图片坐标系
+        
+        Args:
+            element: 元素字典(包含bbox等坐标信息)
+            rotate_angle: 旋转角度(0, 90, 180, 270)
+            rotated_shape: 旋转后图片的shape (h, w, c)
+            original_shape: 原始图片的shape (h, w, c)
+        
+        Returns:
+            坐标转换后的元素字典(深拷贝)
+        """
+        import copy
+        element = copy.deepcopy(element)
+        
+        if rotate_angle == 0 or not MERGER_AVAILABLE or BBoxExtractor is None:
+            return element
+        
+        # 原始图片尺寸 (width, height)
+        orig_h, orig_w = original_shape[:2]
+        orig_image_size = (orig_w, orig_h)
+        
+        # 转换主bbox
+        if 'bbox' in element and element['bbox']:
+            element['bbox'] = BBoxExtractor.inverse_rotate_box_coordinates(
+                element['bbox'], rotate_angle, orig_image_size
+            )
+        
+        # 转换表格相关坐标
+        if element.get('type') == 'table' and 'content' in element:
+            content = element['content']
+            
+            # 转换 OCR boxes
+            if 'ocr_boxes' in content and content['ocr_boxes']:
+                for box in content['ocr_boxes']:
+                    if 'bbox' in box and box['bbox']:
+                        box['bbox'] = BBoxExtractor.inverse_rotate_box_coordinates(
+                            box['bbox'], rotate_angle, orig_image_size
+                        )
+            
+            # 转换 cells
+            if 'cells' in content and content['cells']:
+                for cell in content['cells']:
+                    if 'bbox' in cell and cell['bbox']:
+                        cell['bbox'] = BBoxExtractor.inverse_rotate_box_coordinates(
+                            cell.get('bbox', []), rotate_angle, orig_image_size
+                        )
+            
+            # 转换 HTML 中的 data-bbox 属性
+            if 'html' in content and content['html']:
+                content['html'] = CoordinateUtils.transform_html_data_bbox(
+                    content['html'], rotate_angle, orig_image_size
+                )
+        
+        # 转换文本OCR details
+        if 'content' in element and 'ocr_details' in element.get('content', {}):
+            ocr_details = element['content'].get('ocr_details', [])
+            if ocr_details:
+                for detail in ocr_details:
+                    if 'bbox' in detail and detail['bbox']:
+                        if CoordinateUtils.is_poly_format(detail['bbox']):
+                            detail['bbox'] = BBoxExtractor.inverse_rotate_coordinates(
+                                detail['bbox'], rotate_angle, orig_image_size
+                            )
+                        else:
+                            detail['bbox'] = BBoxExtractor.inverse_rotate_box_coordinates(
+                                detail.get('bbox', []), rotate_angle, orig_image_size
+                            )
+        
+        return element
+    
+    @staticmethod
+    def transform_html_data_bbox(
+        html: str,
+        rotate_angle: int,
+        orig_image_size: Tuple[int, int]
+    ) -> str:
+        """
+        转换 HTML 中所有 data-bbox 属性的坐标
+        
+        Args:
+            html: 包含 data-bbox 属性的 HTML 字符串
+            rotate_angle: 旋转角度
+            orig_image_size: 原始图片尺寸 (width, height)
+        
+        Returns:
+            转换后的 HTML 字符串
+        """
+        if not MERGER_AVAILABLE or BBoxExtractor is None:
+            return html
+        
+        def replace_bbox(match):
+            try:
+                bbox_str = match.group(1)
+                bbox = json.loads(bbox_str)
+                if bbox and len(bbox) == 4:
+                    transformed = BBoxExtractor.inverse_rotate_box_coordinates(
+                        bbox, rotate_angle, orig_image_size
+                    )
+                    return f'data-bbox="{json.dumps(transformed)}"'
+            except (json.JSONDecodeError, ValueError):
+                pass
+            return match.group(0)
+        
+        pattern = r'data-bbox="(\[[^\]]+\])"'
+        return re.sub(pattern, replace_bbox, html)
+

+ 750 - 0
ocr_tools/universal_doc_parser/core/element_processors.py

@@ -0,0 +1,750 @@
+"""
+元素处理器模块
+
+提供各类文档元素的处理功能:
+- 文本元素处理
+- 表格元素处理
+- 图片元素处理
+- 公式元素处理
+- 代码元素处理
+- 丢弃元素处理
+"""
+from typing import Dict, List, Any, Optional, Tuple
+import numpy as np
+from loguru import logger
+
+from .coordinate_utils import CoordinateUtils
+from .pdf_utils import PDFUtils
+
+# 导入 SpanMatcher(用于 spans 合并)
+try:
+    from .layout_utils import SpanMatcher
+except ImportError:
+    from layout_utils import SpanMatcher
+
+# 导入 merger 组件
+try:
+    from merger import TableCellMatcher, TextMatcher, BBoxExtractor
+    MERGER_AVAILABLE = True
+except ImportError:
+    MERGER_AVAILABLE = False
+    TableCellMatcher = None
+    TextMatcher = None
+    BBoxExtractor = None
+
+
+class ElementProcessors:
+    """元素处理器类"""
+    
+    def __init__(
+        self,
+        preprocessor: Any,
+        ocr_recognizer: Any,
+        vl_recognizer: Any,
+        table_cell_matcher: Optional[Any] = None,
+        wired_table_recognizer: Optional[Any] = None,
+    ):
+        """
+        初始化元素处理器
+        
+        Args:
+            preprocessor: 预处理器(方向检测)
+            ocr_recognizer: OCR识别器
+            vl_recognizer: VL识别器(表格、公式)
+            table_cell_matcher: 表格单元格匹配器
+            wired_table_recognizer: 有线表格识别器(可选)
+        """
+        self.preprocessor = preprocessor
+        self.ocr_recognizer = ocr_recognizer
+        self.vl_recognizer = vl_recognizer
+        self.table_cell_matcher = table_cell_matcher
+        self.wired_table_recognizer = wired_table_recognizer
+    
+    def _convert_ocr_details_to_absolute(
+        self,
+        ocr_details: List[Dict[str, Any]],
+        region_bbox: List[float]
+    ) -> List[Dict[str, Any]]:
+        """
+        将 OCR 详情中的相对坐标转换为绝对坐标
+        
+        Args:
+            ocr_details: OCR 结果列表,每项包含 'bbox' 字段
+            region_bbox: 裁剪区域的绝对坐标 [x1, y1, x2, y2]
+            
+        Returns:
+            坐标转换后的 OCR 详情列表
+        """
+        if not ocr_details or not region_bbox or len(region_bbox) < 2:
+            return ocr_details
+        
+        converted_details = []
+        for item in ocr_details:
+            new_item = item.copy()
+            ocr_bbox = item.get('bbox', [])
+            if ocr_bbox:
+                new_item['bbox'] = CoordinateUtils.convert_to_absolute_coords(
+                    ocr_bbox, region_bbox
+                )
+            converted_details.append(new_item)
+        
+        return converted_details
+    
+    def process_text_element(
+        self,
+        image: np.ndarray,
+        layout_item: Dict[str, Any],
+        pdf_type: str,
+        pdf_doc: Optional[Any],
+        page_idx: int,
+        scale: float,
+        pre_matched_spans: Optional[List[Dict[str, Any]]] = None
+    ) -> Dict[str, Any]:
+        """
+        处理文本元素
+        
+        处理优先级:
+        1. 如果有预匹配的 spans(整页 OCR 结果),优先使用
+        2. 数字PDF:尝试 PDF 字符提取
+        3. 扫描件或提取失败:裁剪区域 OCR(兜底方案)
+        
+        Args:
+            image: 页面图像
+            layout_item: 布局检测项
+            pdf_type: PDF类型 ('ocr' 或 'txt')
+            pdf_doc: PDF文档对象
+            page_idx: 页码索引
+            scale: 缩放比例
+            pre_matched_spans: 预匹配的 OCR spans(来自整页 OCR)
+            
+        Returns:
+            处理后的元素字典
+        """
+        bbox = layout_item.get('bbox', [0, 0, 0, 0])
+        
+        text_content = ""
+        ocr_details = []
+        extraction_method = "none"
+        
+        # 优先级1:使用预匹配的 spans(整页 OCR 结果)
+        if pre_matched_spans and len(pre_matched_spans) > 0:
+            text_content, sorted_spans = SpanMatcher.merge_spans_to_text(
+                pre_matched_spans, bbox
+            )
+            if text_content.strip():
+                # spans 的坐标已经是绝对坐标,直接使用
+                ocr_details = sorted_spans
+                extraction_method = "fullpage_ocr"
+                logger.debug(f"📝 Text from full-page OCR: '{text_content[:30]}...'")
+        
+        # 优先级2:数字 PDF 字符提取
+        if not text_content.strip() and pdf_type == 'txt' and pdf_doc is not None:
+            try:
+                text_content, extraction_success = PDFUtils.extract_text_from_pdf(
+                    pdf_doc, page_idx, bbox, scale
+                )
+                if extraction_success and text_content.strip():
+                    extraction_method = "pdf_extract"
+                    logger.debug(f"📝 Text extracted from PDF: '{text_content[:30]}...'")
+            except Exception as e:
+                logger.debug(f"PDF text extraction failed: {e}")
+        
+        # 优先级3:裁剪区域 OCR(兜底方案)
+        if not text_content.strip():
+            try:
+                cropped_image = CoordinateUtils.crop_region(image, bbox)
+                ocr_results = self.ocr_recognizer.recognize_text(cropped_image)
+                if ocr_results:
+                    text_parts = [
+                        item['text'] for item in ocr_results 
+                        if item.get('confidence', 0) > 0.5
+                    ]
+                    text_content = " ".join(text_parts)
+                    # 将 OCR 坐标转换为绝对坐标
+                    ocr_details = self._convert_ocr_details_to_absolute(ocr_results, bbox)
+                    extraction_method = "cropped_ocr"
+            except Exception as e:
+                logger.warning(f"OCR recognition failed: {e}")
+        
+        return {
+            'type': layout_item.get('category', 'text'),
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'content': {
+                'text': text_content,
+                'ocr_details': ocr_details,
+                'extraction_method': extraction_method
+            }
+        }
+    
+    def _prepare_table_ocr(
+        self,
+        image: np.ndarray,
+        bbox: List[float],
+        pre_matched_spans: Optional[List[Dict[str, Any]]] = None
+    ) -> Tuple[np.ndarray, List[Dict[str, Any]], int, str]:
+        """
+        表格OCR预处理(共享逻辑)
+        
+        优先使用整页 OCR 结果,兜底裁剪 OCR
+        
+        Args:
+            image: 页面图像(原始未旋转)
+            bbox: 表格的绝对坐标 bbox
+            pre_matched_spans: 预匹配的 OCR spans
+            
+        Returns:
+            (cropped_table, ocr_boxes, table_angle, ocr_source)
+            其中 cropped_table 已经过方向检测和旋转处理
+        """
+        cropped_table = CoordinateUtils.crop_region(image, bbox)
+        table_angle = 0
+        
+        # 1. 表格方向检测
+        try:
+            rotated_table, table_angle = self.preprocessor.process(cropped_table)
+            if table_angle != 0:
+                logger.info(f"📐 Table rotated {table_angle}°")
+                cropped_table = rotated_table
+        except Exception as e:
+            logger.debug(f"Table orientation detection skipped: {e}")
+        
+        # 2. 收集 OCR 框:优先整页 OCR,兜底裁剪 OCR
+        ocr_boxes = []
+        ocr_source = "none"
+        
+        if pre_matched_spans and len(pre_matched_spans) > 0 and table_angle == 0:
+            # 使用整页 OCR 的结果
+            for idx, span in enumerate(pre_matched_spans):
+                span_bbox = span.get('bbox', [])
+                if span_bbox:
+                    relative_bbox = [
+                        span_bbox[0] - bbox[0],
+                        span_bbox[1] - bbox[1],
+                        span_bbox[2] - bbox[0],
+                        span_bbox[3] - bbox[1]
+                    ]
+                    formatted_box = CoordinateUtils.convert_ocr_to_matcher_format(
+                        relative_bbox,
+                        span.get('text', ''),
+                        span.get('confidence', 0.0),
+                        idx,
+                        table_bbox=None
+                    )
+                    if formatted_box:
+                        ocr_boxes.append(formatted_box)
+            
+            if ocr_boxes:
+                ocr_source = "fullpage_ocr"
+                logger.info(f"📊 Using {len(ocr_boxes)} text boxes from full-page OCR")
+        
+        # 兜底:裁剪后单独 OCR
+        if not ocr_boxes:
+            try:
+                ocr_results = self.ocr_recognizer.recognize_text(cropped_table)
+                if ocr_results:
+                    for idx, item in enumerate(ocr_results):
+                        ocr_poly = item.get('bbox', [])
+                        if ocr_poly:
+                            formatted_box = CoordinateUtils.convert_ocr_to_matcher_format(
+                                ocr_poly, 
+                                item.get('text', ''),
+                                item.get('confidence', 0.0),
+                                idx,
+                                table_bbox=None
+                            )
+                            if formatted_box:
+                                ocr_boxes.append(formatted_box)
+                                # 归一化为4值矩形供有线识别
+                                if isinstance(ocr_poly[0], (list, tuple)) and len(ocr_poly) == 4:
+                                    xs = [p[0] for p in ocr_poly]
+                                    ys = [p[1] for p in ocr_poly]
+                                    rect = [min(xs), min(ys), max(xs), max(ys)]
+                                elif len(ocr_poly) == 8:
+                                    xs = [ocr_poly[i] for i in range(0, 8, 2)]
+                                    ys = [ocr_poly[i] for i in range(1, 8, 2)]
+                                    rect = [min(xs), min(ys), max(xs), max(ys)]
+                                elif len(ocr_poly) == 4:
+                                    rect = [ocr_poly[0], ocr_poly[1], ocr_poly[2], ocr_poly[3]]
+                                else:
+                                    rect = None
+                ocr_source = "cropped_ocr"
+                logger.info(f"📊 OCR detected {len(ocr_boxes)} text boxes in table (cropped)")
+            except Exception as e:
+                logger.warning(f"Table OCR detection failed: {e}")
+        
+        return cropped_table, ocr_boxes, table_angle, ocr_source
+    
+    def process_table_element_wired(
+        self,
+        image: np.ndarray,
+        layout_item: Dict[str, Any],
+        scale: float,
+        pre_matched_spans: Optional[List[Dict[str, Any]]] = None
+    ) -> Dict[str, Any]:
+        """
+        使用 UNet 有线表格识别处理表格元素
+        
+        流程:
+        1. OCR检测获取文本框坐标
+        2. UNet 有线表格识别
+        3. 坐标逆向转换回原图坐标
+        
+        Args:
+            image: 页面图像
+            layout_item: 布局检测项
+            scale: 缩放比例
+            pre_matched_spans: 预匹配的 OCR spans(来自整页 OCR)
+            
+        Returns:
+            处理后的元素字典
+        """
+        bbox = layout_item.get('bbox', [0, 0, 0, 0])
+        
+        # OCR 预处理(返回已旋转的表格图片 + OCR 框)
+        cropped_table, ocr_boxes, table_angle, ocr_source = \
+            self._prepare_table_ocr(image, bbox, pre_matched_spans)
+        
+        # 获取裁剪后表格图片的尺寸
+        orig_table_h, orig_table_w = cropped_table.shape[:2]
+        orig_table_size = (orig_table_w, orig_table_h)
+        
+        # UNet 有线表格识别
+        cells = []
+        enhanced_html = ""
+        
+        try:
+            if not self.wired_table_recognizer:
+                raise RuntimeError("Wired table recognizer not available")
+            
+            wired_res = self.wired_table_recognizer.recognize(
+                table_image=cropped_table,
+                # ocr_boxes=ocr_boxes_for_wired,
+                ocr_boxes=ocr_boxes,
+            )
+            
+            if not (wired_res.get('html') or wired_res.get('cells')):
+                raise RuntimeError("Wired recognizer returned empty result")
+            
+            cells = wired_res.get('cells', [])
+            enhanced_html = wired_res.get('html', '')
+            logger.info(f"📊 Wired UNet recognized {len(cells)} cells")
+            
+        except Exception as e:
+            logger.warning(f"⚠️ Wired UNet recognition failed: {e}, falling back to empty result")
+            # 有线识别失败,返回空表格(由 pipeline 层负责 fallback 到 VLM)
+            return self._create_empty_table_result(layout_item, bbox, table_angle, ocr_source)
+        
+        # 坐标转换:将旋转后的坐标转换回原图坐标
+        if table_angle != 0 and MERGER_AVAILABLE:
+            cells, enhanced_html = CoordinateUtils.inverse_rotate_table_coords(
+                cells=cells,
+                html=enhanced_html,
+                rotation_angle=table_angle,
+                orig_table_size=orig_table_size,
+                table_bbox=bbox
+            )
+            ocr_boxes = CoordinateUtils.inverse_rotate_ocr_boxes(
+                ocr_boxes=ocr_boxes,
+                rotation_angle=table_angle,
+                orig_table_size=orig_table_size,
+                table_bbox=bbox
+            )
+            logger.info(f"📐 Wired table coordinates transformed back to original image")
+        else:
+            # 没有旋转,只需要加上表格偏移量
+            cells = CoordinateUtils.add_table_offset_to_cells(cells, bbox)
+            enhanced_html = CoordinateUtils.add_table_offset_to_html(enhanced_html, bbox)
+            ocr_boxes = CoordinateUtils.add_table_offset_to_ocr_boxes(ocr_boxes, bbox)
+        
+        return {
+            'type': 'table',
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'content': {
+                'html': enhanced_html,
+                'original_html': enhanced_html,
+                'cells': cells,
+                'ocr_boxes': ocr_boxes,
+                'table_angle': table_angle,
+                'skew_angle': 0.0,
+                'ocr_source': ocr_source,
+                'recognition_method': 'wired_unet',
+            },
+        }
+    
+    def process_table_element_vlm(
+        self,
+        image: np.ndarray,
+        layout_item: Dict[str, Any],
+        scale: float,
+        pre_matched_spans: Optional[List[Dict[str, Any]]] = None
+    ) -> Dict[str, Any]:
+        """
+        使用 VLM 无线表格识别处理表格元素
+        
+        流程:
+        1. OCR检测获取文本框坐标
+        2. VLM识别获取表格结构HTML
+        3. 匹配OCR坐标与VLM结构
+        4. 坐标逆向转换回原图坐标
+        
+        Args:
+            image: 页面图像
+            layout_item: 布局检测项
+            scale: 缩放比例
+            pre_matched_spans: 预匹配的 OCR spans(来自整页 OCR)
+            
+        Returns:
+            处理后的元素字典
+        """
+        bbox = layout_item.get('bbox', [0, 0, 0, 0])
+        
+        # OCR 预处理(返回已旋转的表格图片 + OCR 框)
+        cropped_table, ocr_boxes, table_angle, ocr_source = \
+            self._prepare_table_ocr(image, bbox, pre_matched_spans)
+        
+        # 获取裁剪后表格图片的尺寸
+        orig_table_h, orig_table_w = cropped_table.shape[:2]
+        orig_table_size = (orig_table_w, orig_table_h)
+        
+        # VLM 识别获取表格结构HTML
+        table_html = ""
+        try:
+            vl_result = self.vl_recognizer.recognize_table(
+                cropped_table,
+                return_cells_coordinate=True
+            )
+            table_html = vl_result.get('html', '')
+            logger.info(f"📊 VLM recognized table structure")
+        except Exception as e:
+            logger.warning(f"VLM table recognition failed: {e}")
+            return self._create_empty_table_result(layout_item, bbox, table_angle, ocr_source)
+        
+        # 匹配OCR坐标与VLM结构
+        cells = []
+        enhanced_html = table_html
+        skew_angle = 0.0
+        
+        if table_html and ocr_boxes and self.table_cell_matcher:
+            try:
+                enhanced_html, cells, _, skew_angle = self.table_cell_matcher.enhance_table_html_with_bbox(
+                    html=table_html,
+                    paddle_text_boxes=ocr_boxes,
+                    start_pointer=0,
+                    table_bbox=[0, 0, bbox[2] - bbox[0], bbox[3] - bbox[1]]
+                )
+                logger.info(f"📊 Matched {len(cells)} cells with coordinates (skew: {skew_angle:.2f}°)")
+            except Exception as e:
+                logger.warning(f"Cell coordinate matching failed: {e}")
+        
+        # 坐标转换:将旋转后的坐标转换回原图坐标
+        if table_angle != 0 and MERGER_AVAILABLE:
+            cells, enhanced_html = CoordinateUtils.inverse_rotate_table_coords(
+                cells=cells,
+                html=enhanced_html,
+                rotation_angle=table_angle,
+                orig_table_size=orig_table_size,
+                table_bbox=bbox
+            )
+            ocr_boxes = CoordinateUtils.inverse_rotate_ocr_boxes(
+                ocr_boxes=ocr_boxes,
+                rotation_angle=table_angle,
+                orig_table_size=orig_table_size,
+                table_bbox=bbox
+            )
+            logger.info(f"📐 VLM table coordinates transformed back to original image")
+        else:
+            # 没有旋转,只需要加上表格偏移量
+            cells = CoordinateUtils.add_table_offset_to_cells(cells, bbox)
+            enhanced_html = CoordinateUtils.add_table_offset_to_html(enhanced_html, bbox)
+            ocr_boxes = CoordinateUtils.add_table_offset_to_ocr_boxes(ocr_boxes, bbox)
+        
+        return {
+            'type': 'table',
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'content': {
+                'html': enhanced_html,
+                'original_html': table_html,
+                'cells': cells,
+                'ocr_boxes': ocr_boxes,
+                'table_angle': table_angle,
+                'skew_angle': skew_angle,
+                'ocr_source': ocr_source,
+                'recognition_method': 'vlm',
+            },
+        }
+    
+    def _create_empty_table_result(
+        self,
+        layout_item: Dict[str, Any],
+        bbox: List[float],
+        table_angle: int = 0,
+        ocr_source: str = "none"
+    ) -> Dict[str, Any]:
+        """
+        创建空表格结果(识别失败时使用)
+        
+        Args:
+            layout_item: 布局检测项
+            bbox: 表格 bbox
+            table_angle: 表格旋转角度
+            ocr_source: OCR 来源
+            
+        Returns:
+            空表格结果
+        """
+        return {
+            'type': 'table',
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'content': {
+                'html': '',
+                'original_html': '',
+                'cells': [],
+                'ocr_boxes': [],
+                'table_angle': table_angle,
+                'skew_angle': 0.0,
+                'ocr_source': ocr_source,
+                'recognition_method': 'none',
+            },
+        }
+    
+    def process_equation_element(
+        self,
+        image: np.ndarray,
+        layout_item: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """
+        处理公式元素
+        
+        Args:
+            image: 页面图像
+            layout_item: 布局检测项
+            
+        Returns:
+            处理后的元素字典
+        """
+        bbox = layout_item.get('bbox', [0, 0, 0, 0])
+        category = layout_item.get('category', '')
+        cropped_region = CoordinateUtils.crop_region(image, bbox)
+        
+        content = {'latex': '', 'confidence': 0.0}
+        
+        try:
+            formula_result = self.vl_recognizer.recognize_formula(cropped_region)
+            content = {
+                'latex': formula_result.get('latex', ''),
+                'confidence': formula_result.get('confidence', 0.0)
+            }
+        except Exception as e:
+            logger.warning(f"Formula recognition failed: {e}")
+        
+        return {
+            'type': category,
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'content': content
+        }
+    
+    def process_image_element(
+        self,
+        image: np.ndarray,
+        layout_item: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """
+        处理图片元素
+        
+        Args:
+            image: 页面图像
+            layout_item: 布局检测项
+            
+        Returns:
+            处理后的元素字典
+        """
+        bbox = layout_item.get('bbox', [0, 0, 0, 0])
+        category = layout_item.get('category', '')
+        
+        cropped_image = CoordinateUtils.crop_region(image, bbox)
+        
+        return {
+            'type': category,
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'content': {
+                'type': 'image',
+                'description': '',
+                'image_data': cropped_image,
+                'image_path': ''
+            }
+        }
+    
+    def process_code_element(
+        self,
+        image: np.ndarray,
+        layout_item: Dict[str, Any],
+        pdf_type: str,
+        pdf_doc: Optional[Any],
+        page_idx: int,
+        scale: float,
+        pre_matched_spans: Optional[List[Dict[str, Any]]] = None
+    ) -> Dict[str, Any]:
+        """
+        处理代码元素
+        
+        处理优先级:
+        1. 如果有预匹配的 spans(整页 OCR 结果),优先使用
+        2. 数字PDF:尝试 PDF 字符提取
+        3. 扫描件或提取失败:裁剪区域 OCR(兜底方案)
+        
+        Args:
+            image: 页面图像
+            layout_item: 布局检测项
+            pdf_type: PDF类型
+            pdf_doc: PDF文档对象
+            page_idx: 页码索引
+            scale: 缩放比例
+            pre_matched_spans: 预匹配的 OCR spans(来自整页 OCR)
+            
+        Returns:
+            处理后的元素字典
+        """
+        bbox = layout_item.get('bbox', [0, 0, 0, 0])
+        category = layout_item.get('category', '')
+        
+        code_content = ""
+        ocr_details = []
+        extraction_method = "none"
+        
+        # 优先级1:使用预匹配的 spans(整页 OCR 结果)
+        if pre_matched_spans and len(pre_matched_spans) > 0:
+            code_content, sorted_spans = SpanMatcher.merge_spans_to_text(
+                pre_matched_spans, bbox
+            )
+            if code_content.strip():
+                ocr_details = sorted_spans
+                extraction_method = "fullpage_ocr"
+                logger.debug(f"📝 Code from full-page OCR: '{code_content[:30]}...'")
+        
+        # 优先级2:数字 PDF 字符提取
+        if not code_content.strip() and pdf_type == 'txt' and pdf_doc is not None:
+            code_content, success = PDFUtils.extract_text_from_pdf(
+                pdf_doc, page_idx, bbox, scale
+            )
+            if success and code_content.strip():
+                extraction_method = "pdf_extract"
+            else:
+                code_content = ""
+        
+        # 优先级3:裁剪区域 OCR(兜底方案)
+        if not code_content.strip():
+            try:
+                cropped_image = CoordinateUtils.crop_region(image, bbox)
+                ocr_results = self.ocr_recognizer.recognize_text(cropped_image)
+                if ocr_results:
+                    lines = []
+                    for item in ocr_results:
+                        lines.append(item.get('text', ''))
+                    code_content = '\n'.join(lines)
+                    # 将 OCR 坐标转换为绝对坐标
+                    ocr_details = self._convert_ocr_details_to_absolute(ocr_results, bbox)
+                    extraction_method = "cropped_ocr"
+            except Exception as e:
+                logger.warning(f"Code OCR failed: {e}")
+        
+        return {
+            'type': category,
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'content': {
+                'code': code_content,
+                'language': '',
+                'ocr_details': ocr_details,
+                'extraction_method': extraction_method
+            }
+        }
+    
+    def process_discard_element(
+        self,
+        image: np.ndarray,
+        layout_item: Dict[str, Any],
+        pre_matched_spans: Optional[List[Dict[str, Any]]] = None
+    ) -> Dict[str, Any]:
+        """
+        处理丢弃元素(水印、装饰等)
+        
+        提取文字块用于后续分析,但标记为丢弃类型
+        
+        Args:
+            image: 页面图像
+            layout_item: 布局检测项
+            pre_matched_spans: 预匹配的 OCR spans(来自整页 OCR)
+            
+        Returns:
+            处理后的元素字典
+        """
+        bbox = layout_item.get('bbox', [0, 0, 0, 0])
+        category = layout_item.get('category', 'abandon')
+        
+        text_content = ""
+        ocr_details: List[Dict[str, Any]] = []
+        
+        # 优先使用预匹配的 spans
+        if pre_matched_spans and len(pre_matched_spans) > 0:
+            text_content, sorted_spans = SpanMatcher.merge_spans_to_text(
+                pre_matched_spans, bbox
+            )
+            if text_content.strip():
+                ocr_details = sorted_spans
+        
+        # 兜底:裁剪区域 OCR
+        if not text_content.strip():
+            try:
+                cropped_image = CoordinateUtils.crop_region(image, bbox)
+                ocr_results = self.ocr_recognizer.recognize_text(cropped_image)
+                if ocr_results:
+                    text_parts = [
+                        item['text'] for item in ocr_results 
+                        if item.get('confidence', 0) > 0.5
+                    ]
+                    text_content = " ".join(text_parts)
+                    # 将 OCR 坐标转换为绝对坐标
+                    ocr_details = self._convert_ocr_details_to_absolute(ocr_results, bbox)
+            except Exception as e:
+                logger.debug(f"Discard element OCR failed: {e}")
+        
+        return {
+            'type': 'discarded',
+            'bbox': bbox,
+            'confidence': layout_item.get('confidence', 0.0),
+            'original_category': category,
+            'content': {
+                'text': text_content,
+                'ocr_details': ocr_details
+            }
+        }
+    
+    @staticmethod
+    def create_error_element(
+        layout_item: Dict[str, Any], 
+        error_msg: str
+    ) -> Dict[str, Any]:
+        """
+        创建错误元素
+        
+        Args:
+            layout_item: 原始布局项
+            error_msg: 错误信息
+            
+        Returns:
+            错误元素字典
+        """
+        return {
+            'type': 'error',
+            'bbox': layout_item.get('bbox', [0, 0, 0, 0]),
+            'confidence': 0.0,
+            'content': {'error': error_msg},
+            'original_category': layout_item.get('category', 'unknown')
+        }
+

+ 518 - 0
ocr_tools/universal_doc_parser/core/layout_utils.py

@@ -0,0 +1,518 @@
+"""
+布局处理工具模块
+
+提供布局相关处理功能:
+- 重叠框检测与去重
+- 阅读顺序排序
+- OCR Span 与 Layout Block 匹配
+- 大面积文本块转换为表格(后处理)
+
+注:底层坐标计算方法(IoU、重叠比例、poly_to_bbox 等)已统一到 coordinate_utils.py
+"""
+from typing import Dict, List, Any, Tuple, Optional
+from loguru import logger
+import statistics
+
+# 导入坐标工具(底层坐标计算方法)
+try:
+    from .coordinate_utils import CoordinateUtils
+except ImportError:
+    from coordinate_utils import CoordinateUtils
+
+
+class LayoutUtils:
+    """布局处理工具类"""
+    
+    # ==================== 坐标计算方法(委托给 CoordinateUtils)====================
+    
+    @staticmethod
+    def calculate_iou(bbox1: List[float], bbox2: List[float]) -> float:
+        """计算两个 bbox 的 IoU(交并比)- 委托给 CoordinateUtils"""
+        return CoordinateUtils.calculate_iou(bbox1, bbox2)
+    
+    @staticmethod
+    def calculate_overlap_ratio(bbox1: List[float], bbox2: List[float]) -> float:
+        """计算重叠面积占小框面积的比例 - 委托给 CoordinateUtils"""
+        return CoordinateUtils.calculate_overlap_ratio(bbox1, bbox2)
+    
+    # ==================== 布局处理方法 ====================
+    
+    @staticmethod
+    def remove_overlapping_boxes(
+        layout_results: List[Dict[str, Any]],
+        iou_threshold: float = 0.8,
+        overlap_ratio_threshold: float = 0.8
+    ) -> List[Dict[str, Any]]:
+        """
+        处理重叠的布局框(参考 MinerU 的去重策略)
+        
+        策略:
+        1. 高 IoU 重叠:保留置信度高的框
+        2. 包含关系:小框被大框高度包含时,保留大框并扩展边界
+        3. 同类型优先合并
+        
+        Args:
+            layout_results: Layout 检测结果列表
+            iou_threshold: IoU 阈值,超过此值认为高度重叠
+            overlap_ratio_threshold: 重叠面积占小框面积的比例阈值
+            
+        Returns:
+            去重后的布局结果列表
+        """
+        if not layout_results or len(layout_results) <= 1:
+            return layout_results
+        
+        # 复制列表避免修改原数据
+        results = [item.copy() for item in layout_results]
+        need_remove = set()
+        
+        for i in range(len(results)):
+            if i in need_remove:
+                continue
+                
+            for j in range(i + 1, len(results)):
+                if j in need_remove:
+                    continue
+                
+                bbox1 = results[i].get('bbox', [0, 0, 0, 0])
+                bbox2 = results[j].get('bbox', [0, 0, 0, 0])
+                
+                if len(bbox1) < 4 or len(bbox2) < 4:
+                    continue
+                
+                # 计算 IoU
+                iou = LayoutUtils.calculate_iou(bbox1, bbox2)
+                
+                if iou > iou_threshold:
+                    # 高度重叠,保留置信度高的
+                    score1 = results[i].get('confidence', results[i].get('score', 0))
+                    score2 = results[j].get('confidence', results[j].get('score', 0))
+                    
+                    if score1 >= score2:
+                        need_remove.add(j)
+                    else:
+                        need_remove.add(i)
+                        break  # i 被移除,跳出内层循环
+                else:
+                    # 检查包含关系
+                    overlap_ratio = LayoutUtils.calculate_overlap_ratio(bbox1, bbox2)
+                    
+                    if overlap_ratio > overlap_ratio_threshold:
+                        # 小框被大框高度包含
+                        area1 = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])
+                        area2 = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1])
+                        
+                        if area1 <= area2:
+                            small_idx, large_idx = i, j
+                        else:
+                            small_idx, large_idx = j, i
+                        
+                        # 扩展大框的边界
+                        small_bbox = results[small_idx]['bbox']
+                        large_bbox = results[large_idx]['bbox']
+                        results[large_idx]['bbox'] = [
+                            min(small_bbox[0], large_bbox[0]),
+                            min(small_bbox[1], large_bbox[1]),
+                            max(small_bbox[2], large_bbox[2]),
+                            max(small_bbox[3], large_bbox[3])
+                        ]
+                        need_remove.add(small_idx)
+                        
+                        if small_idx == i:
+                            break  # i 被移除,跳出内层循环
+        
+        # 返回去重后的结果
+        return [results[i] for i in range(len(results)) if i not in need_remove]
+    
+    @staticmethod
+    def convert_large_text_to_table(
+        layout_results: List[Dict[str, Any]],
+        image_shape: Tuple[int, int],
+        min_area_ratio: float = 0.25,
+        min_width_ratio: float = 0.4,
+        min_height_ratio: float = 0.3
+    ) -> List[Dict[str, Any]]:
+        """
+        将大面积的文本块转换为表格
+        
+        判断规则:
+        1. 面积占比:占页面面积超过 min_area_ratio(默认25%)
+        2. 尺寸比例:宽度和高度都超过一定比例(避免细长条)
+        3. 不与其他表格重叠:如果已有表格,不转换
+        
+        Args:
+            layout_results: Layout 检测结果列表
+            image_shape: 图像尺寸 (height, width)
+            min_area_ratio: 最小面积占比(0-1),默认0.25(25%)
+            min_width_ratio: 最小宽度占比(0-1),默认0.4(40%)
+            min_height_ratio: 最小高度占比(0-1),默认0.3(30%)
+            
+        Returns:
+            转换后的布局结果列表
+        """
+        if not layout_results:
+            return layout_results
+        
+        img_height, img_width = image_shape
+        img_area = img_height * img_width
+        
+        # 检查是否已有表格
+        has_table = any(
+            item.get('category', '').lower() in ['table', 'table_body']
+            for item in layout_results
+        )
+        
+        # 如果已有表格,不进行转换(避免误判)
+        if has_table:
+            logger.debug("📋 Page already has table elements, skipping text-to-table conversion")
+            return layout_results
+        
+        # 复制列表避免修改原数据
+        results = [item.copy() for item in layout_results]
+        converted_count = 0
+        
+        for item in results:
+            category = item.get('category', '').lower()
+            
+            # 只处理文本类型的元素
+            if category not in ['text', 'ocr_text']:
+                continue
+            
+            bbox = item.get('bbox', [0, 0, 0, 0])
+            if len(bbox) < 4:
+                continue
+            
+            x1, y1, x2, y2 = bbox[:4]
+            width = x2 - x1
+            height = y2 - y1
+            area = width * height
+            
+            # 计算占比
+            area_ratio = area / img_area if img_area > 0 else 0
+            width_ratio = width / img_width if img_width > 0 else 0
+            height_ratio = height / img_height if img_height > 0 else 0
+            
+            # 判断是否满足转换条件
+            if (area_ratio >= min_area_ratio and 
+                width_ratio >= min_width_ratio and 
+                height_ratio >= min_height_ratio):
+                
+                # 转换为表格
+                item['category'] = 'table'
+                item['original_category'] = category  # 保留原始类别
+                converted_count += 1
+                
+                logger.info(
+                    f"🔄 Converted large text block to table: "
+                    f"area={area_ratio:.1%}, size={width_ratio:.1%}×{height_ratio:.1%}, "
+                    f"bbox=[{x1:.0f}, {y1:.0f}, {x2:.0f}, {y2:.0f}]"
+                )
+        
+        if converted_count > 0:
+            logger.info(f"✅ Converted {converted_count} large text block(s) to table(s)")
+        
+        return results
+    
+    @staticmethod
+    def sort_elements_by_reading_order(
+        elements: List[Dict[str, Any]],
+        y_tolerance: float = 15.0
+    ) -> List[Dict[str, Any]]:
+        """
+        根据阅读顺序对元素进行排序,并添加 reading_order 字段
+        
+        排序规则:
+        1. 按Y坐标分行(考虑容差,Y坐标相近的元素视为同一行)
+        2. 同一行内按X坐标从左到右排序
+        3. 行与行之间按Y坐标从上到下排序
+        
+        Args:
+            elements: 元素列表(坐标已转换为原始图片坐标系)
+            y_tolerance: Y坐标容差,在此范围内的元素被视为同一行
+            
+        Returns:
+            排序后的元素列表,每个元素都添加了 reading_order 字段
+        """
+        if not elements:
+            return elements
+        
+        # 为每个元素提取排序用的坐标
+        elements_with_coords = []
+        for elem in elements:
+            bbox = elem.get('bbox', [0, 0, 0, 0])
+            if len(bbox) >= 4:
+                y_top = bbox[1]  # 上边界
+                x_left = bbox[0]  # 左边界
+            else:
+                y_top = 0
+                x_left = 0
+            elements_with_coords.append((elem, y_top, x_left))
+        
+        # 先按Y坐标排序
+        elements_with_coords.sort(key=lambda x: (x[1], x[2]))
+        
+        # 按Y坐标分行
+        rows = []
+        current_row = []
+        current_row_y = None
+        
+        for elem, y_top, x_left in elements_with_coords:
+            if current_row_y is None:
+                # 第一个元素
+                current_row.append((elem, x_left))
+                current_row_y = y_top
+            elif abs(y_top - current_row_y) <= y_tolerance:
+                # 同一行
+                current_row.append((elem, x_left))
+            else:
+                # 新的一行
+                rows.append(current_row)
+                current_row = [(elem, x_left)]
+                current_row_y = y_top
+        
+        # 添加最后一行
+        if current_row:
+            rows.append(current_row)
+        
+        # 每行内按X坐标排序,然后展平
+        sorted_elements = []
+        reading_order = 0
+        
+        for row in rows:
+            # 行内按X坐标排序
+            row.sort(key=lambda x: x[1])
+            for elem, _ in row:
+                # 添加 reading_order 字段
+                elem['reading_order'] = reading_order
+                sorted_elements.append(elem)
+                reading_order += 1
+        
+        logger.debug(f"📖 Elements sorted by reading order: {len(sorted_elements)} elements")
+        return sorted_elements
+
+
+class SpanMatcher:
+    """
+    OCR Span 与 Layout Block 匹配器
+    
+    参考 MinerU 的处理方式:
+    1. 整页 OCR 获取所有 spans
+    2. 将 spans 匹配到对应的 layout blocks
+    
+    注:底层坐标计算方法已统一到 CoordinateUtils
+    """
+    
+    # ==================== 坐标计算方法(委托给 CoordinateUtils)====================
+    
+    @staticmethod
+    def calculate_overlap_area_in_bbox1_ratio(
+        bbox1: List[float], 
+        bbox2: List[float]
+    ) -> float:
+        """计算 bbox1 被 bbox2 覆盖的面积比例 - 委托给 CoordinateUtils"""
+        return CoordinateUtils.calculate_overlap_in_bbox1_ratio(bbox1, bbox2)
+    
+    @staticmethod
+    def poly_to_bbox(poly: List) -> List[float]:
+        """将多边形坐标转换为 bbox 格式 - 委托给 CoordinateUtils"""
+        return CoordinateUtils.poly_to_bbox(poly)
+    
+    # ==================== Span 匹配方法 ====================
+    
+    @staticmethod
+    def match_spans_to_blocks(
+        ocr_spans: List[Dict[str, Any]],
+        layout_blocks: List[Dict[str, Any]],
+        overlap_threshold: float = 0.5
+    ) -> Dict[int, List[Dict[str, Any]]]:
+        """
+        将 OCR spans 匹配到 layout blocks
+        
+        Args:
+            ocr_spans: OCR 识别结果列表,每项包含 'bbox'/'poly', 'text', 'confidence'
+            layout_blocks: Layout 检测结果列表,每项包含 'bbox', 'category'
+            overlap_threshold: 重叠比例阈值,span 被 block 覆盖超过此比例才算匹配
+            
+        Returns:
+            匹配结果字典 {block_index: [matched_spans]}
+        """
+        matched = {i: [] for i in range(len(layout_blocks))}
+        
+        for span in ocr_spans:
+            # 获取 span 的 bbox
+            span_bbox = span.get('bbox', [])
+            if not span_bbox:
+                continue
+            
+            # 转换为标准 bbox 格式
+            span_bbox = SpanMatcher.poly_to_bbox(span_bbox)
+            
+            # 找到最佳匹配的 block
+            best_match_idx = -1
+            best_overlap = 0.0
+            
+            for block_idx, block in enumerate(layout_blocks):
+                block_bbox = block.get('bbox', [0, 0, 0, 0])
+                
+                overlap = SpanMatcher.calculate_overlap_area_in_bbox1_ratio(
+                    span_bbox, block_bbox
+                )
+                
+                if overlap > overlap_threshold and overlap > best_overlap:
+                    best_overlap = overlap
+                    best_match_idx = block_idx
+            
+            if best_match_idx >= 0:
+                # 创建带绝对坐标的 span 副本
+                matched_span = span.copy()
+                matched_span['bbox'] = span_bbox  # 确保是标准 bbox 格式
+                matched[best_match_idx].append(matched_span)
+        
+        return matched
+    
+    @staticmethod
+    def merge_spans_to_text(
+        spans: List[Dict[str, Any]],
+        block_bbox: Optional[List[float]] = None
+    ) -> Tuple[str, List[Dict[str, Any]]]:
+        """
+        将多个 spans 合并为单个文本字符串
+        
+        参考 MinerU 的 span 合并逻辑:
+        1. 按 Y 坐标分行
+        2. 同行内按 X 坐标排序
+        3. 行间添加换行,词间可能添加空格
+        
+        Args:
+            spans: span 列表
+            block_bbox: 所属 block 的 bbox(用于参考)
+            
+        Returns:
+            (merged_text, sorted_spans)
+        """
+        if not spans:
+            return "", []
+        
+        # 计算 spans 的高度中位数(用于判断同行)
+        heights = []
+        for span in spans:
+            bbox = span.get('bbox', [0, 0, 0, 0])
+            if len(bbox) >= 4:
+                h = bbox[3] - bbox[1]
+                if h > 0:
+                    heights.append(h)
+        
+        if heights:
+            median_height = statistics.median(heights)
+            y_tolerance = median_height * 0.5
+        else:
+            y_tolerance = 10
+        
+        # 为每个 span 添加坐标信息用于排序
+        spans_with_coords = []
+        for span in spans:
+            bbox = span.get('bbox', [0, 0, 0, 0])
+            if len(bbox) >= 4:
+                y_center = (bbox[1] + bbox[3]) / 2
+                x_left = bbox[0]
+            else:
+                y_center = 0
+                x_left = 0
+            spans_with_coords.append((span, y_center, x_left))
+        
+        # 按 Y 坐标分行
+        spans_with_coords.sort(key=lambda x: (x[1], x[2]))
+        
+        lines = []
+        current_line = []
+        current_line_y = None
+        
+        for span, y_center, x_left in spans_with_coords:
+            if current_line_y is None:
+                current_line.append((span, x_left))
+                current_line_y = y_center
+            elif abs(y_center - current_line_y) <= y_tolerance:
+                current_line.append((span, x_left))
+            else:
+                lines.append(current_line)
+                current_line = [(span, x_left)]
+                current_line_y = y_center
+        
+        if current_line:
+            lines.append(current_line)
+        
+        # 合并文本
+        text_parts = []
+        sorted_spans = []
+        
+        for line in lines:
+            # 行内按 X 坐标排序
+            line.sort(key=lambda x: x[1])
+            
+            line_texts = []
+            for span, _ in line:
+                text = span.get('text', '')
+                if text:
+                    line_texts.append(text)
+                sorted_spans.append(span)
+            
+            if line_texts:
+                text_parts.append(' '.join(line_texts))
+        
+        merged_text = '\n'.join(text_parts)
+        
+        return merged_text, sorted_spans
+    
+    @staticmethod
+    def remove_duplicate_spans(
+        spans: List[Dict[str, Any]],
+        iou_threshold: float = 0.9
+    ) -> List[Dict[str, Any]]:
+        """
+        移除重复的 spans(高 IoU 重叠)
+        
+        Args:
+            spans: span 列表
+            iou_threshold: IoU 阈值
+            
+        Returns:
+            去重后的 spans
+        """
+        if len(spans) <= 1:
+            return spans
+        
+        result = []
+        removed = set()
+        
+        for i, span1 in enumerate(spans):
+            if i in removed:
+                continue
+            
+            bbox1 = span1.get('bbox', [0, 0, 0, 0])
+            bbox1 = CoordinateUtils.poly_to_bbox(bbox1)
+            
+            for j in range(i + 1, len(spans)):
+                if j in removed:
+                    continue
+                
+                bbox2 = spans[j].get('bbox', [0, 0, 0, 0])
+                bbox2 = CoordinateUtils.poly_to_bbox(bbox2)
+                
+                iou = CoordinateUtils.calculate_iou(bbox1, bbox2)
+                
+                if iou > iou_threshold:
+                    # 保留置信度高的
+                    score1 = span1.get('confidence', span1.get('score', 0))
+                    score2 = spans[j].get('confidence', spans[j].get('score', 0))
+                    
+                    if score1 >= score2:
+                        removed.add(j)
+                    else:
+                        removed.add(i)
+                        break
+            
+            if i not in removed:
+                result.append(span1)
+        
+        return result
+

+ 98 - 0
ocr_tools/universal_doc_parser/core/model_factory.py

@@ -0,0 +1,98 @@
+"""模型工厂 - 根据配置创建模型实例"""
+from typing import Dict, Any, Optional
+from models.adapters import BasePreprocessor, BaseLayoutDetector, BaseVLRecognizer, BaseOCRRecognizer
+
+class ModelFactory:
+    """模型工厂类,负责创建和管理各种模型适配器"""
+    
+    _adapters_registry = {}
+    
+    @classmethod
+    def register_adapter(cls, adapter_type: str, module_name: str, class_name: str):
+        """注册适配器"""
+        cls._adapters_registry[adapter_type] = {
+            'module': module_name,
+            'class': class_name
+        }
+    
+    @classmethod
+    def create_preprocessor(cls, config: Dict[str, Any]) -> BasePreprocessor:
+        """创建预处理器"""
+        module_name = config.get('module', 'mineru')
+        
+        if module_name == 'mineru':
+            from models.adapters import MinerUPreprocessor
+            preprocessor = MinerUPreprocessor(config)
+        else:
+            raise ValueError(f"Unknown preprocessor module: {module_name}")
+            
+        preprocessor.initialize()
+        return preprocessor
+    
+    @classmethod
+    def create_layout_detector(cls, config: Dict[str, Any]) -> BaseLayoutDetector:
+        # 根据配置创建检测器
+        module_name = config.get('module', 'mineru')
+        if module_name == 'paddle':
+            from models.adapters import PaddleLayoutDetector
+            detector = PaddleLayoutDetector(config)
+        elif module_name == 'docling':
+            from models.adapters import DoclingLayoutDetector
+            detector = DoclingLayoutDetector(config)
+        elif module_name == 'mineru':
+            from models.adapters import MinerULayoutDetector
+            detector = MinerULayoutDetector(config)
+        else:
+            raise ValueError(f"Unknown layout detector module: {module_name}")
+        try:
+            detector.initialize()
+            return detector
+        except Exception as e:
+            print(f"Failed to initialize layout detector: {e}")
+            raise
+    
+    @classmethod
+    def create_vl_recognizer(cls, config: Dict[str, Any]) -> BaseVLRecognizer:
+        """创建VL识别器"""
+        module_name = config.get('module', 'mineru')
+        
+        if module_name == 'paddle':
+            from models.adapters import PaddleVLRecognizer
+            recognizer = PaddleVLRecognizer(config)
+        elif module_name == 'mineru':
+            from models.adapters import MinerUVLRecognizer
+            recognizer = MinerUVLRecognizer(config)
+        else:
+            raise ValueError(f"Unknown VL recognizer module: {module_name}")
+            
+        recognizer.initialize()
+        return recognizer
+    
+    @classmethod
+    def create_ocr_recognizer(cls, config: Dict[str, Any]) -> BaseOCRRecognizer:
+        """创建OCR识别器"""
+        module_name = config.get('module', 'mineru')
+        
+        if module_name == 'mineru':
+            from models.adapters import MinerUOCRRecognizer
+            recognizer = MinerUOCRRecognizer(config)
+        else:
+            raise ValueError(f"Unknown OCR recognizer module: {module_name}")
+            
+        recognizer.initialize()
+        return recognizer
+    
+    @classmethod
+    def cleanup_all(cls):
+        """清理所有模型资源"""
+        # 在实际应用中,可以维护一个活跃模型列表进行清理
+        pass
+
+# 注册默认适配器
+ModelFactory.register_adapter('preprocessor', 'mineru_adapter', 'MinerUPreprocessor')
+ModelFactory.register_adapter('layout_detector', 'mineru_adapter', 'MinerULayoutDetector')
+ModelFactory.register_adapter('vl_recognizer', 'mineru_adapter', 'MinerUVLRecognizer')
+ModelFactory.register_adapter('ocr_recognizer', 'mineru_adapter', 'MinerUOCRRecognizer')
+
+ModelFactory.register_adapter('layout_detector', 'paddle_adapter', 'PaddleLayoutDetector')
+ModelFactory.register_adapter('layout_detector', 'docling_adapter', 'DoclingLayoutDetector')

+ 22 - 0
ocr_tools/universal_doc_parser/core/pdf_utils.py

@@ -0,0 +1,22 @@
+"""
+PDF处理工具模块
+
+此模块已迁移到使用 ocr_utils.PDFUtils,保留此文件仅用于向后兼容。
+新代码应直接使用 ocr_utils.PDFUtils。
+"""
+import sys
+from pathlib import Path
+
+# 添加 ocr_platform 根目录到 Python 路径(用于导入 ocr_utils)
+ocr_platform_root = Path(__file__).parents[3]  # core -> universal_doc_parser -> ocr_tools -> ocr_platform -> repository.git
+if str(ocr_platform_root) not in sys.path:
+    sys.path.insert(0, str(ocr_platform_root))
+
+# 从 ocr_utils 导入 PDFUtils
+try:
+    from ocr_utils import PDFUtils
+except ImportError:
+    raise ImportError("ocr_utils.PDFUtils is required. Please ensure ocr_utils is available.")
+
+# 为了向后兼容,将 PDFUtils 导出(实际上就是 ocr_utils.PDFUtils)
+__all__ = ['PDFUtils']

+ 686 - 0
ocr_tools/universal_doc_parser/core/pipeline_manager_v2.py

@@ -0,0 +1,686 @@
+"""
+增强版文档处理流水线 v2
+
+按照优化后的流程实现:
+1. PDF分类 → 扫描件/数字原生PDF
+2. 页面方向识别(仅扫描件)
+3. Layout检测
+4. 并行处理:
+   - 文本区域:OCR检测+识别 / PDF字符提取
+   - 表格区域:OCR检测(坐标) + VLM结构识别 → 坐标匹配
+5. 合并结果 → 跨页表格合并
+
+模块结构:
+- coordinate_utils.py: 坐标转换工具
+- layout_utils.py: 布局处理工具
+- pdf_utils.py: PDF处理工具
+- element_processors.py: 元素处理器
+"""
+import sys
+from typing import Dict, List, Any, Optional
+from pathlib import Path
+import numpy as np
+from loguru import logger
+
+# 添加 ocr_platform 根目录到 Python 路径(用于导入 ocr_utils)
+ocr_platform_root = Path(__file__).parents[3]  # core -> universal_doc_parser -> ocr_tools -> ocr_platform -> repository.git
+if str(ocr_platform_root) not in sys.path:
+    sys.path.insert(0, str(ocr_platform_root))
+
+# 添加 universal_doc_parser 根目录到 Python 路径(用于相对导入)
+module_root = Path(__file__).parents[0]  # core -> universal_doc_parser
+if str(module_root) not in sys.path:
+    sys.path.insert(0, str(module_root))
+
+# 导入本地模块
+try:
+    from .model_factory import ModelFactory
+    from .config_manager import ConfigManager
+    from .coordinate_utils import CoordinateUtils
+    from .layout_utils import LayoutUtils, SpanMatcher
+    from .pdf_utils import PDFUtils
+    from .element_processors import ElementProcessors
+except ImportError:
+    from model_factory import ModelFactory
+    from config_manager import ConfigManager
+    from coordinate_utils import CoordinateUtils
+    from layout_utils import LayoutUtils, SpanMatcher
+    from pdf_utils import PDFUtils
+    from element_processors import ElementProcessors
+
+# 导入 merger 组件
+try:
+    from merger import TableCellMatcher, TextMatcher
+    MERGER_AVAILABLE = True
+except ImportError:
+    MERGER_AVAILABLE = False
+    TableCellMatcher = None
+    TextMatcher = None
+
+
+class EnhancedDocPipeline:
+    """增强版文档处理流水线"""
+    
+    # ==================== 元素类别定义 ====================
+    # 参考 MinerU CategoryId 定义
+    
+    # 文本类元素:需要OCR识别或PDF文本提取
+    TEXT_CATEGORIES = [
+        'text', 'title', 'ocr_text', 'low_score_text',  # 基础文本
+        'header', 'footer', 'page_number',               # 页面元素
+        'ref_text', 'aside_text', 'page_footnote',       # 辅助文本
+    ]
+    
+    # 表格相关元素
+    TABLE_BODY_CATEGORIES = ['table', 'table_body']
+    TABLE_TEXT_CATEGORIES = ['table_caption', 'table_footnote']
+    
+    # 图片相关元素
+    IMAGE_BODY_CATEGORIES = ['image', 'image_body', 'figure']
+    IMAGE_TEXT_CATEGORIES = ['image_caption', 'image_footnote']
+    
+    # 公式类元素
+    EQUATION_CATEGORIES = [
+        'interline_equation', 'inline_equation', 'equation',
+        'interline_equation_yolo', 'interline_equation_number'
+    ]
+    
+    # 丢弃类元素(水印、装饰等)
+    DISCARD_CATEGORIES = ['abandon', 'discarded']
+    
+    # 代码类元素
+    CODE_CATEGORIES = ['code', 'code_body', 'code_caption', 'algorithm']
+    
+    # ==================== 初始化 ====================
+    
+    def __init__(self, config_path: str):
+        """
+        初始化流水线
+        
+        Args:
+            config_path: 配置文件路径
+        """
+        self.config = ConfigManager.load_config(config_path)
+        self.scene_name = self.config.get('scene_name', 'unknown')
+        self.debug_mode = self.config.get('output', {}).get('debug_mode', False)
+        
+        # 初始化组件
+        self._init_components()
+        
+        # 初始化元素处理器
+        self._init_element_processors()
+        
+        logger.info(f"✅ Pipeline initialized for scene: {self.scene_name}")
+    
+    def _init_components(self):
+        """初始化处理组件"""
+        try:
+            # 1. 预处理器(方向分类)
+            self.preprocessor = ModelFactory.create_preprocessor(
+                self.config['preprocessor']
+            )
+            
+            # 2. 版式检测器
+            self.layout_detector = ModelFactory.create_layout_detector(
+                self.config['layout_detection']
+            )
+            
+            # 3. VL识别器(表格、公式)
+            if self.config.get('vl_recognition', {}) != {}:
+                self.vl_recognizer = ModelFactory.create_vl_recognizer(
+                    self.config['vl_recognition']
+                )
+            else:
+                self.vl_recognizer = None
+            
+            # 4. OCR识别器
+            self.ocr_recognizer = ModelFactory.create_ocr_recognizer(
+                self.config['ocr_recognition']
+            )
+
+            # 5. 有线表格识别器(可选)
+            self.table_config = self.config.get('table_recognition_wired', {})
+            self.wired_table_recognizer = None
+            if self.table_config.get('use_wired_unet', False):
+                try:
+                    from models.adapters.mineru_wired_table import MinerUWiredTableRecognizer
+                    ocr_engine = getattr(self.ocr_recognizer, 'ocr_model', None)
+                    self.wired_table_recognizer = MinerUWiredTableRecognizer(
+                        self.table_config, ocr_engine
+                    )
+                    logger.info("✅ Wired UNet table recognizer initialized")
+                except Exception as e:
+                    logger.warning(f"⚠️ Wired UNet table recognizer init failed: {e}")
+            
+        except Exception as e:
+            logger.error(f"❌ Failed to initialize pipeline: {e}")
+            raise
+    
+    def _init_element_processors(self):
+        """初始化元素处理器"""
+        # 初始化 merger 组件
+        table_cell_matcher = None
+        if MERGER_AVAILABLE and TextMatcher is not None and TableCellMatcher is not None:
+            text_matcher = TextMatcher()
+            table_cell_matcher = TableCellMatcher(
+                text_matcher=text_matcher,
+                x_tolerance=3,
+                y_tolerance=10
+            )
+        else:
+            logger.warning("⚠️ Merger components not available, cell coordinate matching disabled")
+        
+        # 创建元素处理器
+        self.element_processors = ElementProcessors(
+            preprocessor=self.preprocessor,
+            ocr_recognizer=self.ocr_recognizer,
+            vl_recognizer=self.vl_recognizer,
+            table_cell_matcher=table_cell_matcher,
+            wired_table_recognizer=getattr(self, 'wired_table_recognizer', None),
+        )
+    
+    # ==================== 主处理流程 ====================
+    
+    def process_document(
+        self, 
+        document_path: str,
+        page_range: Optional[str] = None
+    ) -> Dict[str, Any]:
+        """
+        处理文档主流程
+        
+        Args:
+            document_path: 文档路径(PDF、图片或目录)
+            page_range: 页面范围字符串,如 "1-5,7,9-12"
+                       - PDF:按页码(从1开始)
+                       - 图片目录:按文件名排序后的位置(从1开始)
+            
+        Returns:
+            处理结果字典
+        """
+        doc_path = Path(document_path)
+        
+        results = {
+            'scene': self.scene_name,
+            'document_path': str(doc_path),
+            'pages': [],
+            'metadata': {},
+            'debug_info': {} if self.debug_mode else None
+        }
+        
+        try:
+            # 1. 加载文档并分类
+            dpi = self.config.get('input', {}).get('dpi', 200)
+            images, pdf_type, pdf_doc = PDFUtils.load_and_classify_document(
+                doc_path, dpi=dpi, page_range=page_range
+            )
+            results['metadata']['pdf_type'] = pdf_type
+            results['metadata']['page_count'] = len(images)
+            results['metadata']['page_range'] = page_range
+            
+            logger.info(f"📄 Loaded {len(images)} pages, type: {pdf_type}")
+            
+            # 2. 处理每一页
+            for idx, image_dict in enumerate(images):
+                # 使用原始页码索引(支持页面范围过滤)
+                page_idx = image_dict.get('page_idx', idx)
+                page_name = image_dict.get('page_name', f'page_{page_idx + 1:03d}')
+                logger.info(f"🔍 Processing page {idx + 1}/{len(images)} (original index: {page_idx})")
+                
+                page_result = self._process_single_page(
+                    image_dict=image_dict,
+                    page_idx=page_idx,
+                    pdf_type=pdf_type,
+                    pdf_doc=pdf_doc,
+                    page_name=page_name
+                )
+                results['pages'].append(page_result)
+            
+            # 3. 跨页表格合并
+            results = PDFUtils.merge_cross_page_tables(results)
+            
+            # 4. 关闭 PDF 文档
+            if pdf_doc is not None:
+                pdf_doc.close()
+            
+            logger.info(f"✅ Document processing completed")
+            return results
+            
+        except Exception as e:
+            logger.error(f"❌ Document processing failed: {e}")
+            raise
+    
+    def _process_single_page(
+        self,
+        image_dict: Dict[str, Any],
+        page_idx: int,
+        pdf_type: str,
+        pdf_doc: Optional[Any] = None,
+        page_name: Optional[str] = None
+    ) -> Dict[str, Any]:
+        """
+        处理单页文档
+        
+        流程:
+        1. 扫描件:页面方向识别 → Layout检测
+        2. 数字PDF:直接Layout检测
+        3. 并行处理文本区域和表格区域
+        4. 合并结果
+        5. 坐标转换回原始图片坐标系
+        """
+        pil_image = image_dict['img_pil']
+        scale = image_dict.get('scale', 1.0)
+        original_image = np.array(pil_image)
+        
+        # 页面名称(用于输出文件命名)
+        if page_name is None:
+            page_name = f"page_{page_idx + 1:03d}"
+        
+        page_result = {
+            'page_idx': page_idx,
+            'page_name': page_name,  # 用于输出文件命名
+            'elements': [],
+            'image_shape': original_image.shape,
+            'original_image': original_image,
+            'angle': 0,
+            'scale': scale,
+            'pdf_type': pdf_type
+        }
+        
+        # 用于检测的图片(可能被旋转)
+        detection_image = original_image.copy()
+        rotate_angle = 0
+        
+        # 1. 页面方向识别(仅扫描件)
+        if pdf_type == 'ocr':
+            try:
+                detection_image, rotate_angle = self.preprocessor.process(original_image)
+                page_result['angle'] = rotate_angle
+                
+                if rotate_angle != 0:
+                    logger.info(f"📐 Page {page_idx}: rotated {rotate_angle}° for detection")
+            except Exception as e:
+                logger.warning(f"⚠️ Orientation detection failed: {e}")
+        
+        # 2. Layout检测
+        try:
+            layout_results = self.layout_detector.detect(detection_image)
+            logger.info(f"📋 Page {page_idx}: detected {len(layout_results)} elements (before dedup)")
+        except Exception as e:
+            logger.error(f"❌ Layout detection failed: {e}")
+            layout_results = []
+        
+        # 2.5 处理重叠框
+        if layout_results:
+            original_count = len(layout_results)
+            layout_results = LayoutUtils.remove_overlapping_boxes(layout_results)
+            removed_count = original_count - len(layout_results)
+            if removed_count > 0:
+                logger.info(f"🔄 Page {page_idx}: removed {removed_count} overlapping boxes")
+        
+        # 2.6 将大面积文本块转换为表格(后处理)
+        if layout_results:
+            convert_large_text = self.config.get('layout', {}).get('convert_large_text_to_table', False)
+            if convert_large_text:
+                # 获取图像尺寸 (height, width)
+                img_shape = detection_image.shape
+                if len(img_shape) >= 2:
+                    image_shape = (int(img_shape[0]), int(img_shape[1]))
+                else:
+                    image_shape = (0, 0)
+                
+                layout_results = LayoutUtils.convert_large_text_to_table(
+                    layout_results,
+                    image_shape,
+                    min_area_ratio=self.config.get('layout', {}).get('min_text_area_ratio', 0.25),
+                    min_width_ratio=self.config.get('layout', {}).get('min_text_width_ratio', 0.4),
+                    min_height_ratio=self.config.get('layout', {}).get('min_text_height_ratio', 0.3)
+                )
+        
+        page_result['layout_raw'] = layout_results
+        
+        # 3. 整页 OCR 获取所有 text spans(关键改进)
+        all_ocr_spans = []
+        try:
+            all_ocr_spans = self.ocr_recognizer.recognize_text(detection_image)
+            # 去除重复 spans
+            all_ocr_spans = SpanMatcher.remove_duplicate_spans(all_ocr_spans)
+            # 按坐标排序(从上到下,从左到右),方便人工检查缺失字符
+            all_ocr_spans = self._sort_spans_by_position(all_ocr_spans)
+            logger.info(f"📝 Page {page_idx}: OCR detected {len(all_ocr_spans)} text spans")
+        except Exception as e:
+            logger.warning(f"⚠️ Full-page OCR failed: {e}")
+        
+        # 4. 将 OCR spans 匹配到 layout blocks
+        matched_spans = SpanMatcher.match_spans_to_blocks(
+            all_ocr_spans, layout_results, overlap_threshold=0.5
+        )
+        
+        # 5. 分类元素
+        classified_elements = self._classify_elements(layout_results, page_idx)
+        
+        # 6. 处理各类元素(传入匹配的 spans)
+        processed_elements, discarded_elements = self._process_all_elements(
+            detection_image=detection_image,
+            classified_elements=classified_elements,
+            pdf_type=pdf_type,
+            pdf_doc=pdf_doc,
+            page_idx=page_idx,
+            scale=scale,
+            matched_spans=matched_spans,
+            layout_results=layout_results
+        )
+        
+        # 7. 按阅读顺序排序
+        sorted_elements = LayoutUtils.sort_elements_by_reading_order(processed_elements)
+        sorted_discarded = LayoutUtils.sort_elements_by_reading_order(discarded_elements)
+        
+        # 8. 坐标转换回原始图片坐标系
+        if rotate_angle != 0:
+            sorted_elements = [
+                CoordinateUtils.transform_coords_to_original(
+                    element, rotate_angle, detection_image.shape, original_image.shape
+                ) for element in sorted_elements
+            ]
+            sorted_discarded = [
+                CoordinateUtils.transform_coords_to_original(
+                    element, rotate_angle, detection_image.shape, original_image.shape
+                ) for element in sorted_discarded
+            ]
+            logger.debug(f"📐 Coordinates transformed back to original image")
+        
+        page_result['elements'] = sorted_elements
+        page_result['discarded_blocks'] = sorted_discarded
+        return page_result
+    
+    # ==================== OCR Spans 排序 ====================
+    
+    @staticmethod
+    def _sort_spans_by_position(spans: List[Dict[str, Any]], row_tolerance: float = 10.0) -> List[Dict[str, Any]]:
+        """
+        按照坐标对OCR spans排序(从上到下,从左到右)
+        
+        Args:
+            spans: OCR检测的text spans列表
+            row_tolerance: 同一行的y坐标容差(像素)
+            
+        Returns:
+            排序后的spans列表
+        """
+        if not spans:
+            return spans
+        
+        def get_sort_key(span: Dict[str, Any]) -> tuple:
+            """
+            生成排序键
+            
+            Returns:
+                (row_group, x1) - row_group用于分组同一行,x1用于行内从左到右排序
+            """
+            bbox = span.get('bbox', [[0, 0], [0, 0], [0, 0], [0, 0]])
+            
+            # 获取左上角坐标
+            if isinstance(bbox[0], (list, tuple)):
+                # 四点格式 [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
+                x1, y1 = bbox[0][0], bbox[0][1]
+            else:
+                # [x1, y1, x2, y2] 格式
+                x1, y1 = bbox[0], bbox[1]
+            
+            # 将y坐标按row_tolerance分组(同一行)
+            row_group = int(y1 / row_tolerance)
+            
+            return (row_group, x1)
+        
+        # 执行排序
+        sorted_spans = sorted(spans, key=get_sort_key)
+        return sorted_spans
+    
+    # ==================== 元素分类与处理 ====================
+    
+    def _classify_elements(
+        self,
+        layout_results: List[Dict[str, Any]],
+        page_idx: int
+    ) -> Dict[str, List[Dict[str, Any]]]:
+        """
+        将布局结果分类为不同类型的元素
+        
+        Args:
+            layout_results: 布局检测结果
+            page_idx: 页码
+            
+        Returns:
+            分类后的元素字典
+        """
+        classified = {
+            'text': [],
+            'table_body': [],
+            'table_text': [],
+            'image_body': [],
+            'image_text': [],
+            'equation': [],
+            'code': [],
+            'discard': []
+        }
+        
+        for item in layout_results:
+            category = item.get('category', '').lower()
+            
+            if category in self.TEXT_CATEGORIES:
+                classified['text'].append(item)
+            elif category in self.TABLE_BODY_CATEGORIES:
+                classified['table_body'].append(item)
+            elif category in self.TABLE_TEXT_CATEGORIES:
+                classified['table_text'].append(item)
+            elif category in self.IMAGE_BODY_CATEGORIES:
+                classified['image_body'].append(item)
+            elif category in self.IMAGE_TEXT_CATEGORIES:
+                classified['image_text'].append(item)
+            elif category in self.EQUATION_CATEGORIES:
+                classified['equation'].append(item)
+            elif category in self.CODE_CATEGORIES:
+                classified['code'].append(item)
+            elif category in self.DISCARD_CATEGORIES:
+                classified['discard'].append(item)
+            else:
+                logger.debug(f"Unknown category '{category}', treating as text")
+                classified['text'].append(item)
+        
+        logger.info(f"📊 Page {page_idx} elements: "
+                   f"text={len(classified['text'])}, "
+                   f"table={len(classified['table_body'])}, "
+                   f"table_text={len(classified['table_text'])}, "
+                   f"image={len(classified['image_body'])}, "
+                   f"image_text={len(classified['image_text'])}, "
+                   f"equation={len(classified['equation'])}, "
+                   f"code={len(classified['code'])}, "
+                   f"discard={len(classified['discard'])}")
+        
+        return classified
+    
+    def _process_all_elements(
+        self,
+        detection_image: np.ndarray,
+        classified_elements: Dict[str, List[Dict[str, Any]]],
+        pdf_type: str,
+        pdf_doc: Optional[Any],
+        page_idx: int,
+        scale: float,
+        matched_spans: Optional[Dict[int, List[Dict[str, Any]]]] = None,
+        layout_results: Optional[List[Dict[str, Any]]] = None
+    ) -> tuple:
+        """
+        处理所有分类后的元素
+        
+        Args:
+            detection_image: 检测用图像
+            classified_elements: 分类后的元素
+            pdf_type: PDF类型
+            pdf_doc: PDF文档对象
+            page_idx: 页码
+            scale: 缩放比例
+            matched_spans: 匹配的 OCR spans {block_idx: [spans]}
+            layout_results: 原始 layout 检测结果(用于索引匹配)
+            
+        Returns:
+            (processed_elements, discarded_elements)
+        """
+        processed_elements = []
+        discarded_elements = []
+        
+        # 构建 layout item 到 block index 的映射
+        item_to_block_idx = {}
+        if layout_results:
+            for idx, item in enumerate(layout_results):
+                # 使用 bbox 作为唯一标识(转为元组方便哈希)
+                bbox_key = tuple(item.get('bbox', [0, 0, 0, 0]))
+                item_to_block_idx[bbox_key] = idx
+        
+        def get_matched_spans_for_item(item: Dict[str, Any]) -> List[Dict[str, Any]]:
+            """获取某个 layout item 匹配的 spans"""
+            if not matched_spans or not layout_results:
+                return []
+            bbox_key = tuple[Any, ...](item.get('bbox', [0, 0, 0, 0]))
+            block_idx = item_to_block_idx.get(bbox_key, -1)
+            return matched_spans.get(block_idx, [])
+        
+        # 处理纯文本区域
+        for item in classified_elements['text']:
+            try:
+                spans = get_matched_spans_for_item(item)
+                element = self.element_processors.process_text_element(
+                    detection_image, item, pdf_type, pdf_doc, page_idx, scale,
+                    pre_matched_spans=spans
+                )
+                processed_elements.append(element)
+            except Exception as e:
+                logger.warning(f"⚠️ Text processing failed: {e}")
+                processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
+        
+        # 处理表格标题/脚注
+        for item in classified_elements['table_text']:
+            try:
+                spans = get_matched_spans_for_item(item)
+                element = self.element_processors.process_text_element(
+                    detection_image, item, pdf_type, pdf_doc, page_idx, scale,
+                    pre_matched_spans=spans
+                )
+                processed_elements.append(element)
+            except Exception as e:
+                logger.warning(f"⚠️ Table text processing failed: {e}")
+                processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
+        
+        # 处理图片标题/脚注
+        for item in classified_elements['image_text']:
+            try:
+                spans = get_matched_spans_for_item(item)
+                element = self.element_processors.process_text_element(
+                    detection_image, item, pdf_type, pdf_doc, page_idx, scale,
+                    pre_matched_spans=spans
+                )
+                processed_elements.append(element)
+            except Exception as e:
+                logger.warning(f"⚠️ Image text processing failed: {e}")
+                processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
+        
+        # 处理表格主体
+        for item in classified_elements['table_body']:
+            try:
+                spans = get_matched_spans_for_item(item)
+                
+                # 🔑 关键:根据配置选择表格识别路径
+                use_wired_unet = self.table_config.get('use_wired_unet', False)
+                
+                if use_wired_unet and self.wired_table_recognizer:
+                    # 有线表格路径:UNet 识别
+                    logger.info(f"🔷 Using wired UNet table recognition (configured)")
+                    element = self.element_processors.process_table_element_wired(
+                        detection_image, item, scale, pre_matched_spans=spans
+                    )
+                    
+                    # 如果有线识别失败(返回空 HTML),fallback 到 VLM
+                    if not element['content'].get('html') and not element['content'].get('cells'):
+                        raise ValueError(f"Wired UNet table recognition failed, element: {item}")
+                else:
+                    # VLM 无线表格路径(默认)
+                    logger.info(f"🔷 Using VLM table recognition (configured)")
+                    element = self.element_processors.process_table_element_vlm(
+                        detection_image, item, scale, pre_matched_spans=spans
+                    )
+                
+                processed_elements.append(element)
+            except Exception as e:
+                logger.warning(f"⚠️ Table processing failed: {e}")
+                processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
+        
+        # 处理公式元素
+        for item in classified_elements['equation']:
+            try:
+                element = self.element_processors.process_equation_element(
+                    detection_image, item
+                )
+                processed_elements.append(element)
+            except Exception as e:
+                logger.warning(f"⚠️ Equation processing failed: {e}")
+                processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
+        
+        # 处理图片主体
+        for item in classified_elements['image_body']:
+            try:
+                element = self.element_processors.process_image_element(
+                    detection_image, item
+                )
+                processed_elements.append(element)
+            except Exception as e:
+                logger.warning(f"⚠️ Image processing failed: {e}")
+                processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
+        
+        # 处理代码元素
+        for item in classified_elements['code']:
+            try:
+                spans = get_matched_spans_for_item(item)
+                element = self.element_processors.process_code_element(
+                    detection_image, item, pdf_type, pdf_doc, page_idx, scale,
+                    pre_matched_spans=spans
+                )
+                processed_elements.append(element)
+            except Exception as e:
+                logger.warning(f"⚠️ Code processing failed: {e}")
+                processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
+        
+        # 处理丢弃元素
+        for item in classified_elements['discard']:
+            try:
+                spans = get_matched_spans_for_item(item)
+                element = self.element_processors.process_discard_element(
+                    detection_image, item, pre_matched_spans=spans
+                )
+                discarded_elements.append(element)
+            except Exception as e:
+                logger.debug(f"Discard element processing failed: {e}")
+        
+        return processed_elements, discarded_elements
+    
+    # ==================== 资源管理 ====================
+    
+    def cleanup(self):
+        """清理资源"""
+        try:
+            if hasattr(self, 'preprocessor'):
+                self.preprocessor.cleanup()
+            if hasattr(self, 'layout_detector'):
+                self.layout_detector.cleanup()
+            if hasattr(self, 'vl_recognizer'):
+                self.vl_recognizer.cleanup()
+            if hasattr(self, 'ocr_recognizer'):
+                self.ocr_recognizer.cleanup()
+            logger.info("✅ Pipeline cleanup completed")
+        except Exception as e:
+            logger.warning(f"⚠️ Cleanup failed: {e}")
+    
+    def __enter__(self):
+        return self
+    
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.cleanup()

+ 315 - 0
ocr_tools/universal_doc_parser/core/pipeline_manager_v2_streaming.py

@@ -0,0 +1,315 @@
+"""
+流式处理版本的文档处理流水线
+
+优化点:
+1. 按页处理:处理一页,立即保存该页的JSON和Markdown
+2. 内存优化:每页处理完成后立即释放该页的图像和OCR数据
+3. 输出一致:使用 OutputFormatterV2 的方法,确保输出格式与批量模式完全一致
+
+内存优化:
+- 每页处理完成后立即保存并释放内存
+- 只保留页面结果用于跨页表格合并
+- 最后统一生成完整输出(middle.json、完整Markdown等)
+"""
+import sys
+from typing import Dict, List, Any, Optional
+from pathlib import Path
+from loguru import logger
+
+# 添加 ocr_platform 根目录到 Python 路径(用于导入 ocr_utils)
+ocr_platform_root = Path(__file__).parents[3]  # core -> universal_doc_parser -> ocr_tools -> ocr_platform -> repository.git
+if str(ocr_platform_root) not in sys.path:
+    sys.path.insert(0, str(ocr_platform_root))
+
+# 添加 universal_doc_parser 根目录到 Python 路径(用于相对导入)
+module_root = Path(__file__).parents[0]  # core -> universal_doc_parser
+if str(module_root) not in sys.path:
+    sys.path.insert(0, str(module_root))
+
+# 导入基础类(复用现有实现)
+from .pipeline_manager_v2 import EnhancedDocPipeline
+from .pdf_utils import PDFUtils
+
+# 从 ocr_utils 导入输出格式化器
+try:
+    from ocr_utils import (
+        JSONFormatters,
+        VisualizationUtils,
+        MarkdownGenerator,
+        OutputFormatterV2
+    )
+except ImportError:
+    # 降级:尝试从 utils 导入(向后兼容)
+    try:
+        from ..utils import JSONFormatters, VisualizationUtils, MarkdownGenerator, OutputFormatterV2
+    except ImportError:
+        from universal_doc_parser.utils import JSONFormatters, VisualizationUtils, MarkdownGenerator, OutputFormatterV2
+
+
+class StreamingDocPipeline(EnhancedDocPipeline):
+    """
+    流式处理版本的文档处理流水线
+    
+    特点:
+    - 按页处理,立即保存,释放内存
+    - 流式生成Markdown(边处理边写入)
+    - 最后统一生成完整Markdown(跨页表格合并)
+    """
+    
+    def __init__(self, config_path: str, output_dir: str):
+        """
+        初始化流式处理流水线
+        
+        Args:
+            config_path: 配置文件路径
+            output_dir: 输出目录(用于立即保存每页结果)
+        """
+        super().__init__(config_path)
+        self.output_dir = Path(output_dir)
+        self.output_dir.mkdir(parents=True, exist_ok=True)
+        
+        # 使用 OutputFormatterV2 进行格式化(保持输出一致)
+        self.formatter = OutputFormatterV2(str(self.output_dir))
+        
+        # 存储已处理的页面(用于跨页表格合并和最终生成)
+        self.processed_pages = []
+        
+        # 存储页面元数据(用于跨页表格合并)
+        self.page_metadata = []
+        
+        # 文档元数据
+        self.doc_name = None
+        self.is_pdf = False
+        self.total_pages = 0
+    
+    def process_document_streaming(
+        self,
+        document_path: str,
+        page_range: Optional[str] = None,
+        output_config: Optional[Dict[str, Any]] = None
+    ) -> Dict[str, Any]:
+        """
+        流式处理文档主流程
+        
+        Args:
+            document_path: 文档路径(PDF、图片或目录)
+            page_range: 页面范围字符串
+            output_config: 输出配置
+            
+        Returns:
+            处理结果摘要
+        """
+        if output_config is None:
+            output_config = {}
+        
+        doc_path = Path(document_path)
+        doc_name = doc_path.stem
+        
+        # 判断输入类型
+        is_pdf = doc_path.suffix.lower() == '.pdf'
+        
+        # 初始化结果摘要
+        results_summary = {
+            'scene': self.scene_name,
+            'document_path': str(doc_path),
+            'total_pages': 0,
+            'processed_pages': [],
+            'metadata': {},
+            'output_paths': {
+                'json_pages': [],
+                'images': [],
+            }
+        }
+        
+        try:
+            # 1. 加载文档并分类
+            dpi = self.config.get('input', {}).get('dpi', 200)
+            images, pdf_type, pdf_doc = PDFUtils.load_and_classify_document(
+                doc_path, dpi=dpi, page_range=page_range
+            )
+            
+            results_summary['metadata']['pdf_type'] = pdf_type
+            results_summary['metadata']['page_count'] = len(images)
+            results_summary['metadata']['page_range'] = page_range
+            results_summary['total_pages'] = len(images)
+            
+            # 保存文档元数据
+            self.doc_name = doc_name
+            self.is_pdf = is_pdf
+            self.total_pages = len(images)
+            
+            logger.info(f"📄 Loaded {len(images)} pages, type: {pdf_type}")
+            
+            # 创建images子目录
+            images_dir = self.output_dir / 'images'
+            images_dir.mkdir(exist_ok=True)
+            
+            # 3. 按页处理(流式)
+            for idx, image_dict in enumerate(images):
+                page_idx = image_dict.get('page_idx', idx)
+                page_name = image_dict.get('page_name', f'page_{page_idx + 1:03d}')
+                
+                logger.info(f"🔍 Processing page {idx + 1}/{len(images)} (original index: {page_idx})")
+                
+                # 处理单页
+                page_result = self._process_single_page(
+                    image_dict=image_dict,
+                    page_idx=page_idx,
+                    pdf_type=pdf_type,
+                    pdf_doc=pdf_doc,
+                    page_name=page_name
+                )
+                
+                # 立即保存该页结果(使用 OutputFormatterV2 的方法,保持输出一致)
+                page_output_paths = self._save_page_immediately(
+                    page_result=page_result,
+                    doc_name=doc_name,
+                    page_idx=page_idx,
+                    is_pdf=is_pdf,
+                    images_dir=images_dir,
+                    output_config=output_config
+                )
+                
+                # 保存页面到列表(用于跨页表格合并和最终生成,需要在删除前保存)
+                self.processed_pages.append(page_result.copy())
+                
+                # 更新结果摘要
+                results_summary['processed_pages'].append({
+                    'page_idx': page_idx,
+                    'page_name': page_name,
+                    'output_paths': page_output_paths
+                })
+                if page_output_paths.get('json_path'):
+                    results_summary['output_paths']['json_pages'].append(page_output_paths['json_path'])
+                if page_output_paths.get('image_paths'):
+                    results_summary['output_paths']['images'].extend(page_output_paths['image_paths'])
+                
+                # 保存页面元数据(用于跨页表格合并)
+                self.page_metadata.append({
+                    'page_idx': page_idx,
+                    'page_name': page_name,
+                    'json_path': page_output_paths.get('json_path'),
+                    'has_table': any(
+                        elem.get('type') in ['table', 'table_body']
+                        for elem in page_result.get('elements', [])
+                    )
+                })
+                
+                # 释放该页的内存(显式删除大对象,但保留副本在 processed_pages 中)
+                del page_result
+                del image_dict
+                
+                logger.info(f"✅ Page {idx + 1} processed and saved")
+            
+            # 4. 构建完整结果(用于跨页表格合并和最终生成)
+            complete_results = {
+                'scene': self.scene_name,
+                'document_path': str(doc_path),
+                'pages': self.processed_pages,
+                'metadata': results_summary['metadata']
+            }
+            
+            # 5. 跨页表格合并
+            if output_config.get('merge_cross_page_tables', True):
+                logger.info("🔄 Merging cross-page tables...")
+                complete_results = PDFUtils.merge_cross_page_tables(complete_results)
+            
+            # 6. 使用 OutputFormatterV2 生成所有输出(保持输出一致)
+            logger.info("💾 Saving all results using OutputFormatterV2...")
+            final_output_paths = self.formatter.save_results(complete_results, output_config)
+            
+            # 更新结果摘要
+            results_summary['output_paths'].update(final_output_paths)
+            
+            # 7. 关闭 PDF 文档
+            if pdf_doc is not None:
+                pdf_doc.close()
+            
+            logger.info(f"✅ Document processing completed (streaming mode)")
+            return results_summary
+            
+        except Exception as e:
+            logger.error(f"❌ Document processing failed: {e}")
+            raise
+    
+    def _save_page_immediately(
+        self,
+        page_result: Dict[str, Any],
+        doc_name: str,
+        page_idx: int,
+        is_pdf: bool,
+        images_dir: Path,
+        output_config: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """
+        立即保存单页结果(使用 OutputFormatterV2 的方法,保持输出一致)
+        
+        Returns:
+            保存的文件路径字典
+        """
+        output_paths = {}
+        
+        # 构造单页 results 格式(用于调用 OutputFormatterV2 的方法)
+        single_page_results = {
+            'document_path': '',  # 不需要完整路径
+            'pages': [page_result],
+            'metadata': {}
+        }
+        
+        # 1. 保存图片元素(使用 VisualizationUtils)
+        if output_config.get('save_images', True):
+            image_paths = VisualizationUtils.save_image_elements(
+                single_page_results,
+                images_dir,
+                doc_name,
+                is_pdf=is_pdf
+            )
+            if image_paths:
+                output_paths['image_paths'] = image_paths
+        
+        # 2. 保存单页JSON(使用 JSONFormatters.save_page_jsons,保持命名规则一致)
+        if output_config.get('save_page_json', True):
+            normalize_numbers = output_config.get('normalize_numbers', True)
+            page_json_paths = JSONFormatters.save_page_jsons(
+                single_page_results,
+                self.output_dir,
+                doc_name,
+                is_pdf=is_pdf,
+                normalize_numbers=normalize_numbers
+            )
+            if page_json_paths:
+                output_paths['json_path'] = page_json_paths[0]  # 单页只有一个JSON
+        
+        # 3. 保存单页Markdown(使用 MarkdownGenerator.save_page_markdowns,保持命名规则一致)
+        if output_config.get('save_page_markdown', True):
+            normalize_numbers = output_config.get('normalize_numbers', True)
+            page_md_paths = MarkdownGenerator.save_page_markdowns(
+                single_page_results,
+                self.output_dir,
+                doc_name,
+                is_pdf=is_pdf,
+                normalize_numbers=normalize_numbers
+            )
+            if page_md_paths:
+                output_paths['markdown_path'] = page_md_paths[0]  # 单页只有一个Markdown
+        
+        # 4. 保存可视化图片(如果开启debug)
+        if output_config.get('save_layout_image', False):
+            VisualizationUtils.save_layout_images(
+                single_page_results,
+                self.output_dir,
+                doc_name,
+                is_pdf=is_pdf
+            )
+        
+        if output_config.get('save_ocr_image', False):
+            VisualizationUtils.save_ocr_images(
+                single_page_results,
+                self.output_dir,
+                doc_name,
+                is_pdf=is_pdf
+            )
+        
+        return output_paths
+    
+

+ 454 - 0
ocr_tools/universal_doc_parser/main_v2.py

@@ -0,0 +1,454 @@
+#!/usr/bin/env python3
+"""
+金融文档处理统一入口 v2
+支持完整的处理流程:
+1. PDF分类(扫描件/数字原生PDF)
+2. 页面方向识别
+3. Layout检测
+4. 并行处理:文本OCR + 表格VLM识别
+5. 单元格坐标匹配
+6. 多格式输出(JSON、Markdown、HTML、可视化图片)
+
+使用方法:
+    # 处理单个PDF
+    python main_v2.py -i /path/to/document.pdf -c ./config/bank_statement_mineru_vl.yaml
+    
+    # 处理图片目录
+    python main_v2.py -i /path/to/images/ -c ./config/bank_statement_paddle_vl.yaml
+    
+    # 开启debug模式(输出可视化图片)
+    python main_v2.py -i /path/to/doc.pdf -c ./config/xxx.yaml --debug
+"""
+
+import argparse
+import json
+import sys
+import os
+from pathlib import Path
+from typing import Optional
+from loguru import logger
+from datetime import datetime
+
+# 添加 ocr_platform 根目录到 Python 路径(用于导入 ocr_utils)
+ocr_platform_root = Path(__file__).parents[2]  # universal_doc_parser -> ocr_tools -> ocr_platform -> repository.git
+if str(ocr_platform_root) not in sys.path:
+    sys.path.insert(0, str(ocr_platform_root))
+
+# 添加当前目录到 Python 路径(用于相对导入)
+project_root = Path(__file__).parent
+if str(project_root) not in sys.path:
+    sys.path.insert(0, str(project_root))
+
+from dotenv import load_dotenv
+load_dotenv(override=True)
+
+from core.pipeline_manager_v2 import EnhancedDocPipeline
+from core.pipeline_manager_v2_streaming import StreamingDocPipeline
+
+# 从 ocr_utils 导入工具函数
+try:
+    from ocr_utils import OutputFormatterV2
+except ImportError:
+    # 降级:从 utils 导入(向后兼容)
+    from utils import OutputFormatterV2
+
+
+def setup_logging(log_level: str = "INFO", log_file: Optional[str] = None):
+    """设置日志"""
+    logger.remove()
+    
+    # 控制台输出
+    logger.add(
+        sys.stdout,
+        level=log_level,
+        format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
+    )
+    
+    # 文件输出
+    if log_file:
+        logger.add(
+            log_file,
+            level="DEBUG",
+            format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
+            rotation="10 MB"
+        )
+
+
+def process_single_input(
+    input_path: Path,
+    config_path: Path,
+    output_dir: Path,
+    debug: bool = False,
+    scene: Optional[str] = None,
+    page_range: Optional[str] = None,
+    streaming: bool = False
+) -> dict:
+    """
+    处理单个输入(文件或目录)
+    
+    Args:
+        input_path: 输入路径
+        config_path: 配置文件路径
+        output_dir: 输出目录
+        debug: 是否开启debug模式
+        scene: 场景类型覆盖
+        page_range: 页面范围(如 "1-5,7,9-12")
+        streaming: 是否使用流式处理模式(按页处理,立即保存,节省内存)
+        
+    Returns:
+        处理结果和输出路径
+    """
+    try:
+        # 选择处理模式
+        if streaming:
+            logger.info("🔄 Using streaming processing mode (memory-efficient)")
+            pipeline_streaming = StreamingDocPipeline(str(config_path), str(output_dir))
+            use_context = False  # StreamingDocPipeline 不使用 context manager
+        else:
+            logger.info("🔄 Using batch processing mode (all pages in memory)")
+            pipeline_batch = EnhancedDocPipeline(str(config_path))
+            use_context = hasattr(pipeline_batch, '__enter__')
+            if use_context:
+                pipeline_batch = pipeline_batch.__enter__()
+        
+        try:
+            
+            # 覆盖场景设置
+            if streaming:
+                pipeline = pipeline_streaming
+            else:
+                pipeline = pipeline_batch
+            
+            if scene:
+                pipeline.scene_name = scene
+                logger.info(f"🔄 Scene overridden to: {scene}")
+            
+            logger.info(f"🚀 开始处理: {input_path}")
+            logger.info(f"📋 场景配置: {pipeline.scene_name}")
+            logger.info(f"📁 输出目录: {output_dir}")
+            if page_range:
+                logger.info(f"📄 页面范围: {page_range}")
+            
+            # 构建输出配置
+            output_config = {
+                'save_json': True,
+                'save_markdown': True,
+                'save_html': True,
+                'save_page_json': True,
+                'save_images': True,
+                'save_layout_image': debug,
+                'save_ocr_image': debug,
+                'normalize_numbers': True,
+                'merge_cross_page_tables': True,
+                'cleanup_temp_files': True,
+            }
+            
+            # 处理文档
+            start_time = datetime.now()
+            
+            if streaming:
+                # 流式处理模式
+                results = pipeline.process_document_streaming(  # type: ignore
+                    str(input_path),
+                    page_range=page_range,
+                    output_config=output_config
+                )
+                process_time = (datetime.now() - start_time).total_seconds()
+                
+                # 流式模式已经保存了所有结果,只需要返回摘要
+                output_paths = results.get('output_paths', {})
+                
+                # 打印摘要
+                _print_summary_streaming(results, process_time)
+                
+                return {
+                    'success': True,
+                    'results': results,
+                    'output_paths': output_paths,
+                    'process_time': process_time
+                }
+            else:
+                # 批量处理模式(原有逻辑)
+                results = pipeline.process_document(str(input_path), page_range=page_range)
+                process_time = (datetime.now() - start_time).total_seconds()
+                
+                logger.info(f"⏱️ 处理耗时: {process_time:.2f}秒")
+                
+                # 格式化输出
+                logger.info("💾 保存结果...")
+                formatter = OutputFormatterV2(str(output_dir))
+                output_paths = formatter.save_results(results, output_config)
+                
+                # 打印摘要
+                _print_summary(results, output_paths, process_time)
+                
+                return {
+                    'success': True,
+                    'results': results,
+                    'output_paths': output_paths,
+                    'process_time': process_time
+                }
+        
+        finally:
+            # 关闭context manager
+            if not streaming and use_context:
+                pipeline_batch.__exit__(None, None, None)
+            
+    except Exception as e:
+        logger.error(f"❌ 处理失败: {e}")
+        import traceback
+        traceback.print_exc()
+        return {
+            'success': False,
+            'error': str(e)
+        }
+
+
+def _print_summary(results: dict, output_paths: dict, process_time: float):
+    """打印处理结果摘要"""
+    total_pages = len(results.get('pages', []))
+    
+    total_tables = 0
+    total_text_blocks = 0
+    total_cells = 0
+    
+    for page in results.get('pages', []):
+        for element in page.get('elements', []):
+            elem_type = element.get('type', '')
+            if elem_type in ['table', 'table_body']:
+                total_tables += 1
+                cells = element.get('content', {}).get('cells', [])
+                total_cells += len(cells)
+            elif elem_type in ['text', 'title', 'ocr_text', 'ref_text']:
+                total_text_blocks += 1
+    
+    print(f"\n{'='*60}")
+    print(f"📊 处理摘要")
+    print(f"{'='*60}")
+    print(f"   📄 文档: {results.get('document_path', 'N/A')}")
+    print(f"   🎯 场景: {results.get('scene', 'N/A')}")
+    print(f"   📋 PDF类型: {results.get('metadata', {}).get('pdf_type', 'N/A')}")
+    print(f"   📖 页面数: {total_pages}")
+    print(f"   📋 表格数: {total_tables}")
+    print(f"   📝 文本块: {total_text_blocks}")
+    print(f"   🔢 单元格: {total_cells} (带坐标)")
+    print(f"   ⏱️ 耗时: {process_time:.2f}秒")
+    print(f"{'='*60}")
+    print(f"📁 输出文件:")
+    for key, path in output_paths.items():
+        if isinstance(path, list):
+            for p in path:
+                print(f"   - {p}")
+        else:
+            print(f"   - {path}")
+    print(f"{'='*60}\n")
+
+
+def _print_summary_streaming(results_summary: dict, process_time: float):
+    """打印流式处理结果摘要"""
+    print(f"\n{'='*60}")
+    print(f"📊 处理摘要(流式模式)")
+    print(f"{'='*60}")
+    print(f"   📄 文档: {results_summary.get('document_path', 'N/A')}")
+    print(f"   🎯 场景: {results_summary.get('scene', 'N/A')}")
+    print(f"   📋 PDF类型: {results_summary.get('metadata', {}).get('pdf_type', 'N/A')}")
+    print(f"   📖 页面数: {results_summary.get('total_pages', 0)}")
+    print(f"   ⏱️ 耗时: {process_time:.2f}秒")
+    print(f"{'='*60}")
+    print(f"📁 输出文件:")
+    output_paths = results_summary.get('output_paths', {})
+    if output_paths.get('middle_json'):
+        print(f"   - {output_paths['middle_json']}")
+    if output_paths.get('json_pages'):
+        print(f"   - {len(output_paths['json_pages'])} 个页面JSON文件")
+    if output_paths.get('images'):
+        print(f"   - {len(output_paths['images'])} 个图片文件")
+    print(f"{'='*60}\n")
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="金融文档处理工具 v2",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+示例:
+  # 处理单个PDF文件
+  python main_v2.py -i document.pdf -c config/bank_statement_mineru_vl.yaml
+  
+  # 处理图片目录
+  python main_v2.py -i ./images/ -c config/bank_statement_paddle_vl.yaml
+  
+  # 开启debug模式(输出可视化图片)
+  python main_v2.py -i doc.pdf -c config.yaml --debug
+  
+  # 指定输出目录
+  python main_v2.py -i doc.pdf -c config.yaml -o ./my_output/
+  
+  # 指定页面范围(PDF按页码,图片目录按排序位置)
+  python main_v2.py -i doc.pdf -c config.yaml -p 1-5      # 处理第1-5页
+  python main_v2.py -i doc.pdf -c config.yaml -p 3,7,10   # 处理第3、7、10页
+  python main_v2.py -i doc.pdf -c config.yaml -p 1-5,8-10 # 处理第1-5、8-10页
+  python main_v2.py -i doc.pdf -c config.yaml -p 5-       # 从第5页到最后
+  
+  # 使用流式处理模式(节省内存,适合大文档)
+  python main_v2.py -i large_doc.pdf -c config.yaml --streaming
+        """
+    )
+    
+    parser.add_argument(
+        "--input", "-i",
+        required=True,
+        help="输入路径(PDF文件、图片文件或图片目录)"
+    )
+    parser.add_argument(
+        "--config", "-c",
+        required=True,
+        help="配置文件路径"
+    )
+    parser.add_argument(
+        "--output_dir", "-o",
+        default="./output",
+        help="输出目录(默认: ./output)"
+    )
+    parser.add_argument(
+        "--scene", "-s",
+        choices=["bank_statement", "financial_report"],
+        help="场景类型(覆盖配置文件设置)"
+    )
+    parser.add_argument(
+        "--debug",
+        action="store_true",
+        help="开启debug模式(输出layout和OCR可视化图片)"
+    )
+    parser.add_argument(
+        "--log_level",
+        default="INFO",
+        choices=["DEBUG", "INFO", "WARNING", "ERROR"],
+        help="日志级别(默认: INFO)"
+    )
+    parser.add_argument(
+        "--log_file",
+        help="日志文件路径"
+    )
+    parser.add_argument(
+        "--dry_run",
+        action="store_true",
+        help="仅验证配置,不执行处理"
+    )
+    parser.add_argument(
+        "--pages", "-p",
+        help="页面范围(PDF按页码,图片目录按排序位置),如: 1-5,7,9-12"
+    )
+    parser.add_argument(
+        "--streaming",
+        action="store_true",
+        help="使用流式处理模式(按页处理,立即保存,节省内存,适合大文档)"
+    )
+    
+    args = parser.parse_args()
+    
+    # 设置日志
+    setup_logging(args.log_level, args.log_file)
+    
+    # 验证输入
+    input_path = Path(args.input)
+    if not input_path.exists():
+        logger.error(f"❌ 输入路径不存在: {input_path}")
+        return 1
+    
+    # 验证配置文件
+    config_path = Path(args.config)
+    if not config_path.exists():
+        logger.error(f"❌ 配置文件不存在: {config_path}")
+        return 1
+    
+    # 仅验证模式
+    if args.dry_run:
+        logger.info("✅ 配置验证通过(dry run)")
+        return 0
+    
+    # 处理文档
+    result = process_single_input(
+        input_path=input_path,
+        config_path=config_path,
+        output_dir=Path(args.output_dir),
+        debug=args.debug,
+        scene=args.scene,
+        page_range=args.pages,
+        streaming=args.streaming
+    )
+    
+    return 0 if result.get('success') else 1
+
+
+if __name__ == "__main__":
+    # 打印环境变量
+    print(f"🔧 CUDA_VISIBLE_DEVICES: {os.environ.get('CUDA_VISIBLE_DEVICES', 'Not set')}")
+    print(f"🔧 HF_HOME: {os.environ.get('HF_HOME', 'Not set')}")
+    print(f"🔧 HF_ENDPOINT: {os.environ.get('HF_ENDPOINT', 'Not set')}")
+    print(f"🔧 HF_HUB_OFFLINE: {os.environ.get('HF_HUB_OFFLINE', 'Not set')}")
+    print(f"🔧 TORCH_HOME: {os.environ.get('TORCH_HOME', 'Not set')}")
+    print(f"🔧 MODELSCOPE_CACHE: {os.environ.get('MODELSCOPE_CACHE', 'Not set')}")
+    print(f"🔧 USE_MODELSCOPE_HUB: {os.environ.get('USE_MODELSCOPE_HUB', 'Not set')}")
+    print(f"🔧 MINERU_MODEL_SOURCE: {os.environ.get('MINERU_MODEL_SOURCE', 'Not set')}")
+
+    if len(sys.argv) == 1:
+        # 没有命令行参数时,使用默认配置运行
+        print("ℹ️  未提供命令行参数,使用默认配置运行...")
+        
+        # 默认配置
+        default_config = {
+            # 测试输入
+            "input": "/Users/zhch158/workspace/data/流水分析/康强_北京农村商业银行.pdf",
+            "output_dir": "./output/康强_北京农村商业银行_bank_statement_v2",
+
+            # "input": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/mineru_vllm_results/2023年度报告母公司/2023年度报告母公司_page_003.png",
+            # "output_dir": "./output/2023年度报告母公司_bank_statement_v2",
+            
+            # "input": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水.pdf",
+            # "output_dir": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/bank_statement_yusys_v2",
+
+            # "input": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司.pdf",
+            # "output_dir": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/bank_statement_yusys_v2",
+
+            # "input": "/Users/zhch158/workspace/data/流水分析/施博深.pdf",
+            # "output_dir": "/Users/zhch158/workspace/data/流水分析/施博深/bank_statement_yusys_v2",
+
+            # "input": "/Users/zhch158/workspace/data/流水分析/施博深.wiredtable/施博深_page_001.png",
+            # "output_dir": "./output/施博深_page_001_bank_statement_wired_unet",
+
+            # "input": "/Users/zhch158/workspace/data/流水分析/施博深.wiredtable",
+            # "output_dir": "/Users/zhch158/workspace/data/流水分析/施博深/bank_statement_wired_unet",
+
+            # 配置文件
+            # "config": "./config/bank_statement_wired_unet.yaml",
+            "config": "./config/bank_statement_yusys_v2.yaml",
+            # "config": "./config/bank_statement_paddle_vl.yaml",
+            
+            # 场景
+            "scene": "bank_statement",
+            
+            # 页面范围(可选)
+            "pages": "-1",  # 只处理前1页
+            # "pages": "1-3,5,7-10",  # 处理指定页面
+
+            "streaming": True,
+
+            # Debug模式
+            "debug": True,
+            
+            # 日志级别
+            "log_level": "DEBUG",
+        }
+        
+        # 构造参数
+        sys.argv = [sys.argv[0]]
+        for key, value in default_config.items():
+            if isinstance(value, bool):
+                if value:
+                    sys.argv.append(f"--{key}")
+            else:
+                sys.argv.extend([f"--{key}", str(value)])
+    
+    sys.exit(main())
+

+ 116 - 0
ocr_tools/universal_doc_parser/models/adapters/__init__.py

@@ -0,0 +1,116 @@
+"""
+模型适配器模块
+提供统一的接口适配不同的模型后端
+"""
+
+from .base import (
+    BaseAdapter,
+    BasePreprocessor,
+    BaseLayoutDetector,
+    BaseVLRecognizer,
+    BaseOCRRecognizer
+)
+
+from .paddle_layout_detector import PaddleLayoutDetector
+from .paddle_vl_adapter import PaddleVLRecognizer
+
+from .docling_layout_adapter import DoclingLayoutDetector
+# 可选导入 MinerU 适配器
+try:
+    from .mineru_adapter import (
+        MinerUPreprocessor,
+        MinerULayoutDetector,
+        MinerUVLRecognizer,
+        MinerUOCRRecognizer
+    )
+    from .mineru_wired_table import MinerUWiredTableRecognizer
+    MINERU_AVAILABLE = True
+except ImportError:
+    MINERU_AVAILABLE = False
+
+__all__ = [
+    # 基类
+    'BaseAdapter',
+    'BasePreprocessor',
+    'BaseLayoutDetector',
+    'BaseVLRecognizer',
+    'BaseOCRRecognizer',
+    
+    # PaddleX 适配器
+    'PaddleLayoutDetector',
+    'PaddleVLRecognizer',
+    
+    # Docling 适配器
+    'DoclingLayoutDetector',
+]
+
+# 如果 MinerU 可用,添加到导出列表
+if MINERU_AVAILABLE:
+    __all__.extend([
+        'MinerUPreprocessor',
+        'MinerULayoutDetector',
+        'MinerUVLRecognizer',
+        'MinerUOCRRecognizer',
+        'MinerUWiredTableRecognizer',
+    ])
+
+
+def get_layout_detector(config: dict):
+    """
+    根据配置获取布局检测器
+    
+    Args:
+        config: 配置字典,包含 module 和其他参数
+        
+    Returns:
+        BaseLayoutDetector 实例
+    """
+    module = config.get('module', 'paddle')
+    
+    if module == 'paddle':
+        return PaddleLayoutDetector(config)
+    elif module == 'mineru':
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU adapter not available")
+        return MinerULayoutDetector(config)
+    elif module == 'docling':
+        return DoclingLayoutDetector(config)
+    else:
+        raise ValueError(f"Unknown layout detection module: {module}")
+
+
+def get_preprocessor(config: dict):
+    """根据配置获取预处理器"""
+    module = config.get('module', 'mineru')
+    
+    if module == 'mineru':
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU adapter not available")
+        return MinerUPreprocessor(config)
+    else:
+        raise ValueError(f"Unknown preprocessor module: {module}")
+
+
+def get_vl_recognizer(config: dict):
+    """根据配置获取VL识别器"""
+    module = config.get('module', 'mineru')
+    
+    if module == 'paddle':
+        return PaddleVLRecognizer(config)
+    elif module == 'mineru':
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU adapter not available")
+        return MinerUVLRecognizer(config)
+    else:
+        raise ValueError(f"Unknown VL recognizer module: {module}")
+
+def get_ocr_recognizer(config: dict):
+    """根据配置获取OCR识别器"""
+    module = config.get('module', 'mineru')
+    
+    if module == 'mineru':
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU adapter not available")
+        return MinerUOCRRecognizer(config)
+    else:
+        raise ValueError(f"Unknown OCR recognizer module: {module}")

+ 92 - 0
ocr_tools/universal_doc_parser/models/adapters/base.py

@@ -0,0 +1,92 @@
+from abc import ABC, abstractmethod
+from typing import Dict, Any, List, Union
+import numpy as np
+from PIL import Image
+
+class BaseAdapter(ABC):
+    """基础适配器接口"""
+    
+    def __init__(self, config: Dict[str, Any]):
+        self.config = config
+    
+    @abstractmethod
+    def initialize(self):
+        """初始化模型"""
+        pass
+    
+    @abstractmethod 
+    def cleanup(self):
+        """清理资源"""
+        pass
+
+class BasePreprocessor(BaseAdapter):
+    """预处理器基类"""
+    
+    @abstractmethod
+    def process(self, image: Union[np.ndarray, Image.Image]) -> tuple[np.ndarray, int]:
+        """
+        处理图像
+        返回处理后的图像和旋转角度
+        """
+        pass
+    
+    def _apply_rotation(self, image: np.ndarray, rotation_angle: int) -> np.ndarray:
+        """应用旋转"""
+        import cv2
+        if rotation_angle == 90:  # 90度
+            return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
+        elif rotation_angle == 180:  # 180度
+            return cv2.rotate(image, cv2.ROTATE_180)
+        elif rotation_angle == 270:  # 270度
+            return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
+        return image
+
+class BaseLayoutDetector(BaseAdapter):
+    """版式检测器基类"""
+    
+    @abstractmethod
+    def detect(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """检测版式"""
+        pass
+    
+    def _map_category_id(self, category_id: int) -> str:
+        """映射类别ID到字符串"""
+        category_map = {
+            0: 'title',
+            1: 'text', 
+            2: 'abandon',
+            3: 'image_body',
+            4: 'image_caption',
+            5: 'table_body',
+            6: 'table_caption',
+            7: 'table_footnote',
+            8: 'interline_equation',
+            9: 'interline_equation_number',
+            13: 'inline_equation',
+            14: 'interline_equation_yolo',
+            15: 'ocr_text',
+            16: 'low_score_text',
+            101: 'image_footnote'
+        }
+        return category_map.get(category_id, f'unknown_{category_id}')
+
+class BaseVLRecognizer(BaseAdapter):
+    """VL识别器基类"""
+    
+    @abstractmethod
+    def recognize_table(self, image: Union[np.ndarray, Image.Image], **kwargs) -> Dict[str, Any]:
+        """识别表格"""
+        pass
+    
+    @abstractmethod
+    def recognize_formula(self, image: Union[np.ndarray, Image.Image], **kwargs) -> Dict[str, Any]:
+        """识别公式"""
+        pass
+
+class BaseOCRRecognizer(BaseAdapter):
+    """OCR识别器基类"""
+    
+    @abstractmethod
+    def recognize_text(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """识别文本"""
+        pass

+ 667 - 0
ocr_tools/universal_doc_parser/models/adapters/docling_layout_adapter.py

@@ -0,0 +1,667 @@
+"""Docling Layout Predictor 适配器 (符合 BaseLayoutDetector 规范)
+
+基于 HuggingFace transformers 直接加载 Docling 布局模型,不依赖 docling-ibm-models 包。
+使用 MinerU 环境中的 transformers 库。
+
+支持的 Hugging Face 模型仓库:
+- ds4sd/docling-layout-old
+- ds4sd/docling-layout-heron
+- ds4sd/docling-layout-heron-101
+- ds4sd/docling-layout-egret-medium
+- ds4sd/docling-layout-egret-large
+- ds4sd/docling-layout-egret-xlarge
+"""
+
+import cv2
+import numpy as np
+import threading
+from pathlib import Path
+from typing import Dict, List, Union, Any, Optional
+from PIL import Image
+
+try:
+    from .base import BaseLayoutDetector
+except ImportError:
+    from base import BaseLayoutDetector
+
+# 全局锁,防止模型初始化时的线程问题
+_model_init_lock = threading.Lock()
+
+
+class DoclingLayoutDetector(BaseLayoutDetector):
+    """Docling Layout Predictor 适配器
+    
+    直接使用 transformers 库加载 Docling 布局模型,无需安装 docling-ibm-models。
+    """
+    
+    # Docling 原始类别定义(来自 docling-ibm-models/layoutmodel/labels.py)
+    # 使用 shifted canonical(带 Background)的版本,因为 RT-DETR 模型使用这个
+    DOCLING_LABELS = {
+        0: 'Background',
+        1: 'Caption',
+        2: 'Footnote',
+        3: 'Formula',
+        4: 'List-item',
+        5: 'Page-footer',
+        6: 'Page-header',
+        7: 'Picture',
+        8: 'Section-header',
+        9: 'Table',
+        10: 'Text',
+        11: 'Title',
+        12: 'Document Index',
+        13: 'Code',
+        14: 'Checkbox-Selected',
+        15: 'Checkbox-Unselected',
+        16: 'Form',
+        17: 'Key-Value Region',
+    }
+    
+    # 类别映射:Docling LayoutLabels → MinerU/EnhancedDocPipeline 类别体系
+    # 参考:
+    # - MinerU: mineru/utils/enum_class.py (BlockType, CategoryId)
+    # - Pipeline: universal_doc_parser/core/pipeline_manager_v2.py (EnhancedDocPipeline 类别定义)
+    CATEGORY_MAP = {
+        'Caption': 'image_caption',       # Caption -> image_caption (IMAGE_TEXT_CATEGORIES)
+        'Footnote': 'page_footnote',      # Footnote -> page_footnote (TEXT_CATEGORIES)
+        'Formula': 'interline_equation',  # Formula -> interline_equation (EQUATION_CATEGORIES)
+        'List-item': 'text',              # List-item -> text (TEXT_CATEGORIES)
+        'Page-footer': 'footer',          # Page-footer -> footer (TEXT_CATEGORIES)
+        'Page-header': 'header',          # Page-header -> header (TEXT_CATEGORIES)
+        'Picture': 'image_body',          # Picture -> image_body (IMAGE_BODY_CATEGORIES)
+        'Section-header': 'title',        # Section-header -> title (TEXT_CATEGORIES)
+        'Table': 'table_body',            # Table -> table_body (TABLE_BODY_CATEGORIES)
+        'Text': 'text',                   # Text -> text (TEXT_CATEGORIES)
+        'Title': 'title',                 # Title -> title (TEXT_CATEGORIES)
+        'Document Index': 'text',         # Document Index -> text (TEXT_CATEGORIES)
+        'Code': 'code',                   # Code -> code (CODE_CATEGORIES)
+        'Checkbox-Selected': 'abandon',   # Checkbox -> abandon (DISCARD_CATEGORIES)
+        'Checkbox-Unselected': 'abandon', # Checkbox -> abandon (DISCARD_CATEGORIES)
+        'Form': 'abandon',                # Form -> abandon (DISCARD_CATEGORIES)
+        'Key-Value Region': 'text',       # Key-Value Region -> text (TEXT_CATEGORIES)
+        'Background': 'abandon',          # Background -> abandon (DISCARD_CATEGORIES)
+    }
+    
+    def __init__(self, config: Dict[str, Any]):
+        """
+        初始化 Docling Layout 检测器
+        
+        Args:
+            config: 配置字典,支持以下参数:
+                - model_dir: 模型目录路径或 HuggingFace 仓库 ID
+                - device: 运行设备 ('cpu', 'cuda', 'mps')
+                - conf: 置信度阈值 (默认 0.3)
+                - num_threads: CPU 线程数 (默认 4)
+        """
+        super().__init__(config)
+        self.model = None
+        self.image_processor = None
+        self._device = None
+        self._threshold = 0.3
+        self._num_threads = 4
+        self._model_path = None
+        # RT-DETR 使用 shifted labels,label_offset = 1
+        self._label_offset = 1
+    
+    def initialize(self):
+        """初始化模型"""
+        try:
+            import torch
+            from transformers import AutoModelForObjectDetection, RTDetrImageProcessor
+            from huggingface_hub import snapshot_download
+            
+            model_dir = self.config.get('model_dir', 'ds4sd/docling-layout-old')
+            device = self.config.get('device', 'cpu')
+            self._threshold = self.config.get('conf', 0.3)
+            self._num_threads = self.config.get('num_threads', 4)
+            
+            # 设置设备
+            self._device = torch.device(device)
+            if device == 'cpu':
+                torch.set_num_threads(self._num_threads)
+            
+            # 判断是本地路径还是 HuggingFace 仓库
+            model_path = Path(model_dir)
+            if model_path.exists() and model_path.is_dir():
+                # 本地路径
+                self._model_path = str(model_path)
+                print(f"📂 Loading model from local path: {self._model_path}")
+            else:
+                # 从 HuggingFace 下载
+                print(f"📥 Downloading model from HuggingFace: {model_dir}")
+                self._model_path = snapshot_download(repo_id=model_dir)
+            
+            # 检查必要文件
+            processor_config = Path(self._model_path) / "preprocessor_config.json"
+            model_config = Path(self._model_path) / "config.json"
+            safetensors_file = Path(self._model_path) / "model.safetensors"
+            
+            if not processor_config.exists():
+                raise FileNotFoundError(f"Missing preprocessor_config.json in {self._model_path}")
+            if not model_config.exists():
+                raise FileNotFoundError(f"Missing config.json in {self._model_path}")
+            if not safetensors_file.exists():
+                raise FileNotFoundError(f"Missing model.safetensors in {self._model_path}")
+            
+            # 加载图像处理器
+            self.image_processor = RTDetrImageProcessor.from_json_file(str(processor_config))
+            
+            # 加载模型(使用锁防止线程问题)
+            with _model_init_lock:
+                self.model = AutoModelForObjectDetection.from_pretrained(
+                    self._model_path,
+                    config=str(model_config),
+                    device_map=self._device
+                )
+                self.model.eval()
+            
+            # 检测模型类型
+            model_name = type(self.model).__name__
+            print(f"✅ Docling Layout Detector initialized")
+            print(f"   - Model: {model_name}")
+            print(f"   - Device: {self._device}")
+            print(f"   - Threshold: {self._threshold}")
+            print(f"   - Image size: {self.image_processor.size}")
+            
+        except ImportError as e:
+            print(f"❌ Failed to import required libraries: {e}")
+            print("   Please ensure transformers and torch are installed")
+            raise
+        except Exception as e:
+            print(f"❌ Failed to initialize Docling Layout Detector: {e}")
+            raise
+    
+    def cleanup(self):
+        """清理资源"""
+        self.model = None
+        self.image_processor = None
+        self._model_path = None
+    
+    def detect(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """
+        检测布局
+        
+        Args:
+            image: 输入图像 (numpy数组或PIL图像)
+            
+        Returns:
+            检测结果列表,每个元素包含:
+            - category: MinerU类别名称
+            - bbox: [x1, y1, x2, y2]
+            - confidence: 置信度
+            - raw: 原始检测结果
+        """
+        import torch
+        
+        if self.model is None:
+            raise RuntimeError("Model not initialized. Call initialize() first.")
+        
+        # 转换为 PIL Image
+        if isinstance(image, np.ndarray):
+            # OpenCV BGR -> RGB
+            if len(image.shape) == 3 and image.shape[2] == 3:
+                image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
+            else:
+                image_rgb = image
+            pil_image = Image.fromarray(image_rgb).convert("RGB")
+            orig_h, orig_w = image.shape[:2]
+        else:
+            pil_image = image.convert("RGB")
+            orig_w, orig_h = image.size
+        
+        # 推理
+        with torch.inference_mode():
+            target_sizes = torch.tensor([pil_image.size[::-1]])  # (h, w)
+            inputs = self.image_processor(images=[pil_image], return_tensors="pt").to(self._device)
+            outputs = self.model(**inputs)
+            
+            results = self.image_processor.post_process_object_detection(
+                outputs,
+                target_sizes=target_sizes,
+                threshold=self._threshold,
+            )
+        
+        # 解析结果
+        w, h = pil_image.size
+        result = results[0]
+        
+        formatted_results = []
+        for score, label_id, box in zip(result["scores"], result["labels"], result["boxes"]):
+            score = float(score.item())
+            
+            # 获取原始标签(考虑 offset)
+            label_id_int = int(label_id.item()) + self._label_offset
+            original_label = self.DOCLING_LABELS.get(label_id_int, f'unknown_{label_id_int}')
+            
+            # 映射到 MinerU 类别
+            mineru_category = self.CATEGORY_MAP.get(original_label, 'text')
+            
+            # 跳过 Background
+            if original_label == 'Background':
+                continue
+            
+            # 提取边界框
+            bbox_float = [float(b.item()) for b in box]
+            x1 = min(w, max(0, bbox_float[0]))
+            y1 = min(h, max(0, bbox_float[1]))
+            x2 = min(w, max(0, bbox_float[2]))
+            y2 = min(h, max(0, bbox_float[3]))
+            
+            bbox = [int(x1), int(y1), int(x2), int(y2)]
+            
+            # 计算宽高
+            width = bbox[2] - bbox[0]
+            height = bbox[3] - bbox[1]
+            
+            # 过滤太小的框
+            if width < 10 or height < 10:
+                continue
+            
+            # 过滤面积异常大的框
+            area = width * height
+            img_area = orig_w * orig_h
+            if area > img_area * 0.95:
+                continue
+            
+            # 生成多边形坐标
+            poly = [
+                bbox[0], bbox[1],  # 左上
+                bbox[2], bbox[1],  # 右上
+                bbox[2], bbox[3],  # 右下
+                bbox[0], bbox[3],  # 左下
+            ]
+            
+            formatted_results.append({
+                'category': mineru_category,
+                'bbox': bbox,
+                'confidence': score,
+                'raw': {
+                    'original_label': original_label,
+                    'original_label_id': label_id_int,
+                    'poly': poly,
+                    'width': width,
+                    'height': height
+                }
+            })
+        
+        return formatted_results
+    
+    def detect_batch(
+        self, 
+        images: List[Union[np.ndarray, Image.Image]]
+    ) -> List[List[Dict[str, Any]]]:
+        """
+        批量检测布局(更高效)
+        
+        Args:
+            images: 输入图像列表
+            
+        Returns:
+            每个图像的检测结果列表
+        """
+        import torch
+        
+        if self.model is None:
+            raise RuntimeError("Model not initialized. Call initialize() first.")
+        
+        if not images:
+            return []
+        
+        # 转换为 PIL Image 列表
+        pil_images = []
+        orig_sizes = []
+        
+        for image in images:
+            if isinstance(image, np.ndarray):
+                if len(image.shape) == 3 and image.shape[2] == 3:
+                    image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
+                else:
+                    image_rgb = image
+                pil_images.append(Image.fromarray(image_rgb).convert("RGB"))
+                orig_sizes.append((image.shape[1], image.shape[0]))  # (w, h)
+            else:
+                pil_images.append(image.convert("RGB"))
+                orig_sizes.append(image.size)  # (w, h)
+        
+        # 批量推理
+        with torch.inference_mode():
+            target_sizes = torch.tensor([img.size[::-1] for img in pil_images])
+            inputs = self.image_processor(images=pil_images, return_tensors="pt").to(self._device)
+            outputs = self.model(**inputs)
+            
+            results_list = self.image_processor.post_process_object_detection(
+                outputs,
+                target_sizes=target_sizes,
+                threshold=self._threshold,
+            )
+        
+        # 转换结果
+        all_formatted_results = []
+        
+        for pil_img, results, (orig_w, orig_h) in zip(pil_images, results_list, orig_sizes):
+            w, h = pil_img.size
+            formatted_results = []
+            
+            for score, label_id, box in zip(results["scores"], results["labels"], results["boxes"]):
+                score = float(score.item())
+                
+                label_id_int = int(label_id.item()) + self._label_offset
+                original_label = self.DOCLING_LABELS.get(label_id_int, f'unknown_{label_id_int}')
+                mineru_category = self.CATEGORY_MAP.get(original_label, 'text')
+                
+                if original_label == 'Background':
+                    continue
+                
+                bbox_float = [float(b.item()) for b in box]
+                x1 = min(w, max(0, bbox_float[0]))
+                y1 = min(h, max(0, bbox_float[1]))
+                x2 = min(w, max(0, bbox_float[2]))
+                y2 = min(h, max(0, bbox_float[3]))
+                
+                bbox = [int(x1), int(y1), int(x2), int(y2)]
+                width = bbox[2] - bbox[0]
+                height = bbox[3] - bbox[1]
+                
+                if width < 10 or height < 10:
+                    continue
+                
+                area = width * height
+                img_area = orig_w * orig_h
+                if area > img_area * 0.95:
+                    continue
+                
+                poly = [
+                    bbox[0], bbox[1],
+                    bbox[2], bbox[1],
+                    bbox[2], bbox[3],
+                    bbox[0], bbox[3],
+                ]
+                
+                formatted_results.append({
+                    'category': mineru_category,
+                    'bbox': bbox,
+                    'confidence': score,
+                    'raw': {
+                        'original_label': original_label,
+                        'original_label_id': label_id_int,
+                        'poly': poly,
+                        'width': width,
+                        'height': height
+                    }
+                })
+            
+            all_formatted_results.append(formatted_results)
+        
+        return all_formatted_results
+    
+    def visualize(
+        self, 
+        img: np.ndarray, 
+        results: List[Dict],
+        output_path: str = None,
+        show_confidence: bool = True,
+        min_confidence: float = 0.0
+    ) -> np.ndarray:
+        """
+        可视化检测结果
+        
+        Args:
+            img: 输入图像 (BGR 格式)
+            results: 检测结果 (MinerU 格式)
+            output_path: 输出路径(可选)
+            show_confidence: 是否显示置信度
+            min_confidence: 最小置信度阈值
+            
+        Returns:
+            标注后的图像
+        """
+        import random
+        
+        vis_img = img.copy()
+        
+        # 预定义类别颜色(与 EnhancedDocPipeline 保持一致)
+        predefined_colors = {
+            # 文本类
+            'text': (153, 0, 76),
+            'title': (102, 102, 255),
+            'header': (128, 128, 128),
+            'footer': (128, 128, 128),
+            'page_footnote': (200, 200, 200),
+            # 表格类
+            'table_body': (204, 204, 0),
+            'table_caption': (255, 255, 102),
+            # 图片类
+            'image_body': (153, 255, 51),
+            'image_caption': (102, 178, 255),
+            # 公式类
+            'interline_equation': (0, 255, 0),
+            # 代码类
+            'code': (102, 0, 204),
+            # 丢弃类
+            'abandon': (100, 100, 100),
+        }
+        
+        # 过滤低置信度结果
+        filtered_results = [
+            res for res in results 
+            if res['confidence'] >= min_confidence
+        ]
+        
+        if not filtered_results:
+            print(f"⚠️ No results to visualize (min_confidence={min_confidence})")
+            return vis_img
+        
+        # 为每个出现的类别分配颜色
+        category_colors = {}
+        for res in filtered_results:
+            cat = res['category']
+            if cat not in category_colors:
+                if cat in predefined_colors:
+                    category_colors[cat] = predefined_colors[cat]
+                else:
+                    category_colors[cat] = (
+                        random.randint(50, 255),
+                        random.randint(50, 255),
+                        random.randint(50, 255)
+                    )
+        
+        # 绘制检测框
+        for res in filtered_results:
+            bbox = res['bbox']
+            x1, y1, x2, y2 = bbox
+            cat = res['category']
+            confidence = res['confidence']
+            color = category_colors[cat]
+            
+            # 获取原始标签
+            original_label = res.get('raw', {}).get('original_label', cat)
+            
+            # 绘制矩形边框
+            cv2.rectangle(vis_img, (x1, y1), (x2, y2), color, 2)
+            
+            # 构造标签文本
+            if show_confidence:
+                label = f"{original_label}->{cat} {confidence:.2f}"
+            else:
+                label = f"{original_label}->{cat}"
+            
+            # 计算标签尺寸
+            label_size, baseline = cv2.getTextSize(
+                label, 
+                cv2.FONT_HERSHEY_SIMPLEX, 
+                0.4, 
+                1
+            )
+            label_w, label_h = label_size
+            
+            # 绘制标签背景
+            cv2.rectangle(
+                vis_img,
+                (x1, y1 - label_h - 4),
+                (x1 + label_w, y1),
+                color,
+                -1
+            )
+            
+            # 绘制标签文字
+            cv2.putText(
+                vis_img,
+                label,
+                (x1, y1 - 2),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                0.4,
+                (255, 255, 255),
+                1,
+                cv2.LINE_AA
+            )
+        
+        # 添加图例
+        if category_colors:
+            self._draw_legend(vis_img, category_colors, len(filtered_results))
+        
+        # 保存可视化结果
+        if output_path:
+            output_path = Path(output_path)
+            output_path.parent.mkdir(parents=True, exist_ok=True)
+            cv2.imwrite(str(output_path), vis_img)
+            print(f"💾 Visualization saved to: {output_path}")
+        
+        return vis_img
+    
+    def _draw_legend(
+        self, 
+        img: np.ndarray, 
+        category_colors: Dict[str, tuple],
+        total_count: int
+    ):
+        """在图像上绘制图例"""
+        legend_x = img.shape[1] - 200
+        legend_y = 20
+        line_height = 25
+        
+        # 绘制半透明背景
+        overlay = img.copy()
+        cv2.rectangle(
+            overlay,
+            (legend_x - 10, legend_y - 10),
+            (img.shape[1] - 10, legend_y + len(category_colors) * line_height + 30),
+            (255, 255, 255),
+            -1
+        )
+        cv2.addWeighted(overlay, 0.7, img, 0.3, 0, img)
+        
+        # 绘制标题
+        cv2.putText(
+            img,
+            f"Legend ({total_count} total)",
+            (legend_x, legend_y),
+            cv2.FONT_HERSHEY_SIMPLEX,
+            0.5,
+            (0, 0, 0),
+            1,
+            cv2.LINE_AA
+        )
+        
+        # 绘制每个类别
+        y_offset = legend_y + line_height
+        for cat, color in sorted(category_colors.items()):
+            cv2.rectangle(
+                img,
+                (legend_x, y_offset - 10),
+                (legend_x + 15, y_offset),
+                color,
+                -1
+            )
+            cv2.rectangle(
+                img,
+                (legend_x, y_offset - 10),
+                (legend_x + 15, y_offset),
+                (0, 0, 0),
+                1
+            )
+            
+            cv2.putText(
+                img,
+                cat,
+                (legend_x + 20, y_offset - 2),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                0.4,
+                (0, 0, 0),
+                1,
+                cv2.LINE_AA
+            )
+            
+            y_offset += line_height
+
+
+# 测试代码
+if __name__ == "__main__":
+    import sys
+    
+    # 测试配置 - 使用 HuggingFace 下载模型
+    config = {
+        'model_dir': 'ds4sd/docling-layout-old',  # HuggingFace 仓库 ID
+        'device': 'cpu',
+        'conf': 0.3,
+        'num_threads': 4
+    }
+    
+    # 初始化检测器
+    print("🔧 Initializing Docling Layout Detector...")
+    detector = DoclingLayoutDetector(config)
+    detector.initialize()
+    
+    # 读取测试图像
+    img_path = "/Users/zhch158/workspace/data/流水分析/康强_北京农村商业银行/ppstructurev3_client_results/康强_北京农村商业银行/康强_北京农村商业银行_page_001.png"
+    
+    print(f"\n📖 Loading image: {img_path}")
+    img = cv2.imread(img_path)
+    
+    if img is None:
+        print(f"❌ Failed to load image: {img_path}")
+        sys.exit(1)
+    
+    print(f"   Image shape: {img.shape}")
+    
+    # 执行检测
+    print("\n🔍 Detecting layout...")
+    results = detector.detect(img)
+    
+    print(f"\n✅ 检测到 {len(results)} 个区域:")
+    for i, res in enumerate(results, 1):
+        print(f"  [{i}] {res['category']}: "
+              f"score={res['confidence']:.3f}, "
+              f"bbox={res['bbox']}, "
+              f"original={res['raw']['original_label']}")
+    
+    # 统计各类别
+    category_counts = {}
+    for res in results:
+        cat = res['category']
+        category_counts[cat] = category_counts.get(cat, 0) + 1
+    
+    print(f"\n📊 类别统计 (MinerU格式):")
+    for cat, count in sorted(category_counts.items()):
+        print(f"  - {cat}: {count}")
+    
+    # 可视化
+    if len(results) > 0:
+        print("\n🎨 Generating visualization...")
+        
+        output_dir = Path(__file__).parent.parent.parent / "tests" / "output"
+        output_dir.mkdir(parents=True, exist_ok=True)
+        output_path = output_dir / f"{Path(img_path).stem}_docling_layout_vis.jpg"
+        
+        vis_img = detector.visualize(
+            img, 
+            results, 
+            output_path=str(output_path),
+            show_confidence=True,
+            min_confidence=0.0
+        )
+        
+        print(f"💾 Visualization saved to: {output_path}")
+    
+    # 清理
+    detector.cleanup()
+    print("\n✅ 测试完成!")

+ 510 - 0
ocr_tools/universal_doc_parser/models/adapters/mineru_adapter.py

@@ -0,0 +1,510 @@
+import sys
+from pathlib import Path
+from typing import Dict, Any, List, Union, Optional
+import numpy as np
+import cv2
+from PIL import Image
+from loguru import logger
+
+# 添加MinerU路径
+mineru_path = Path(__file__).parents[4] / "mineru"
+if str(mineru_path) not in sys.path:
+    sys.path.insert(0, str(mineru_path))
+
+from .base import BasePreprocessor, BaseLayoutDetector, BaseVLRecognizer, BaseOCRRecognizer
+
+# 导入MinerU组件
+try:
+    from mineru.backend.pipeline.model_init import AtomModelSingleton
+    from mineru.backend.vlm.vlm_analyze import ModelSingleton as VLMModelSingleton
+    from mineru.backend.pipeline.model_list import AtomicModel
+    from mineru.utils.config_reader import get_device
+    MINERU_AVAILABLE = True
+except ImportError as e:
+    print(f"Warning: MinerU components not available: {e}")
+    MINERU_AVAILABLE = False
+
+class MinerUPreprocessor(BasePreprocessor):
+    """MinerU预处理器适配器"""
+    
+    def __init__(self, config: Dict[str, Any]):
+        super().__init__(config)
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU components not available")
+            
+        self.atom_model_manager = AtomModelSingleton()
+        self.orientation_classifier = None
+        
+    def initialize(self):
+        """初始化预处理组件"""
+        # 初始化方向分类器
+        if self.config.get('orientation_classifier', {}).get('enabled', True):
+            try:
+                self.orientation_classifier = self.atom_model_manager.get_atom_model(
+                    atom_model_name=AtomicModel.ImgOrientationCls,
+                )
+                print("✅ Orientation classifier initialized")
+            except Exception as e:
+                print(f"⚠️ Failed to initialize orientation classifier: {e}")
+        
+    def cleanup(self):
+        """清理资源"""
+        pass
+
+    def process(self, image: Union[np.ndarray, Image.Image]) -> tuple[np.ndarray, int]:
+        """图像预处理"""
+        # 转换为numpy数组
+        if isinstance(image, Image.Image):
+            image = np.array(image)
+
+        rotate_angle = 0
+        processed_image = image
+        
+        # 方向校正
+        if self.orientation_classifier is not None:
+            try:
+                rotate_angle = int(self.orientation_classifier.predict(image))
+                processed_image = self._apply_rotation(processed_image, rotate_angle)
+                logger.info(f"📐 Applied rotation: {rotate_angle}")
+            except Exception as e:
+                logger.error(f"⚠️ Orientation classification failed: {e}")
+
+        return processed_image, rotate_angle
+
+class MinerULayoutDetector(BaseLayoutDetector):
+    """MinerU版式检测适配器"""
+    
+    def __init__(self, config: Dict[str, Any]):
+        super().__init__(config)
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU components not available")
+            
+        self.atom_model_manager = AtomModelSingleton()
+        self.layout_model = None
+        
+    def initialize(self):
+        """初始化版式检测模型"""
+        try:
+            # 获取模型配置
+            model_name = self.config.get('model_name', AtomicModel.Layout)
+            model_dir = self.config.get('model_dir')
+            device = self.config.get('device', 'cpu')
+            
+            # 初始化版式检测模型
+            if model_dir:
+                # 使用自定义模型路径
+                self.layout_model = self.atom_model_manager.get_atom_model(
+                    atom_model_name=AtomicModel.Layout,
+                    doclayout_yolo_weights=model_dir,
+                    device=device
+                )
+            else:
+                import os
+                from mineru.utils.enum_class import ModelPath
+                from mineru.utils.models_download_utils import auto_download_and_get_model_root_path
+                self.layout_model = self.atom_model_manager.get_atom_model(
+                    atom_model_name=AtomicModel.Layout,
+                    doclayout_yolo_weights=os.path.join(auto_download_and_get_model_root_path(ModelPath.doclayout_yolo), ModelPath.doclayout_yolo),
+                    device=device
+                )
+            print(f"✅ Layout detector initialized: {model_name}")
+            
+        except Exception as e:
+            print(f"❌ Failed to initialize layout detector: {e}")
+            raise
+        
+    def cleanup(self):
+        """清理资源"""
+        pass
+        
+    def detect(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """版式检测"""
+        if self.layout_model is None:
+            raise RuntimeError("Layout model not initialized")
+            
+        # 转换为PIL图像
+        if isinstance(image, np.ndarray):
+            image = Image.fromarray(image)
+            
+        # 进行检测
+        try:
+            layout_results = self.layout_model.predict([image])
+            
+            # 转换结果格式
+            formatted_results = []
+            for result in layout_results:  # 第一页结果
+                # 提取坐标信息
+                poly = result.get('poly', [0, 0, 0, 0, 0, 0, 0, 0])
+                if len(poly) >= 8:
+                    # 转换8点坐标为4点坐标 [x1,y1,x2,y2]
+                    bbox = [poly[0], poly[1], poly[4], poly[5]]
+                else:
+                    bbox = poly[:4] if len(poly) >= 4 else [0, 0, 0, 0]
+                    
+                formatted_results.append({
+                    'category': self._map_category_id(result.get('category_id', 1)),
+                    'bbox': bbox,
+                    'confidence': result.get('score', 0.0),
+                    'raw': result
+                })
+                
+            return formatted_results
+            
+        except Exception as e:
+            print(f"❌ Layout detection failed: {e}")
+            return []
+
+class MinerUVLRecognizer(BaseVLRecognizer):
+    """MinerU VL识别适配器"""
+    
+    def __init__(self, config: Dict[str, Any]):
+        super().__init__(config)
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU components not available")
+            
+        self.vlm_model = None
+        # 🔧 添加图片尺寸限制配置
+        self.max_image_size = config.get('max_image_size', 1568)  # VLM 模型的最大尺寸
+        self.resize_mode = config.get('resize_mode', 'max')  # 'max' or 'fixed'
+        
+    def initialize(self):
+        """初始化VL模型"""
+        try:
+            backend = self.config.get('backend', 'http-client')
+            server_url = self.config.get('server_url')
+            model_params = self.config.get('model_params', {})
+            
+            # 初始化VLM模型
+            self.vlm_model = VLMModelSingleton().get_model(
+                backend=backend,
+                model_path=None,
+                server_url=server_url,
+                **model_params
+            )
+            print(f"✅ VL recognizer initialized: {backend}")
+            
+        except Exception as e:
+            print(f"❌ Failed to initialize VL recognizer: {e}")
+            raise
+        
+    def cleanup(self):
+        """清理资源"""
+        pass
+    
+    def _preprocess_image(self, image: Union[np.ndarray, Image.Image]) -> Image.Image:
+        """
+        预处理图片,控制尺寸避免序列长度超限
+        
+        Args:
+            image: 输入图片
+            
+        Returns:
+            处理后的PIL图片
+        """
+        # 转换为PIL图像
+        if isinstance(image, np.ndarray):
+            image = Image.fromarray(image)
+        
+        # 获取原始尺寸
+        orig_w, orig_h = image.size
+        
+        # 计算缩放比例
+        if self.resize_mode == 'max':
+            # 保持宽高比,最长边不超过 max_image_size
+            max_dim = max(orig_w, orig_h)
+            if max_dim > self.max_image_size:
+                scale = self.max_image_size / max_dim
+                new_w = int(orig_w * scale)
+                new_h = int(orig_h * scale)
+                
+                logger.debug(f"🔄 Resizing image: {orig_w}x{orig_h} → {new_w}x{new_h} (scale={scale:.3f})")
+                image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
+        
+        elif self.resize_mode == 'fixed':
+            # 固定尺寸(可能改变宽高比)
+            if orig_w != self.max_image_size or orig_h != self.max_image_size:
+                logger.debug(f"🔄 Resizing image: {orig_w}x{orig_h} → {self.max_image_size}x{self.max_image_size}")
+                image = image.resize((self.max_image_size, self.max_image_size), Image.Resampling.LANCZOS)
+        
+        return image
+
+    def recognize_table(self, image: Union[np.ndarray, Image.Image], **kwargs) -> Dict[str, Any]:
+        """表格识别"""
+        if self.vlm_model is None:
+            raise RuntimeError("VL model not initialized")
+        
+        try:
+            # 🔧 预处理图片
+            image = self._preprocess_image(image)
+            
+            # 直接调用 content_extract,指定类型为 table
+            table_content = self.vlm_model.content_extract(
+                image=image,
+                type="table"
+            )
+            
+            if not table_content:
+                return {'html': '', 'markdown': '', 'cells': []}
+            
+            # 解析表格内容(假设返回的是HTML格式)
+            return {
+                'html': table_content,
+                'markdown': self._html_to_markdown(table_content),
+            }
+            
+        except Exception as e:
+            logger.error(f"❌ Table recognition failed: {e}")
+            return {'html': '', 'markdown': '', 'cells': []}
+    
+    def recognize_formula(self, image: Union[np.ndarray, Image.Image], **kwargs) -> Dict[str, Any]:
+        """识别公式"""
+        if self.vlm_model is None:
+            raise RuntimeError("VL model not initialized")
+        
+        try:
+            # 🔧 预处理图片
+            image = self._preprocess_image(image)
+            
+            # 直接调用 content_extract,指定类型为 equation
+            formula_content = self.vlm_model.content_extract(
+                image=image,
+                type="equation"
+            )
+            
+            if not formula_content:
+                return {'latex': '', 'confidence': 0.0, 'raw': {}}
+            
+            # 清理LaTeX格式
+            latex = self._clean_latex(formula_content)
+            
+            return {
+                'latex': latex,
+                'confidence': 0.9 if latex else 0.0,
+                'raw': {'raw_output': formula_content}
+            }
+            
+        except Exception as e:
+            logger.error(f"❌ Formula recognition failed: {e}")
+            return {'latex': '', 'confidence': 0.0, 'raw': {}}
+    
+    def recognize_text(self, image: Union[np.ndarray, Image.Image], **kwargs) -> Dict[str, Any]:
+        """识别文本区域"""
+        if self.vlm_model is None:
+            raise RuntimeError("VL model not initialized")
+            
+        try:
+            # 🔧 预处理图片
+            image = self._preprocess_image(image)
+
+            # 直接调用 content_extract,指定类型为 text
+            text_content = self.vlm_model.content_extract(
+                image=image,
+                type="text"
+            )
+            
+            return {
+                'text': text_content or '',
+                'confidence': 0.9 if text_content else 0.0
+            }
+            
+        except Exception as e:
+            print(f"❌ Text recognition failed: {e}")
+            return {'text': '', 'confidence': 0.0}
+    
+    def batch_recognize_table(
+        self, 
+        images: List[Union[np.ndarray, Image.Image]], 
+        **kwargs
+    ) -> List[Dict[str, Any]]:
+        """批量表格识别"""
+        if self.vlm_model is None:
+            raise RuntimeError("VL model not initialized")
+        
+        try:
+            # 🔧 批量预处理图片
+            pil_images = [self._preprocess_image(img) for img in images]
+            
+            # 批量调用 batch_content_extract
+            table_contents = self.vlm_model.batch_content_extract(
+                images=pil_images,
+                types="table"
+            )
+            
+            # 格式化结果
+            results = []
+            for content in table_contents:
+                if content:
+                    results.append({
+                        'html': content,
+                        'markdown': self._html_to_markdown(content),
+                        'cells': self._extract_cells_from_html(content) if kwargs.get('return_cells_coordinate', False) else []
+                    })
+                else:
+                    results.append({'html': '', 'markdown': '', 'cells': []})
+            
+            return results
+            
+        except Exception as e:
+            logger.error(f"❌ Batch table recognition failed: {e}")
+            return [{'html': '', 'markdown': '', 'cells': []} for _ in images]
+    
+    def batch_recognize_formula(
+        self, 
+        images: List[Union[np.ndarray, Image.Image]], 
+        **kwargs
+    ) -> List[Dict[str, Any]]:
+        """批量公式识别"""
+        if self.vlm_model is None:
+            raise RuntimeError("VL model not initialized")
+            
+        # 转换为PIL图像列表
+        pil_images = []
+        for img in images:
+            if isinstance(img, np.ndarray):
+                pil_images.append(Image.fromarray(img))
+            else:
+                pil_images.append(img)
+        
+        try:
+            # 批量调用 batch_content_extract,指定类型为 equation
+            formula_contents = self.vlm_model.batch_content_extract(
+                images=pil_images,
+                types="equation"
+            )
+            
+            # 格式化结果
+            results = []
+            for content in formula_contents:
+                latex = self._clean_latex(content) if content else ''
+                results.append({
+                    'latex': latex,
+                    'confidence': 0.9 if latex else 0.0,
+                    'raw': {'raw_output': content}
+                })
+            
+            return results
+            
+        except Exception as e:
+            print(f"❌ Batch formula recognition failed: {e}")
+            return [{'latex': '', 'confidence': 0.0, 'raw': {}} for _ in images]
+    
+    def _clean_latex(self, raw_latex: str) -> str:
+        """清理LaTeX格式"""
+        if not raw_latex:
+            return ''
+        
+        # 移除外层的 $$ 或 $
+        latex = raw_latex.strip()
+        if latex.startswith('$$') and latex.endswith('$$'):
+            latex = latex[2:-2].strip()
+        elif latex.startswith('$') and latex.endswith('$'):
+            latex = latex[1:-1].strip()
+        
+        return latex
+    
+    def _html_to_markdown(self, html: str) -> str:
+        """将HTML表格转换为Markdown格式"""
+        if not html:
+            return ''
+        
+        return html
+        # try:
+        #     # 简单的HTML到Markdown转换
+        #     # 实际应用中可以使用 markdownify 库
+        #     import re
+            
+        #     # 移除HTML标签,保留内容
+        #     markdown = re.sub(r'<tr[^>]*>', '\n', html)
+        #     markdown = re.sub(r'</tr>', '', markdown)
+        #     markdown = re.sub(r'<t[dh][^>]*>', '| ', markdown)
+        #     markdown = re.sub(r'</t[dh]>', ' ', markdown)
+        #     markdown = re.sub(r'<[^>]+>', '', markdown)
+            
+        #     return markdown.strip()
+            
+        # except Exception as e:
+        #     print(f"⚠️ HTML to Markdown conversion failed: {e}")
+        #     return html
+    
+class MinerUOCRRecognizer(BaseOCRRecognizer):
+    """MinerU OCR识别适配器"""
+    
+    def __init__(self, config: Dict[str, Any]):
+        super().__init__(config)
+        if not MINERU_AVAILABLE:
+            raise ImportError("MinerU components not available")
+            
+        self.atom_model_manager = AtomModelSingleton()
+        self.ocr_model = None
+        
+    def initialize(self):
+        """初始化OCR模型
+        
+        参数说明(参考PPStructureV3优化):
+        - det_db_box_thresh: 检测框置信度阈值,默认0.6(PPStructureV3使用0.6,MinerU默认0.3)
+        - det_db_unclip_ratio: 检测框扩展比例,默认1.5(PPStructureV3使用1.5,MinerU默认1.8)
+        - enable_merge_det_boxes: 是否合并检测框,默认False(表格场景建议False,避免错误合并)
+        """
+        try:
+            # 获取配置参数,使用更接近PPStructureV3的默认值
+            det_threshold = self.config.get('det_threshold', 0.6)  # 从0.3提高到0.6
+            unclip_ratio = self.config.get('unclip_ratio', 1.5)    # 从1.8降低到1.5
+            enable_merge = self.config.get('enable_merge_det_boxes', False)  # 从True改为False
+            
+            # 初始化OCR模型
+            self.ocr_model = self.atom_model_manager.get_atom_model(
+                atom_model_name=AtomicModel.OCR,
+                det_db_box_thresh=det_threshold,
+                lang=self.config.get('language', 'ch'),
+                det_db_unclip_ratio=unclip_ratio,
+                enable_merge_det_boxes=enable_merge,
+            )
+            print(f"✅ OCR recognizer initialized: lang={self.config.get('language', 'ch')}, "
+                  f"det_thresh={det_threshold}, unclip_ratio={unclip_ratio}, merge_boxes={enable_merge}")
+            
+        except Exception as e:
+            print(f"❌ Failed to initialize OCR recognizer: {e}")
+            raise
+        
+    def cleanup(self):
+        """清理资源"""
+        pass
+    
+    def recognize_text(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """文本识别"""
+        if self.ocr_model is None:
+            raise RuntimeError("OCR model not initialized")
+            
+        # 转换为BGR格式
+        if isinstance(image, Image.Image):
+            image = np.array(image)
+        bgr_image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
+        
+        try:
+            # OCR识别
+            ocr_results = self.ocr_model.ocr(bgr_image, rec=True)
+            
+            # 格式化结果
+            formatted_results = []
+            if ocr_results and ocr_results[0]:
+                for item in ocr_results[0]:
+                    if len(item) >= 2 and len(item[1]) >= 2:
+                        formatted_results.append({
+                            'bbox': item[0],  # 坐标
+                            'text': item[1][0],  # 识别文本
+                            'confidence': item[1][1]  # 置信度
+                        })
+                        
+            return formatted_results
+            
+        except Exception as e:
+            print(f"❌ OCR recognition failed: {e}")
+            return []
+
+# 导出适配器类
+__all__ = [
+    'MinerUPreprocessor',
+    'MinerULayoutDetector', 
+    'MinerUVLRecognizer',
+    'MinerUOCRRecognizer'
+]

+ 274 - 0
ocr_tools/universal_doc_parser/models/adapters/mineru_wired_table.py

@@ -0,0 +1,274 @@
+import sys
+from typing import Any, Dict, List, Tuple, cast
+
+import cv2
+import numpy as np
+
+# 确保 mineru 库可导入
+mineru_path = str((__file__ and __file__) and __file__)
+# 使用已有 mineru_adapter 中的路径追加逻辑
+from pathlib import Path
+mineru_root = Path(__file__).parents[4] / "mineru"
+if str(mineru_root) not in sys.path:
+    sys.path.insert(0, str(mineru_root))
+
+from mineru.model.table.rec.unet_table.main import UnetTableModel
+
+
+class MinerUWiredTableRecognizer:
+    """有线表格识别封装:裁剪+放大→UNet→坐标回写+按中心点匹配OCR文本"""
+
+    def __init__(self, config: Dict[str, Any], ocr_engine: Any):
+        self.config = config or {}
+        self.upscale_ratio: float = self.config.get("upscale_ratio", 10 / 3)
+        self.need_ocr: bool = self.config.get("need_ocr", True)
+        self.row_threshold: int = self.config.get("row_threshold", 10)
+        self.col_threshold: int = self.config.get("col_threshold", 15)
+        self.ocr_conf_threshold: float = self.config.get("ocr_conf_threshold", 0.5)
+        self.cell_crop_margin: int = self.config.get("cell_crop_margin", 2)
+        self.table_model = UnetTableModel(ocr_engine)
+        self.ocr_engine = ocr_engine
+
+    @staticmethod
+    def _to_unet_ocr_format(ocr_boxes: List[Dict[str, Any]]) -> List[List[Any]]:
+        """将OCR结果转成 UNet 期望格式 [[poly4,text,score], ...],坐标用浮点。"""
+        formatted = []
+        for item in ocr_boxes:
+            poly = item.get("bbox", [])
+            text = item.get("text", "")
+            score = item.get("confidence", 0.0)
+            if not poly or len(poly) < 4:
+                continue
+            # 统一成4点 (4,2)
+            if len(poly) == 8:
+                poly_pts = [[float(poly[i]), float(poly[i + 1])] for i in range(0, 8, 2)]
+            elif len(poly) == 4:
+                x1, y1, x2, y2 = poly
+                poly_pts = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
+            else:
+                # 其他格式跳过
+                continue
+            formatted.append([poly_pts, text, float(score)])
+        return formatted
+
+    @staticmethod
+    def _scale_poly(poly: List[List[float]], ratio: float) -> List[List[float]]:
+        return [[p[0] * ratio, p[1] * ratio] for p in poly]
+
+    @staticmethod
+    def _poly_to_bbox(poly: np.ndarray) -> List[float]:
+        xs = poly[:, 0]
+        ys = poly[:, 1]
+        return [float(xs.min()), float(ys.min()), float(xs.max()), float(ys.max())]
+
+    def _match_text_by_center(
+        self,
+        cells_bbox: List[List[float]],
+        ocr_boxes: List[Dict[str, Any]],
+    ) -> List[str]:
+        """使用中心点落格分配文本,行内按 y 排序后拼接。"""
+        texts_per_cell: List[str] = []
+        centers = []
+        for item in ocr_boxes:
+            poly = item.get("bbox", [])
+            if not poly:
+                continue
+            if len(poly) == 8:
+                xs = [poly[i] for i in range(0, 8, 2)]
+                ys = [poly[i] for i in range(1, 8, 2)]
+                cx = (min(xs) + max(xs)) / 2
+                cy = (min(ys) + max(ys)) / 2
+            elif len(poly) == 4:
+                x1, y1, x2, y2 = poly
+                cx = (x1 + x2) / 2
+                cy = (y1 + y2) / 2
+            else:
+                continue
+            centers.append((cx, cy, item.get("text", ""), item.get("confidence", 0.0)))
+
+        for bbox in cells_bbox:
+            x1, y1, x2, y2 = bbox
+            collected = [(t, cy) for cx, cy, t, conf in centers if x1 <= cx <= x2 and y1 <= cy <= y2]
+            collected.sort(key=lambda x: x[1])
+            cell_text = " ".join([t for t, _ in collected]) if collected else ""
+            texts_per_cell.append(cell_text)
+        return texts_per_cell
+
+    def recognize(
+        self,
+        table_image: np.ndarray,
+        ocr_boxes: List[Dict[str, Any]],
+    ) -> Dict[str, Any]:
+        """运行有线表格识别并输出 HTML + cells(原图坐标)。"""
+        # 预放大
+        upscale = self.upscale_ratio if self.upscale_ratio and self.upscale_ratio > 0 else 1.0
+        h, w = table_image.shape[:2]
+        if upscale != 1.0:
+            table_image_up = cv2.resize(table_image, (int(w * upscale), int(h * upscale)), interpolation=cv2.INTER_CUBIC)
+        else:
+            table_image_up = table_image
+
+        # OCR 坐标缩放到放大后
+        ocr_scaled = []
+        for item in ocr_boxes or []:
+            poly = item.get("bbox", [])
+            if not poly:
+                continue
+            if len(poly) == 8:
+                scaled = [p * upscale for p in poly]
+            elif len(poly) == 4:
+                x1, y1, x2, y2 = poly
+                scaled = [x1 * upscale, y1 * upscale, x2 * upscale, y2 * upscale]
+            else:
+                continue
+            ocr_scaled.append({"bbox": scaled, "text": item.get("text", ""), "confidence": item.get("confidence", 0.0)})
+
+        # 组装 UNet OCR 输入格式
+        unet_ocr = self._to_unet_ocr_format(ocr_scaled)
+
+        # 调用 UNet - 使用单次 wired_table_model 调用获取所有结果
+        html_code = ""
+        cell_polys_flat = None
+        logic_points = None
+        try:
+            # 类型忽略:UnetTableModel 期望 [[poly,text,score], ...]
+            # 单次调用 wired_table_model,返回 WiredTableOutput namedtuple
+            # 包含: pred_html, cell_bboxes, logic_points, elapse
+            wired_out = self.table_model.wired_table_model(
+                table_image_up,
+                unet_ocr,  # type: ignore[arg-type]
+                col_threshold=self.col_threshold,
+                row_threshold=self.row_threshold,
+            )
+            # 统一从 wired_out 提取所有数据
+            html_code = wired_out.pred_html if wired_out.pred_html else ""
+            cell_polys_flat = wired_out.cell_bboxes
+            logic_points = wired_out.logic_points
+        except Exception:
+            html_code = ""
+
+        cells = []
+        if cell_polys_flat is not None and logic_points is not None:
+            polys = np.array(cell_polys_flat, dtype=float).reshape(-1, 4, 2)
+            # 缩回裁剪坐标
+            polys /= upscale
+            bboxes = [self._poly_to_bbox(poly) for poly in polys]
+            texts = self._match_text_by_center(bboxes, ocr_boxes or [])
+            # 对空文本单元格触发单元格级 OCR 补充
+            if self.ocr_engine is not None and any(not t for t in texts):
+                crop_list = []
+                crop_info = []
+                h, w = table_image.shape[:2]
+                margin = self.cell_crop_margin
+                for idx, bbox in enumerate(bboxes):
+                    if texts[idx]:
+                        continue
+                    x1, y1, x2, y2 = bbox
+                    x1i, y1i, x2i, y2i = map(int, [x1, y1, x2, y2])
+                    
+                    # 增加裁剪边距防止文字被截断(特别是边界字符如"司")
+                    x1i = max(0, x1i - margin)
+                    y1i = max(0, y1i - margin)
+                    x2i = min(w, x2i + margin)
+                    y2i = min(h, y2i + margin)
+                    
+                    if x2i <= x1i or y2i <= y1i:
+                        continue
+                    crop = table_image[y1i:y2i, x1i:x2i]
+                    if crop.size == 0:
+                        continue
+                    crop_list.append(crop)
+                    crop_info.append(idx)
+                if crop_list:
+                    try:
+                        ocr_res = self.ocr_engine.ocr(crop_list, det=False)
+                        if ocr_res and isinstance(ocr_res, list) and len(ocr_res) == 1:
+                            for loc, (text, score) in zip(crop_info, ocr_res[0]):
+                                if score >= self.ocr_conf_threshold and text:
+                                    texts[loc] = text
+                    except Exception:
+                        pass
+            for idx, bbox in enumerate(bboxes):
+                lp = logic_points[idx] if len(logic_points) > idx else [0, 0, 0, 0]
+                cells.append({
+                    "bbox": bbox,
+                    "row": int(lp[0]) if len(lp) > 0 else 0,
+                    "col": int(lp[2]) if len(lp) > 2 else 0,
+                    "text": texts[idx] if idx < len(texts) else "",
+                    "matched_text": texts[idx] if idx < len(texts) else "",
+                    "score": 100.0,
+                })
+
+        # 通过BeautifulSoup增强HTML,添加data-bbox和data-score属性(保留原始HTML结构)
+        html_enhanced = self._enhance_html_with_cell_data(html_code, cells)
+
+        return {
+            "html": html_enhanced or html_code or "",
+            "cells": cells,
+        }
+    
+    def _enhance_html_with_cell_data(self, html_code: str, cells: List[Dict[str, Any]]) -> str:
+        """通过BeautifulSoup增强HTML,为每个td添加data-bbox和data-score属性
+        
+        保留原始MinerU的rowspan/colspan等属性,只增加定位信息。按照cells的row/col与HTML中td的位置关系进行匹配。
+        
+        Args:
+            html_code: MinerU生成的原始HTML
+            cells: 单元格列表,包含bbox、row、col等信息
+        
+        Returns:
+            增强后的HTML字符串,包含data-bbox和data-score属性
+        """
+        if not html_code or not cells:
+            return html_code
+        
+        try:
+            from bs4 import BeautifulSoup
+        except ImportError:
+            return html_code
+        
+        soup = BeautifulSoup(html_code, 'html.parser')
+        
+        # 建立cell快速查询字典:(row, col) -> cell
+        cell_dict = {}
+        for cell in cells:
+            row = cell.get("row", 0)
+            col = cell.get("col", 0)
+            key = (row, col)
+            cell_dict[key] = cell
+        
+        # 遍历HTML中的所有tr和td,按行列顺序进行匹配
+        rows = soup.find_all('tr')
+        for row_idx, tr in enumerate(rows):
+            tds = tr.find_all('td')  # type: ignore
+            col_idx = 0
+            for td in tds:
+                # 获取colspan和rowspan属性
+                colspan_str = td.get('colspan')  # type: ignore
+                rowspan_str = td.get('rowspan')  # type: ignore
+                try:
+                    colspan = int(str(colspan_str)) if colspan_str else 1
+                    rowspan = int(str(rowspan_str)) if rowspan_str else 1
+                except (ValueError, TypeError):
+                    colspan = 1
+                    rowspan = 1
+                
+                # 根据row_idx和col_idx查找对应的cell
+                cell_key = (row_idx, col_idx)
+                if cell_key in cell_dict:
+                    cell = cell_dict[cell_key]
+                    bbox = cell.get("bbox", [])
+                    score = cell.get("score", 100.0)
+                    
+                    # 添加data-bbox属性
+                    if bbox and len(bbox) >= 4:
+                        bbox_str = ",".join(map(str, map(int, bbox[:4])))
+                        td['data-bbox'] = f"[{bbox_str}]"  # type: ignore
+                    
+                    # 添加data-score属性
+                    td['data-score'] = f"{score:.4f}"  # type: ignore
+                
+                # 更新列索引(考虑colspan)
+                col_idx += colspan
+        
+        return str(soup)

+ 693 - 0
ocr_tools/universal_doc_parser/models/adapters/paddle_layout_detector.py

@@ -0,0 +1,693 @@
+"""使用 ONNX Runtime 进行布局检测的统一接口 (符合 BaseLayoutDetector 规范)"""
+
+import cv2
+import numpy as np
+import onnxruntime as ort
+from pathlib import Path
+from typing import Dict, List, Tuple, Union, Any
+from PIL import Image
+import sys
+
+try:
+    from .base import BaseLayoutDetector
+except ImportError:
+    # 如果相对导入失败,尝试绝对导入(适用于测试环境)
+    from base import BaseLayoutDetector
+
+class PaddleLayoutDetector(BaseLayoutDetector):
+    """PaddleX RT-DETR 布局检测器 (ONNX 版本)"""
+    
+    # 类别映射:PaddleX RT-DETR-H_layout_17cls → MinerU/EnhancedDocPipeline 类别体系
+    # 参考:
+    # - MinerU: mineru/utils/enum_class.py (BlockType, CategoryId)
+    # - Pipeline: universal_doc_parser/core/pipeline_manager_v2.py (EnhancedDocPipeline 类别定义)
+    CATEGORY_MAP = {
+        0: 'title',              # paragraph_title -> title (TEXT_CATEGORIES)
+        1: 'image_body',         # image -> image_body (IMAGE_BODY_CATEGORIES)
+        2: 'text',               # text -> text (TEXT_CATEGORIES)
+        3: 'text',               # number -> text (TEXT_CATEGORIES)
+        4: 'text',               # abstract -> text (TEXT_CATEGORIES)
+        5: 'text',               # content -> text (TEXT_CATEGORIES)
+        6: 'image_caption',      # figure_title -> image_caption (IMAGE_TEXT_CATEGORIES)
+        7: 'interline_equation', # formula -> interline_equation (EQUATION_CATEGORIES)
+        8: 'table_body',         # table -> table_body (TABLE_BODY_CATEGORIES)
+        9: 'table_caption',      # table_title -> table_caption (TABLE_TEXT_CATEGORIES)
+        10: 'ref_text',          # reference -> ref_text (TEXT_CATEGORIES)
+        11: 'title',             # doc_title -> title (TEXT_CATEGORIES)
+        12: 'page_footnote',     # footnote -> page_footnote (TEXT_CATEGORIES)
+        13: 'header',            # header -> header (TEXT_CATEGORIES)
+        14: 'algorithm',         # algorithm -> algorithm (CODE_CATEGORIES)
+        15: 'footer',            # footer -> footer (TEXT_CATEGORIES)
+        16: 'abandon'            # seal -> abandon (DISCARD_CATEGORIES)
+    }
+    
+    ORIGINAL_CATEGORY_NAMES = {
+        0: 'paragraph_title',
+        1: 'image',
+        2: 'text',
+        3: 'number',
+        4: 'abstract',
+        5: 'content',
+        6: 'figure_title',
+        7: 'formula',
+        8: 'table',
+        9: 'table_title',
+        10: 'reference',
+        11: 'doc_title',
+        12: 'footnote',
+        13: 'header',
+        14: 'algorithm',
+        15: 'footer',
+        16: 'seal'
+    }
+    
+    def __init__(self, config: Dict[str, Any]):
+        super().__init__(config)
+        self.session = None
+        self.inputs = {}
+        self.outputs = {}
+        self.target_size = 640
+    
+    def initialize(self):
+        """初始化 ONNX 模型"""
+        try:
+            onnx_path = self.config.get('model_dir')
+            if not onnx_path:
+                raise ValueError("model_dir not specified in config")
+            
+            if not Path(onnx_path).exists():
+                raise FileNotFoundError(f"ONNX model not found: {onnx_path}")
+            
+            # 根据配置选择执行提供器
+            device = self.config.get('device', 'cpu')
+            if device == 'gpu':
+                # Mac 支持 CoreML
+                providers = ['CoreMLExecutionProvider', 'CPUExecutionProvider']
+            else:
+                providers = ['CPUExecutionProvider']
+            
+            self.session = ort.InferenceSession(onnx_path, providers=providers)
+            
+            # 获取模型输入输出信息
+            self.inputs = {inp.name: inp for inp in self.session.get_inputs()}
+            self.outputs = {out.name: out for out in self.session.get_outputs()}
+            
+            # 自动检测输入尺寸
+            self.target_size = self._detect_input_size()
+            
+            print(f"✅ PaddleX Layout Detector initialized")
+            print(f"   - Model: {Path(onnx_path).name}")
+            print(f"   - Target size: {self.target_size}")
+            print(f"   - Device: {device}")
+            print(f"   - Providers: {self.session.get_providers()}")
+            
+        except Exception as e:
+            print(f"❌ Failed to initialize PaddleX Layout Detector: {e}")
+            raise
+    
+    def cleanup(self):
+        """清理资源"""
+        self.session = None
+        self.inputs = {}
+        self.outputs = {}
+    
+    def detect(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """
+        检测布局
+        
+        Args:
+            image: 输入图像 (numpy数组或PIL图像)
+            
+        Returns:
+            检测结果列表,每个元素包含:
+            - category: MinerU类别名称
+            - bbox: [x1, y1, x2, y2]
+            - confidence: 置信度
+            - raw: 原始检测结果
+        """
+        if self.session is None:
+            raise RuntimeError("Model not initialized. Call initialize() first.")
+        
+        # 转换为numpy数组
+        if isinstance(image, Image.Image):
+            image = np.array(image)
+            if image.ndim == 2:  # 灰度图
+                image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
+            elif image.shape[2] == 4:  # RGBA
+                image = cv2.cvtColor(image, cv2.COLOR_RGBA2BGR)
+            elif image.shape[2] == 3:  # RGB
+                image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
+        
+        # 执行预测
+        conf_threshold = self.config.get('conf', 0.25)
+        results = self._predict(image, conf_threshold)
+        
+        # 转换为 MinerU 格式
+        formatted_results = []
+        for result in results:
+            # 映射类别
+            original_category_id = result['category_id']
+            mineru_category = self.CATEGORY_MAP.get(original_category_id, 'text')
+            
+            formatted_results.append({
+                'category': mineru_category,
+                'bbox': result['bbox'],
+                'confidence': result['score'],
+                'raw': {
+                    'original_category_id': original_category_id,
+                    'original_category_name': result['category_name'],
+                    'poly': result['poly'],
+                    'width': result['width'],
+                    'height': result['height']
+                }
+            })
+        
+        return formatted_results
+    
+    def _detect_input_size(self) -> int:
+        """自动检测模型的输入尺寸"""
+        if 'image' in self.inputs:
+            shape = self.inputs['image'].shape
+            # shape 通常是 [batch, channels, height, width]
+            if len(shape) >= 3:
+                # 尝试从 shape[2] 或 shape[3] 获取尺寸
+                for dim in shape[2:]:
+                    if isinstance(dim, int) and dim > 0:
+                        return dim
+        return 640  # 默认值
+    
+    def _preprocess(
+        self, 
+        img: np.ndarray
+    ) -> Tuple[Dict[str, np.ndarray], Tuple[float, float], Tuple[int, int]]:
+        """
+        预处理图像 (根据 RT-DETR 的配置)
+        
+        Returns:
+            input_dict: 包含所有输入的字典
+            scale: (scale_h, scale_w) 缩放因子
+            orig_shape: (h, w) 原始图像尺寸
+        """
+        orig_h, orig_w = img.shape[:2]
+        target_size = self.target_size  # 640
+        
+        # 1. Resize 到目标尺寸 (不保持长宽比)
+        img_resized = cv2.resize(
+            img, 
+            (target_size, target_size), 
+            interpolation=cv2.INTER_LINEAR
+        )
+        
+        # 2. 转换为 RGB
+        img_rgb = cv2.cvtColor(img_resized, cv2.COLOR_BGR2RGB)
+        
+        # ✅ 修正 3: 归一化 (mean=[0,0,0], std=[1,1,1], norm_type=none)
+        # 只做 /255,不做均值减法和标准差除法
+        img_normalized = img_rgb.astype(np.float32) / 255.0
+        
+        # 4. 转换为 CHW 格式
+        img_chw = img_normalized.transpose(2, 0, 1)
+        img_tensor = img_chw[None, ...].astype(np.float32)  # [1, 3, H, W]
+        
+        # 5. 准备所有输入
+        input_dict = {}
+        
+        # 主图像输入
+        if 'image' in self.inputs:
+            input_dict['image'] = img_tensor
+        elif 'images' in self.inputs:
+            input_dict['images'] = img_tensor
+        else:
+            # 使用第一个输入
+            first_input_name = list(self.inputs.keys())[0]
+            input_dict[first_input_name] = img_tensor
+        
+        # ✅ 修正 4: 计算缩放因子 (实际图像尺寸 / 目标尺寸)
+        scale_h = orig_h / target_size
+        scale_w = orig_w / target_size
+        
+        # im_shape 输入 (原始图像尺寸)
+        if 'im_shape' in self.inputs:
+            im_shape = np.array([[float(orig_h), float(orig_w)]], dtype=np.float32)
+            input_dict['im_shape'] = im_shape
+        
+        # scale_factor 输入
+        if 'scale_factor' in self.inputs:
+            # ⚠️ 注意:这里是原始尺寸/目标尺寸的比例
+            scale_factor = np.array([[scale_h, scale_w]], dtype=np.float32)
+            input_dict['scale_factor'] = scale_factor
+        
+        # ✅ 返回的 scale 用于后处理坐标还原
+        # 因为不保持长宽比,所以需要分别记录 x 和 y 的缩放
+        return input_dict, (scale_h, scale_w), (orig_h, orig_w)
+    
+    def _postprocess(
+        self, 
+        outputs: List[np.ndarray], 
+        scale: Tuple[float, float],  # (scale_h, scale_w)
+        orig_shape: Tuple[int, int],
+        conf_threshold: float = 0.5
+    ) -> List[Dict]:
+        """
+        后处理模型输出
+        
+        Args:
+            outputs: ONNX 模型输出
+            scale: (scale_h, scale_w) 缩放因子
+            orig_shape: (h, w) 原始图像尺寸
+            conf_threshold: 置信度阈值
+            
+        Returns:
+            检测结果列表
+        """
+        scale_h, scale_w = scale
+        orig_h, orig_w = orig_shape
+        
+        # 解析输出格式
+        if len(outputs) >= 2:
+            output0_shape = outputs[0].shape
+            output1_shape = outputs[1].shape
+            
+            # RT-DETR ONNX 格式: (num_boxes, 6)
+            # 格式: [label_id, score, x1, y1, x2, y2]
+            if len(output0_shape) == 2 and output0_shape[1] == 6:
+                pred = outputs[0]
+                labels = pred[:, 0].astype(int)
+                scores = pred[:, 1]
+                bboxes = pred[:, 2:6].copy()  # [x1, y1, x2, y2] - 在 640×640 尺度上
+                
+            # 情况2: output0 是 (batch, num_boxes, 6) - 带batch的合并格式
+            elif len(output0_shape) == 3 and output0_shape[2] == 6:
+                pred = outputs[0][0]
+                labels = pred[:, 0].astype(int)
+                scores = pred[:, 1]
+                bboxes = pred[:, 2:6].copy()
+                
+            # 情况3: output0 是 bboxes, output1 是 scores (分离格式)
+            elif len(output0_shape) == 2 and output0_shape[1] == 4:
+                bboxes = outputs[0].copy()
+                if len(output1_shape) == 1:
+                    scores = outputs[1]
+                    labels = np.zeros(len(scores), dtype=int)
+                elif len(output1_shape) == 2:
+                    scores_all = outputs[1]
+                    scores = scores_all.max(axis=1)
+                    labels = scores_all.argmax(axis=1)
+                else:
+                    raise ValueError(f"Unexpected output1 shape: {output1_shape}")
+        
+            # 情况4: RT-DETR 格式 (batch, num_boxes, 4) + (batch, num_boxes, num_classes)
+            elif len(output0_shape) == 3 and output0_shape[2] == 4:
+                bboxes = outputs[0][0].copy()
+                scores_all = outputs[1][0]
+                scores = scores_all.max(axis=1)
+                labels = scores_all.argmax(axis=1)
+            
+            else:
+                raise ValueError(f"Unexpected output format: {output0_shape}, {output1_shape}")
+        
+        elif len(outputs) == 1:
+            # 单一输出
+            output_shape = outputs[0].shape
+            
+            if len(output_shape) == 2 and output_shape[1] == 6:
+                pred = outputs[0]
+                labels = pred[:, 0].astype(int)
+                scores = pred[:, 1]
+                bboxes = pred[:, 2:6].copy()
+            
+            elif len(output_shape) == 3 and output_shape[2] == 6:
+                pred = outputs[0][0]
+                labels = pred[:, 0].astype(int)
+                scores = pred[:, 1]
+                bboxes = pred[:, 2:6].copy()
+            
+            else:
+                raise ValueError(f"Unexpected single output shape: {output_shape}")
+        
+        else:
+            raise ValueError(f"Unexpected number of outputs: {len(outputs)}")
+        
+        # 将坐标从 640×640 还原到原图尺度
+        bboxes[:, [0, 2]] *= scale_w
+        bboxes[:, [1, 3]] *= scale_h
+        
+        # 自适应阈值
+        max_score = scores.max() if len(scores) > 0 else 0
+        if max_score < conf_threshold:
+            adjusted_threshold = max(max_score * 0.5, 0.05)
+            conf_threshold = adjusted_threshold
+        
+        # 过滤低分框
+        mask = scores > conf_threshold
+        bboxes = bboxes[mask]
+        scores = scores[mask]
+        labels = labels[mask]
+        
+        # 过滤完全在图像外的框
+        valid_mask = (
+            (bboxes[:, 2] > 0) &  # x2 > 0
+            (bboxes[:, 3] > 0) &  # y2 > 0
+            (bboxes[:, 0] < orig_w) &  # x1 < width
+            (bboxes[:, 1] < orig_h)    # y1 < height
+        )
+        bboxes = bboxes[valid_mask]
+        scores = scores[valid_mask]
+        labels = labels[valid_mask]
+        
+        # 裁剪坐标到图像范围
+        bboxes[:, [0, 2]] = np.clip(bboxes[:, [0, 2]], 0, orig_w)
+        bboxes[:, [1, 3]] = np.clip(bboxes[:, [1, 3]], 0, orig_h)
+        
+        # 构造结果
+        results = []
+        for box, score, label in zip(bboxes, scores, labels):
+            x1, y1, x2, y2 = box
+            
+            # 过滤无效框
+            width = x2 - x1
+            height = y2 - y1
+            
+            # 过滤太小的框
+            if width < 10 or height < 10:
+                continue
+            
+            # 过滤面积异常大的框
+            area = width * height
+            img_area = orig_w * orig_h
+            if area > img_area * 0.95:
+                continue
+            
+            results.append({
+                'category_id': int(label),
+                'category_name': self.ORIGINAL_CATEGORY_NAMES.get(int(label), f'unknown_{label}'),
+                'bbox': [int(x1), int(y1), int(x2), int(y2)],
+                'poly': [int(x1), int(y1), int(x2), int(y1), int(x2), int(y2), int(x1), int(y2)],
+                'score': float(score),
+                'width': int(width),
+                'height': int(height)
+            })
+        
+        return results
+    
+    def _predict(
+        self, 
+        img: np.ndarray, 
+        conf_threshold: float = 0.25
+    ) -> List[Dict]:
+        """执行预测"""
+        # 预处理
+        input_dict, scale, orig_shape = self._preprocess(img)
+        
+        # ONNX 推理
+        output_names = [out.name for out in self.session.get_outputs()]
+        outputs = self.session.run(output_names, input_dict)
+        
+        # 后处理
+        results = self._postprocess(outputs, scale, orig_shape, conf_threshold)
+        
+        return results
+
+    def visualize(
+        self, 
+        img: np.ndarray, 
+        results: List[Dict],
+        output_path: str = None,
+        show_confidence: bool = True,
+        min_confidence: float = 0.0
+    ) -> np.ndarray:
+        """
+        可视化检测结果
+        
+        Args:
+            img: 输入图像
+            results: 检测结果 (MinerU格式)
+            output_path: 输出路径(可选)
+            show_confidence: 是否显示置信度
+            min_confidence: 最小置信度阈值
+            
+        Returns:
+            标注后的图像
+        """
+        import random
+        
+        vis_img = img.copy()
+        
+        # 为每个类别分配固定颜色
+        category_colors = {}
+        
+        # 预定义类别颜色(与 EnhancedDocPipeline 类别定义保持一致)
+        predefined_colors = {
+            # 文本类
+            'text': (153, 0, 76),              # 深红
+            'title': (102, 102, 255),          # 蓝色
+            'header': (128, 128, 128),         # 灰色
+            'footer': (128, 128, 128),         # 灰色
+            'ref_text': (180, 180, 180),       # 浅灰
+            'page_footnote': (200, 200, 200),  # 浅灰
+            # 表格类
+            'table_body': (204, 204, 0),       # 黄色
+            'table_caption': (255, 255, 102),  # 浅黄
+            'table_footnote': (229, 255, 204), # 浅黄绿
+            # 图片类
+            'image_body': (153, 255, 51),      # 绿色
+            'image_caption': (102, 178, 255),  # 浅蓝
+            # 公式类
+            'interline_equation': (0, 255, 0), # 亮绿
+            # 代码类
+            'algorithm': (128, 0, 255),        # 紫色
+            'code': (102, 0, 204),             # 紫色
+            # 丢弃类
+            'abandon': (100, 100, 100),        # 深灰
+        }
+        
+        # 过滤低置信度结果
+        filtered_results = [
+            res for res in results 
+            if res['confidence'] >= min_confidence
+        ]
+        
+        if not filtered_results:
+            print(f"⚠️ No results to visualize (min_confidence={min_confidence})")
+            return vis_img
+        
+        # 为每个出现的类别分配颜色
+        for res in filtered_results:
+            cat = res['category']
+            if cat not in category_colors:
+                if cat in predefined_colors:
+                    category_colors[cat] = predefined_colors[cat]
+                else:
+                    # 随机生成颜色
+                    category_colors[cat] = (
+                        random.randint(50, 255),
+                        random.randint(50, 255),
+                        random.randint(50, 255)
+                    )
+        
+        # 绘制检测框
+        for res in filtered_results:
+            bbox = res['bbox']
+            x1, y1, x2, y2 = bbox
+            cat = res['category']
+            confidence = res['confidence']
+            color = category_colors[cat]
+            
+            # 绘制矩形边框
+            cv2.rectangle(vis_img, (x1, y1), (x2, y2), color, 2)
+            
+            # 构造标签文本
+            if show_confidence:
+                label = f"{cat} {confidence:.2f}"
+            else:
+                label = cat
+            
+            # 计算标签尺寸
+            label_size, baseline = cv2.getTextSize(
+                label, 
+                cv2.FONT_HERSHEY_SIMPLEX, 
+                0.5, 
+                1
+            )
+            label_w, label_h = label_size
+            
+            # 绘制标签背景 (填充矩形)
+            cv2.rectangle(
+                vis_img,
+                (x1, y1 - label_h - 4),
+                (x1 + label_w, y1),
+                color,
+                -1  # 填充
+            )
+            
+            # 绘制标签文字 (白色)
+            cv2.putText(
+                vis_img,
+                label,
+                (x1, y1 - 2),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                0.5,
+                (255, 255, 255),  # 白色文字
+                1,
+                cv2.LINE_AA
+            )
+        
+        # 添加图例 (在图像右上角)
+        if category_colors:
+            self._draw_legend(vis_img, category_colors, len(filtered_results))
+        
+        # 保存可视化结果
+        if output_path:
+            output_path = Path(output_path)
+            output_path.parent.mkdir(parents=True, exist_ok=True)
+            cv2.imwrite(str(output_path), vis_img)
+            print(f"💾 Visualization saved to: {output_path}")
+        
+        return vis_img
+    
+    def _draw_legend(
+        self, 
+        img: np.ndarray, 
+        category_colors: Dict[str, tuple],
+        total_count: int
+    ):
+        """
+        在图像上绘制图例
+        
+        Args:
+            img: 图像
+            category_colors: 类别颜色映射
+            total_count: 总检测数量
+        """
+        legend_x = img.shape[1] - 200  # 右侧留200像素
+        legend_y = 20
+        line_height = 25
+        
+        # 绘制半透明背景
+        overlay = img.copy()
+        cv2.rectangle(
+            overlay,
+            (legend_x - 10, legend_y - 10),
+            (img.shape[1] - 10, legend_y + len(category_colors) * line_height + 30),
+            (255, 255, 255),
+            -1
+        )
+        cv2.addWeighted(overlay, 0.7, img, 0.3, 0, img)
+        
+        # 绘制标题
+        cv2.putText(
+            img,
+            f"Legend ({total_count} total)",
+            (legend_x, legend_y),
+            cv2.FONT_HERSHEY_SIMPLEX,
+            0.5,
+            (0, 0, 0),
+            1,
+            cv2.LINE_AA
+        )
+        
+        # 绘制每个类别
+        y_offset = legend_y + line_height
+        for cat, color in sorted(category_colors.items()):
+            # 绘制颜色方块
+            cv2.rectangle(
+                img,
+                (legend_x, y_offset - 10),
+                (legend_x + 15, y_offset),
+                color,
+                -1
+            )
+            cv2.rectangle(
+                img,
+                (legend_x, y_offset - 10),
+                (legend_x + 15, y_offset),
+                (0, 0, 0),
+                1
+            )
+            
+            # 绘制类别名称
+            cv2.putText(
+                img,
+                cat,
+                (legend_x + 20, y_offset - 2),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                0.4,
+                (0, 0, 0),
+                1,
+                cv2.LINE_AA
+            )
+            
+            y_offset += line_height
+
+
+# 测试代码
+if __name__ == "__main__":
+    import yaml
+    
+    # 测试配置
+    config = {
+        'model_dir': '/Users/zhch158/workspace/repository.git/PaddleX/zhch/unified_pytorch_models/Layout/RT-DETR-H_layout_17cls.onnx',
+        'device': 'cpu',
+        'conf': 0.25
+    }
+    
+    # 初始化检测器
+    print("🔧 Initializing detector...")
+    detector = PaddleLayoutDetector(config)
+    detector.initialize()
+    
+    # 读取测试图像
+    img_path = "/Users/zhch158/workspace/data/流水分析/康强_北京农村商业银行/ppstructurev3_client_results/康强_北京农村商业银行/康强_北京农村商业银行_page_001.png"
+    print(f"\n📖 Loading image: {img_path}")
+    img = cv2.imread(img_path)
+    
+    if img is None:
+        print(f"❌ Failed to load image: {img_path}")
+        exit(1)
+    
+    print(f"   Image shape: {img.shape}")
+    
+    # 执行检测
+    print("\n🔍 Detecting layout...")
+    results = detector.detect(img)
+    
+    print(f"\n✅ 检测到 {len(results)} 个区域:")
+    for i, res in enumerate(results, 1):
+        print(f"  [{i}] {res['category']}: "
+              f"score={res['confidence']:.3f}, "
+              f"bbox={res['bbox']}, "
+              f"original={res['raw']['original_category_name']}")
+    
+    # 统计各类别
+    category_counts = {}
+    for res in results:
+        cat = res['category']
+        category_counts[cat] = category_counts.get(cat, 0) + 1
+    
+    print(f"\n📊 类别统计 (MinerU格式):")
+    for cat, count in sorted(category_counts.items()):
+        print(f"  - {cat}: {count}")
+    
+    # 使用新的可视化方法
+    if len(results) > 0:
+        print("\n🎨 Generating visualization...")
+        
+        # 创建输出目录
+        output_dir = Path(__file__).parent.parent.parent / "tests" / "output"
+        output_dir.mkdir(parents=True, exist_ok=True)
+        output_path = output_dir / f"{Path(img_path).stem}_layout_vis.jpg"
+        
+        # 调用可视化方法
+        vis_img = detector.visualize(
+            img, 
+            results, 
+            output_path=str(output_path),
+            show_confidence=True,
+            min_confidence=0.0
+        )
+        
+        print(f"💾 Visualization saved to: {output_path}")
+    
+    # 清理
+    detector.cleanup()
+    print("\n✅ 测试完成!")

+ 107 - 0
ocr_tools/universal_doc_parser/models/adapters/paddle_vl_adapter.py

@@ -0,0 +1,107 @@
+import sys
+from pathlib import Path
+from typing import Dict, Any, List, Union, Optional
+import numpy as np
+from PIL import Image
+from loguru import logger
+
+# 导入基类
+from .mineru_adapter import MinerUVLRecognizer
+
+# 导入 mineru-vl-utils 的客户端
+try:
+    from mineru_vl_utils import MinerUClient
+    MINERU_VL_UTILS_AVAILABLE = True
+except ImportError as e:
+    logger.warning(f"mineru-vl-utils not available: {e}")
+    MINERU_VL_UTILS_AVAILABLE = False
+
+
+class PaddleVLRecognizer(MinerUVLRecognizer):
+    """
+    PaddleOCR-VL识别适配器,继承自MinerUVLRecognizer
+    
+    主要差异:
+    1. 强制使用 PaddleOCR-VL-0.9B 模型
+    2. 确保使用 vllm-server 后端
+    3. 复用所有MinerU的预处理/后处理逻辑
+    """
+    
+    def __init__(self, config: Dict[str, Any]):
+        # 🔧 强制设置 PaddleOCR-VL 模型名称
+        config['model_name'] = 'PaddleOCR-VL-0.9B'
+        
+        # 🔧 确保使用正确的后端配置
+        if config.get('backend') not in ['http-client']:
+            logger.error(
+                f"Backend '{config.get('backend')}' may not be optimal for PaddleOCR-VL. "
+                f"must: 'http-client'"
+            )
+        
+        # 调用父类初始化
+        super().__init__(config)
+        
+    def initialize(self):
+        """初始化VL模型 - 使用MinerU的客户端"""
+        if not MINERU_VL_UTILS_AVAILABLE:
+            raise ImportError("mineru-vl-utils is required for PaddleVLRecognizer")
+            
+        try:
+            backend = self.config.get('backend', 'http-client')
+            server_url = self.config.get('server_url')
+            model_params = self.config.get('model_params', {})
+            
+            # 🔧 提取 MinerUClient 所需的参数
+            # 从 model_params 中获取,如果没有则使用默认值
+            max_concurrency = model_params.get('max_concurrency', 100)
+            http_timeout = model_params.get('http_timeout', 600)
+            
+            # 🔧 PaddleOCR-VL 特定的提示词(可选)
+            prompts = model_params.get('prompts', {
+                "table": "\nTable Recognition:",
+                "equation": "\nFormula Recognition:",
+                "[default]": "\nText Recognition:",
+                "[layout]": "\nLayout Detection:",
+            })
+            
+            # 🔧 初始化 MinerUClient
+            logger.info(f"Initializing PaddleOCR-VL with backend: {backend}")
+            logger.info(f"Server URL: {server_url}")
+            logger.info(f"Max concurrency: {max_concurrency}")
+            
+            # 根据后端类型调整参数
+            if backend == 'http-client':
+                # HTTP客户端模式
+                self.vlm_model = MinerUClient(
+                    backend=backend,
+                    model_name=self.config['model_name'],
+                    server_url=server_url,
+                    prompts=prompts,
+                    max_concurrency=max_concurrency,
+                    http_timeout=http_timeout,
+                    use_tqdm=False,  # 可根据需要调整
+                )
+            else:
+                raise ValueError(f"Unsupported backend for PaddleOCR-VL: {backend}")
+            
+            logger.success(f"✅ PaddleOCR-VL recognizer initialized: {backend}")
+            
+        except Exception as e:
+            logger.error(f"❌ Failed to initialize PaddleOCR-VL recognizer: {e}")
+            raise
+    
+    # 以下方法都继承自 MinerUVLRecognizer,无需重写:
+    # - cleanup()
+    # - _preprocess_image()
+    # - recognize_table()
+    # - recognize_formula()
+    # - recognize_text()
+    # - batch_recognize_table()
+    # - batch_recognize_formula()
+    # - _clean_latex()
+    # - _html_to_markdown()
+    # - _extract_cells_from_html()
+
+
+# 导出适配器类
+__all__ = ['PaddleVLRecognizer']

BIN
ocr_tools/universal_doc_parser/tests/2023年度报告母公司_page_003_270.png


+ 153 - 0
ocr_tools/universal_doc_parser/tests/test_doclayoutyolo.py

@@ -0,0 +1,153 @@
+import os
+from typing import List, Dict, Union
+
+from doclayout_yolo import YOLOv10
+from tqdm import tqdm
+import numpy as np
+from PIL import Image, ImageDraw
+
+from mineru.utils.enum_class import ModelPath
+from mineru.utils.models_download_utils import auto_download_and_get_model_root_path
+
+
+class DocLayoutYOLOModel:
+    def __init__(
+        self,
+        weight: str,
+        device: str = "cuda",
+        imgsz: int = 1280,
+        conf: float = 0.1,
+        iou: float = 0.45,
+    ):
+        self.model = YOLOv10(weight).to(device)
+        self.device = device
+        self.imgsz = imgsz
+        self.conf = conf
+        self.iou = iou
+
+    def _parse_prediction(self, prediction) -> List[Dict]:
+        layout_res = []
+
+        # 容错处理
+        if not hasattr(prediction, "boxes") or prediction.boxes is None:
+            return layout_res
+
+        for xyxy, conf, cls in zip(
+            prediction.boxes.xyxy.cpu(),
+            prediction.boxes.conf.cpu(),
+            prediction.boxes.cls.cpu(),
+        ):
+            coords = list(map(int, xyxy.tolist()))
+            xmin, ymin, xmax, ymax = coords
+            layout_res.append({
+                "category_id": int(cls.item()),
+                "poly": [xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax],
+                "score": round(float(conf.item()), 3),
+            })
+        return layout_res
+
+    def predict(self, image: Union[np.ndarray, Image.Image]) -> List[Dict]:
+        prediction = self.model.predict(
+            image,
+            imgsz=self.imgsz,
+            conf=self.conf,
+            iou=self.iou,
+            verbose=False
+        )[0]
+        return self._parse_prediction(prediction)
+
+    def batch_predict(
+        self,
+        images: List[Union[np.ndarray, Image.Image]],
+        batch_size: int = 4
+    ) -> List[List[Dict]]:
+        results = []
+        with tqdm(total=len(images), desc="Layout Predict") as pbar:
+            for idx in range(0, len(images), batch_size):
+                batch = images[idx: idx + batch_size]
+                if batch_size == 1:
+                    conf = 0.9 * self.conf
+                else:
+                    conf = self.conf
+                predictions = self.model.predict(
+                    batch,
+                    imgsz=self.imgsz,
+                    conf=conf,
+                    iou=self.iou,
+                    verbose=False,
+                )
+                for pred in predictions:
+                    results.append(self._parse_prediction(pred))
+                pbar.update(len(batch))
+        return results
+
+    # DocLayout-YOLO 类别映射
+    CATEGORY_NAMES = {
+        0: "title",
+        1: "text", 
+        2: "abandon",
+        3: "figure",
+        4: "figure_caption",
+        5: "table",
+        6: "table_caption",
+        7: "table_footnote",
+        8: "isolate_formula",
+        9: "formula_caption",
+    }
+    
+    # 不同类别使用不同颜色
+    CATEGORY_COLORS = {
+        0: "red",           # title
+        1: "blue",          # text
+        2: "gray",          # abandon
+        3: "green",         # figure
+        4: "lightgreen",    # figure_caption
+        5: "orange",        # table
+        6: "yellow",        # table_caption
+        7: "pink",          # table_footnote
+        8: "purple",        # isolate_formula
+        9: "cyan",          # formula_caption
+    }
+
+    def visualize(
+            self,
+            image: Union[np.ndarray, Image.Image],
+            results: List
+    ) -> Image.Image:
+
+        if isinstance(image, np.ndarray):
+            image = Image.fromarray(image)
+
+        draw = ImageDraw.Draw(image)
+        for res in results:
+            poly = res['poly']
+            xmin, ymin, xmax, ymax = poly[0], poly[1], poly[4], poly[5]
+            category_id = res['category_id']
+            category_name = self.CATEGORY_NAMES.get(category_id, f"unknown_{category_id}")
+            color = self.CATEGORY_COLORS.get(category_id, "red")
+            
+            print(
+                f"Detected box: {xmin}, {ymin}, {xmax}, {ymax}, Category: {category_name}({category_id}), Score: {res['score']}")
+            # 使用PIL在图像上画框
+            draw.rectangle([xmin, ymin, xmax, ymax], outline=color, width=2)
+            # 在框旁边画类别名和置信度
+            label = f"{category_name} {res['score']:.2f}"
+            draw.text((xmin, ymin - 25), label, fill=color, font_size=20)
+        return image
+
+
+if __name__ == '__main__':
+    # image_path = "./2023年度报告母公司_page_003_270.png"
+    image_path = "/Users/zhch158/workspace/data/流水分析/施博深.wiredtable/施博深_page_001.png"
+    doclayout_yolo_weights = os.path.join(auto_download_and_get_model_root_path(ModelPath.doclayout_yolo), ModelPath.doclayout_yolo)
+    device = 'cpu'
+    model = DocLayoutYOLOModel(
+        weight=doclayout_yolo_weights,
+        device=device,
+    )
+    image = Image.open(image_path)
+    results = model.predict(image)
+
+    image = model.visualize(image, results)
+
+    image.show()  # 显示图像

+ 128 - 0
ocr_tools/universal_doc_parser/tests/test_layout_detector.py

@@ -0,0 +1,128 @@
+"""
+布局检测器测试脚本
+"""
+
+import sys
+from pathlib import Path
+import cv2
+import random
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parents[1]
+sys.path.insert(0, str(project_root))
+
+from models.adapters import PaddleLayoutDetector
+
+
+def test_layout_detector():
+    """测试 PaddleX 布局检测器"""
+    
+    # 测试配置
+    config = {
+        'model_dir': '/Users/zhch158/workspace/repository.git/PaddleX/zhch/unified_pytorch_models/Layout/RT-DETR-H_layout_17cls.onnx',
+        'device': 'cpu',
+        'conf': 0.25
+    }
+    
+    # 初始化检测器
+    print("🔧 Initializing detector...")
+    detector = PaddleLayoutDetector(config)
+    detector.initialize()
+    
+    # 读取测试图像
+    img_path = "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/PaddleOCR_VL_Results/B用户_扫描流水/B用户_扫描流水_page_001.png"
+    print(f"\n📖 Loading image: {img_path}")
+    img = cv2.imread(img_path)
+    
+    if img is None:
+        print(f"❌ Failed to load image: {img_path}")
+        return
+    
+    print(f"   Image shape: {img.shape}")
+    
+    # 执行检测
+    print("\n🔍 Detecting layout...")
+    results = detector.detect(img)
+    
+    print(f"\n✅ 检测到 {len(results)} 个区域:")
+    for i, res in enumerate(results, 1):
+        print(f"  [{i}] {res['category']}: "
+              f"score={res['confidence']:.3f}, "
+              f"bbox={res['bbox']}, "
+              f"size={res['raw']['width']}x{res['raw']['height']}, "
+              f"original={res['raw']['original_category_name']}")
+    
+    # 统计各类别
+    category_counts = {}
+    for res in results:
+        cat = res['category']
+        category_counts[cat] = category_counts.get(cat, 0) + 1
+    
+    print(f"\n📊 类别统计 (MinerU格式):")
+    for cat, count in sorted(category_counts.items()):
+        print(f"  - {cat}: {count}")
+    
+    # 可视化结果
+    if len(results) > 0:
+        print("\n🎨 Generating visualization...")
+        
+        # 为每个类别分配颜色
+        category_colors = {}
+        for res in results:
+            cat = res['category']
+            if cat not in category_colors:
+                category_colors[cat] = (
+                    random.randint(50, 255),
+                    random.randint(50, 255),
+                    random.randint(50, 255)
+                )
+        
+        # 绘制检测框
+        vis_img = img.copy()
+        for res in results:
+            bbox = res['bbox']
+            x1, y1, x2, y2 = bbox
+            cat = res['category']
+            color = category_colors[cat]
+            
+            # 绘制矩形
+            cv2.rectangle(vis_img, (x1, y1), (x2, y2), color, 2)
+            
+            # 绘制标签
+            label = f"{cat} {res['confidence']:.2f}"
+            label_size, _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
+            
+            # 标签背景
+            cv2.rectangle(
+                vis_img,
+                (x1, y1 - label_size[1] - 4),
+                (x1 + label_size[0], y1),
+                color,
+                -1
+            )
+            
+            # 标签文字
+            cv2.putText(
+                vis_img,
+                label,
+                (x1, y1 - 2),
+                cv2.FONT_HERSHEY_SIMPLEX,
+                0.5,
+                (255, 255, 255),
+                1
+            )
+        
+        # 保存可视化结果
+        output_dir = Path(__file__).parent / "output"
+        output_dir.mkdir(exist_ok=True)
+        output_path = output_dir / f"{Path(img_path).stem}_layout_vis.jpg"
+        cv2.imwrite(str(output_path), vis_img)
+        print(f"💾 Visualization saved to: {output_path}")
+    
+    # 清理
+    detector.cleanup()
+    print("\n✅ 测试完成!")
+
+
+if __name__ == "__main__":
+    test_layout_detector()

+ 184 - 0
ocr_tools/universal_doc_parser/tests/test_table_routing.py

@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""
+表格识别路由逻辑测试脚本
+
+验证:
+1. process_table_element_wired 和 process_table_element_vlm 方法是否可调用
+2. 路由选择逻辑是否正确(基于配置)
+3. Fallback 机制是否工作
+"""
+import sys
+from pathlib import Path
+
+# 添加路径
+project_root = Path(__file__).parents[2]
+sys.path.insert(0, str(project_root))
+
+from core.element_processors import ElementProcessors
+
+
+def test_method_existence():
+    """测试新方法是否存在"""
+    print("🧪 Testing method existence...")
+    
+    # 检查 ElementProcessors 类是否有新的方法
+    required_methods = [
+        'process_table_element_wired',
+        'process_table_element_vlm',
+        '_prepare_table_ocr',
+        '_create_empty_table_result',
+    ]
+    
+    for method_name in required_methods:
+        if hasattr(ElementProcessors, method_name):
+            print(f"  ✅ {method_name} exists")
+        else:
+            print(f"  ❌ {method_name} NOT found")
+            return False
+    
+    # 检查旧方法是否已删除
+    if hasattr(ElementProcessors, 'process_table_element'):
+        print(f"  ❌ Old process_table_element still exists (should be removed)")
+        return False
+    else:
+        print(f"  ✅ Old process_table_element removed")
+    
+    return True
+
+
+def test_method_signatures():
+    """测试方法签名"""
+    print("\n🧪 Testing method signatures...")
+    import inspect
+    
+    # 检查 wired 方法签名
+    wired_sig = inspect.signature(ElementProcessors.process_table_element_wired)
+    expected_params = {'self', 'image', 'layout_item', 'scale', 'pre_matched_spans'}
+    actual_params = set(wired_sig.parameters.keys())
+    
+    if actual_params == expected_params:
+        print(f"  ✅ process_table_element_wired signature correct")
+    else:
+        print(f"  ❌ process_table_element_wired signature mismatch")
+        print(f"    Expected: {expected_params}")
+        print(f"    Actual: {actual_params}")
+        return False
+    
+    # 检查 vlm 方法签名
+    vlm_sig = inspect.signature(ElementProcessors.process_table_element_vlm)
+    if actual_params == expected_params:
+        print(f"  ✅ process_table_element_vlm signature correct")
+    else:
+        print(f"  ❌ process_table_element_vlm signature mismatch")
+        return False
+    
+    return True
+
+
+def test_config_removal():
+    """测试配置参数是否从 __init__ 中移除"""
+    print("\n🧪 Testing __init__ parameters...")
+    import inspect
+    
+    init_sig = inspect.signature(ElementProcessors.__init__)
+    params = list(init_sig.parameters.keys())
+    
+    # table_config 不应该在参数中
+    if 'table_config' in params:
+        print(f"  ❌ table_config still in __init__ parameters (should be removed)")
+        return False
+    else:
+        print(f"  ✅ table_config removed from __init__ parameters")
+    
+    # wired_table_recognizer 应该在参数中
+    if 'wired_table_recognizer' in params:
+        print(f"  ✅ wired_table_recognizer in __init__ parameters")
+    else:
+        print(f"  ❌ wired_table_recognizer NOT in __init__ parameters")
+        return False
+    
+    return True
+
+
+def test_routing_logic_structure():
+    """测试路由逻辑结构(在 pipeline_manager_v2.py 中)"""
+    print("\n🧪 Testing routing logic in pipeline_manager_v2...")
+    
+    # 读取 pipeline_manager_v2.py 并检查路由逻辑
+    pipeline_file = Path(__file__).parent / 'core' / 'pipeline_manager_v2.py'
+    
+    with open(pipeline_file, 'r') as f:
+        content = f.read()
+    
+    # 检查是否包含新的路由逻辑
+    required_patterns = [
+        'use_wired_unet = self.table_config.get',
+        'process_table_element_wired',
+        'process_table_element_vlm',
+        'falling back to VLM',
+    ]
+    
+    for pattern in required_patterns:
+        if pattern in content:
+            print(f"  ✅ Found: '{pattern[:50]}...'")
+        else:
+            print(f"  ❌ Missing: '{pattern}'")
+            return False
+    
+    # 检查旧的 process_table_element 调用是否已移除
+    if 'process_table_element(' in content and 'process_table_element_' not in content.split('process_table_element(')[1][:100]:
+        print(f"  ❌ Old process_table_element call still exists")
+        return False
+    else:
+        print(f"  ✅ Old process_table_element call removed")
+    
+    return True
+
+
+def main():
+    """主测试函数"""
+    print("=" * 70)
+    print("表格识别路由逻辑验证测试")
+    print("=" * 70)
+    
+    tests = [
+        ("方法存在性", test_method_existence),
+        ("方法签名", test_method_signatures),
+        ("配置参数移除", test_config_removal),
+        ("路由逻辑结构", test_routing_logic_structure),
+    ]
+    
+    results = []
+    for test_name, test_func in tests:
+        try:
+            result = test_func()
+            results.append((test_name, result))
+        except Exception as e:
+            print(f"\n❌ Test '{test_name}' failed with exception:")
+            print(f"   {type(e).__name__}: {e}")
+            results.append((test_name, False))
+    
+    # 汇总结果
+    print("\n" + "=" * 70)
+    print("测试汇总")
+    print("=" * 70)
+    
+    passed = sum(1 for _, result in results if result)
+    total = len(results)
+    
+    for test_name, result in results:
+        status = "✅ PASS" if result else "❌ FAIL"
+        print(f"{status}: {test_name}")
+    
+    print(f"\n总计: {passed}/{total} 测试通过")
+    
+    if passed == total:
+        print("\n🎉 所有测试通过!架构重构成功")
+        return 0
+    else:
+        print(f"\n⚠️  {total - passed} 个测试失败")
+        return 1
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 288 - 0
ocr_tools/universal_doc_parser/unclip_ratio参数说明.md

@@ -0,0 +1,288 @@
+# `unclip_ratio` 参数说明
+
+## 📋 概述
+
+`unclip_ratio` 是 OCR 文本检测中的一个重要参数,用于控制检测框的扩展比例。
+
+## 🎯 作用原理
+
+### 1. **Vatti Clipping 算法**
+
+`unclip_ratio` 使用 **Vatti Clipping 算法**对检测到的文字区域进行扩张(unclip)。
+
+### 2. **工作原理**
+
+```
+原始检测框(可能过紧)
+┌─────────┐
+│  文本   │  ← 检测模型输出的框可能太紧,裁剪了文字边缘
+└─────────┘
+
+应用 unclip_ratio = 1.5 后
+┌───────────────┐
+│               │
+│    文本       │  ← 扩展后的框,包含完整的文字区域
+│               │
+└───────────────┘
+```
+
+### 3. **数学公式**
+
+```python
+# 计算扩展后的框尺寸
+width = x2 - x1
+height = y2 - y1
+
+new_width = width * unclip_ratio
+new_height = height * unclip_ratio
+
+# 保持中心点不变,扩展框
+center_x = x1 + width / 2
+center_y = y1 + height / 2
+
+new_x1 = center_x - new_width / 2
+new_y1 = center_y - new_height / 2
+new_x2 = center_x + new_width / 2
+new_y2 = center_y + new_height / 2
+```
+
+## 📊 参数影响
+
+### 不同 `unclip_ratio` 值的效果
+
+| unclip_ratio | 扩展程度 | 适用场景 | 优缺点 |
+|-------------|---------|---------|--------|
+| **1.0** | 不扩展 | 检测框已经很精确 | ✅ 精确度高<br>❌ 可能裁剪文字边缘 |
+| **1.3** | 轻微扩展 | 高质量扫描件 | ✅ 精确度较高<br>✅ 包含完整文字 |
+| **1.5** | 中等扩展 | **推荐值**(PPStructureV3默认) | ✅ 平衡精确度和完整性<br>✅ 适合大多数场景 |
+| **1.8** | 较大扩展 | MinerU默认,质量较差的扫描件 | ✅ 确保包含完整文字<br>❌ 可能包含过多背景 |
+| **2.0+** | 大幅扩展 | 极低质量图像 | ✅ 最大程度包含文字<br>❌ 精确度低,可能包含相邻文字 |
+
+## 🔍 实际效果对比
+
+### 示例1:正常文本
+
+```
+原始检测框(unclip_ratio=1.0)
+┌────────┐
+│ 货币资金 │  ← 可能裁剪了"货"字的左边
+└────────┘
+
+unclip_ratio=1.5(推荐)
+┌───────────┐
+│  货币资金  │  ← 包含完整文字
+└───────────┘
+
+unclip_ratio=1.8(过大)
+┌─────────────┐
+│   货币资金   │  ← 包含过多空白,可能影响识别
+└─────────────┘
+```
+
+### 示例2:密集文本(表格)
+
+```
+unclip_ratio=1.5(推荐)
+┌─────┐ ┌─────┐
+│ 资产 │ │ 负债 │  ← 框精确,不重叠
+└─────┘ └─────┘
+
+unclip_ratio=1.8(过大)
+┌──────┐┌──────┐
+│ 资产  ││ 负债 │  ← 框重叠,可能合并相邻单元格
+└──────┘└──────┘
+```
+
+## ⚙️ 参数调优建议
+
+### 1. **根据文档质量调整**
+
+```yaml
+# 高质量扫描件(清晰、无倾斜)
+ocr:
+  unclip_ratio: 1.3  # 较小扩展,保持精确
+
+# 中等质量扫描件(推荐)
+ocr:
+  unclip_ratio: 1.5  # 平衡精确度和完整性
+
+# 低质量扫描件(模糊、倾斜)
+ocr:
+  unclip_ratio: 1.8  # 较大扩展,确保包含完整文字
+```
+
+### 2. **根据文档类型调整**
+
+```yaml
+# 表格场景(密集文本)
+ocr:
+  unclip_ratio: 1.5  # 较小扩展,避免框重叠
+  enable_merge_det_boxes: false  # 不合并框
+
+# 普通文本场景
+ocr:
+  unclip_ratio: 1.6  # 中等扩展
+  enable_merge_det_boxes: true  # 可以合并框
+
+# 稀疏文本场景
+ocr:
+  unclip_ratio: 1.8  # 较大扩展,确保包含完整文字
+```
+
+### 3. **与 `box_thresh` 配合使用**
+
+```yaml
+# 严格检测 + 精确扩展
+ocr:
+  det_threshold: 0.6      # 高阈值,减少噪声框
+  unclip_ratio: 1.5       # 精确扩展
+
+# 宽松检测 + 较大扩展
+ocr:
+  det_threshold: 0.3      # 低阈值,检测更多框
+  unclip_ratio: 1.8       # 较大扩展,确保包含文字
+```
+
+## 📈 性能影响
+
+### 识别准确率
+
+| unclip_ratio | 文字完整性 | 精确度 | 相邻文字干扰 | 综合评分 |
+|-------------|----------|--------|------------|---------|
+| 1.3 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
+| **1.5** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | **⭐⭐⭐⭐⭐** |
+| 1.8 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
+
+### 处理速度
+
+- `unclip_ratio` 对处理速度影响很小(< 1%)
+- 主要影响识别准确率,而非速度
+
+## 🎯 推荐配置
+
+### PPStructureV3 配置(推荐)
+
+```yaml
+ocr:
+  det_threshold: 0.6
+  unclip_ratio: 1.5      # ✅ 推荐值
+  enable_merge_det_boxes: false
+```
+
+### MinerU 默认配置
+
+```yaml
+ocr:
+  det_threshold: 0.3
+  unclip_ratio: 1.8      # ⚠️ 可能过大,建议改为1.5
+  enable_merge_det_boxes: true
+```
+
+### 优化后的配置
+
+```yaml
+ocr:
+  det_threshold: 0.6     # 提高检测阈值
+  unclip_ratio: 1.5      # 降低扩展比例(从1.8改为1.5)
+  enable_merge_det_boxes: false  # 表格场景不合并
+```
+
+## 🔧 代码实现
+
+### PaddleOCR 中的使用
+
+```python
+# 在文本检测后处理中使用
+def unclip_boxes(boxes, unclip_ratio):
+    """
+    扩展检测框
+    
+    Args:
+        boxes: 检测框列表 [(x1, y1, x2, y2), ...]
+        unclip_ratio: 扩展比例(如 1.5)
+    
+    Returns:
+        扩展后的检测框列表
+    """
+    expanded_boxes = []
+    for box in boxes:
+        x1, y1, x2, y2 = box
+        width = x2 - x1
+        height = y2 - y1
+        
+        # 计算扩展后的尺寸
+        new_width = width * unclip_ratio
+        new_height = height * unclip_ratio
+        
+        # 保持中心点不变
+        center_x = x1 + width / 2
+        center_y = y1 + height / 2
+        
+        # 计算新的坐标
+        new_x1 = center_x - new_width / 2
+        new_y1 = center_y - new_height / 2
+        new_x2 = center_x + new_width / 2
+        new_y2 = center_y + new_height / 2
+        
+        expanded_boxes.append([new_x1, new_y1, new_x2, new_y2])
+    
+    return expanded_boxes
+```
+
+## ⚠️ 常见问题
+
+### Q1: `unclip_ratio` 越大越好吗?
+
+**A:** 不是。过大的 `unclip_ratio` 会导致:
+- 检测框包含过多背景
+- 相邻文字框重叠
+- 识别准确率下降
+
+**建议**:从 1.5 开始,根据实际效果调整。
+
+### Q2: 为什么 PPStructureV3 使用 1.5,而 MinerU 使用 1.8?
+
+**A:** 
+- **PPStructureV3 (1.5)**:更注重精确度,适合高质量文档
+- **MinerU (1.8)**:更注重完整性,适合低质量扫描件
+
+**建议**:对于表格等密集文本,使用 1.5 更合适。
+
+### Q3: 如何判断 `unclip_ratio` 是否合适?
+
+**A:** 检查识别结果:
+- **框太紧**:文字被裁剪 → 增大 `unclip_ratio`
+- **框太松**:包含相邻文字 → 减小 `unclip_ratio`
+- **框重叠**:相邻单元格合并 → 减小 `unclip_ratio` 或关闭 `enable_merge_det_boxes`
+
+### Q4: `unclip_ratio` 和 `box_thresh` 的关系?
+
+**A:** 
+- `box_thresh`:控制哪些检测框被保留(过滤低置信度框)
+- `unclip_ratio`:控制检测框的扩展程度(调整框的大小)
+
+两者独立,但可以配合使用:
+- 高 `box_thresh` + 低 `unclip_ratio`:严格检测,精确扩展
+- 低 `box_thresh` + 高 `unclip_ratio`:宽松检测,较大扩展
+
+## 📚 相关文档
+
+- [OCR识别差异分析与改进方案.md](./OCR识别差异分析与改进方案.md) - 参数优化说明
+- [模型统一框架.md](./模型统一框架.md) - OCR配置说明
+
+## 🎯 总结
+
+| 特性 | 说明 |
+|-----|------|
+| **作用** | 扩展文本检测框,确保包含完整文字 |
+| **算法** | Vatti Clipping 算法 |
+| **推荐值** | **1.5**(PPStructureV3默认) |
+| **范围** | 1.0 - 2.0(通常 1.3 - 1.8) |
+| **影响** | 主要影响识别准确率,对速度影响很小 |
+| **调优** | 根据文档质量和类型调整 |
+
+**最佳实践**:
+- ✅ 表格场景:`unclip_ratio=1.5`,`enable_merge_det_boxes=false`
+- ✅ 普通文本:`unclip_ratio=1.5-1.6`,`enable_merge_det_boxes=true`
+- ✅ 低质量扫描件:`unclip_ratio=1.8`,`enable_merge_det_boxes=true`
+

+ 184 - 0
ocr_tools/universal_doc_parser/utils/README_OUTPUT_FORMAT.md

@@ -0,0 +1,184 @@
+# MinerU 输出格式说明
+
+## 概述
+
+`MinerUOutputFormatter` 严格遵循 MinerU 的输出格式,与 `mineru_vllm_results_cell_bbox` 目录下的格式完全一致。
+
+## 输出格式
+
+### 1. JSON 格式
+
+每页一个 JSON 文件,包含元素列表:
+
+```json
+[
+  {
+    "type": "header",
+    "text": "页眉内容",
+    "bbox": [160, 126, 590, 161],
+    "page_idx": 0
+  },
+  {
+    "type": "text",
+    "text": "正文内容...",
+    "bbox": [158, 226, 1463, 322],
+    "page_idx": 0
+  },
+  {
+    "type": "table",
+    "img_path": "images/xxx.jpg",
+    "table_caption": ["表格标题"],
+    "table_footnote": [],
+    "table_body": "<table>...</table>",
+    "bbox": [251, 264, 1404, 2111],
+    "page_idx": 0,
+    "table_cells": [...],
+    "image_rotation_angle": 270.0,
+    "skew_angle": -0.26
+  }
+]
+```
+
+### 2. 表格单元格格式 (table_cells)
+
+```json
+{
+  "type": "table_cell",
+  "text": "单元格内容",
+  "matched_text": "OCR匹配的文本",
+  "bbox": [273, 1653, 302, 2106],
+  "row": 2,
+  "col": 1,
+  "score": 100.0,
+  "paddle_bbox_indices": [11, 12]
+}
+```
+
+### 3. 表格 HTML 格式 (table_body)
+
+HTML 表格带有 `data-bbox` 属性:
+
+```html
+<table>
+  <tr>
+    <td data-bbox="[232,273,685,302]" data-paddle-indices="[11, 12]" data-score="100.0000">流动资产:</td>
+    <td></td>
+    <td></td>
+  </tr>
+</table>
+```
+
+### 4. Markdown 格式
+
+带 bbox 注释的 Markdown:
+
+```markdown
+<!-- bbox: [160, 126, 590, 161] -->
+<!-- 页眉: 广东荣德会计师事务所有限公司 -->
+
+<!-- bbox: [158, 226, 1463, 322] -->
+在按照审计准则执行审计工作的过程中...
+
+<!-- bbox: [251, 264, 1404, 2111] -->
+**资产负债表**
+
+<table>...</table>
+```
+
+## 使用方法
+
+### 方法1: 直接使用格式化器
+
+```python
+from utils.mineru_output_formatter import MinerUOutputFormatter
+
+# 创建格式化器
+formatter = MinerUOutputFormatter(
+    output_dir="./output",
+    save_images=True
+)
+
+# 格式化并保存
+output_paths = formatter.format_and_save(
+    results=pipeline_results,
+    doc_name="my_document"
+)
+
+print(output_paths)
+# {
+#     'json': ['./output/my_document_page_001.json', ...],
+#     'markdown': ['./output/my_document_page_001.md', ...],
+#     'images': ['./output/images/xxx.png', ...]
+# }
+```
+
+### 方法2: 使用便捷函数
+
+```python
+from utils.mineru_output_formatter import save_mineru_format
+
+output_paths = save_mineru_format(
+    results=pipeline_results,
+    output_dir="./output",
+    doc_name="my_document",
+    save_images=True
+)
+```
+
+### 方法3: 仅转换格式(不保存)
+
+```python
+from utils.mineru_output_formatter import MinerUFormatConverter
+
+# 转换为 MinerU content_list 格式
+content_list = MinerUFormatConverter.convert_pipeline_result_to_mineru(
+    pipeline_results
+)
+```
+
+## 与 MinerU 原生输出的对比
+
+| 特性 | MinerU 原生 | MinerUOutputFormatter |
+|------|------------|----------------------|
+| JSON 格式 | ✅ | ✅ 完全一致 |
+| Markdown | ✅ | ✅ 完全一致 |
+| bbox 注释 | ✅ | ✅ 完全一致 |
+| table_body HTML | ✅ | ✅ 完全一致 |
+| table_cells | ✅ | ✅ 完全一致 |
+| data-bbox 属性 | ✅ | ✅ 完全一致 |
+| image_rotation_angle | ✅ | ✅ 完全一致 |
+| skew_angle | ✅ | ✅ 完全一致 |
+
+## 坐标系统
+
+### 输出坐标
+
+- **bbox**: `[x1, y1, x2, y2]` 格式,基于**原始未旋转图片**的像素坐标
+- **table_cells.bbox**: 同上,基于原始图片坐标系
+
+### 旋转信息
+
+- **image_rotation_angle**: 图片旋转角度(0, 90, 180, 270)
+- **skew_angle**: 倾斜校正角度
+
+## 目录结构
+
+```
+output/
+├── document_page_001.json
+├── document_page_001.md
+├── document_page_002.json
+├── document_page_002.md
+└── images/
+    ├── table_0_251_264_xxx.jpg
+    ├── image_0_133_1582_xxx.jpg
+    └── ...
+```
+
+## 注意事项
+
+1. **坐标系**: 所有坐标都是基于原始未旋转图片的坐标系
+2. **图片**: 保存的是原始未旋转的图片
+3. **表格 HTML**: 包含 `data-bbox` 属性,可用于前端渲染和坐标定位
+4. **单元格匹配**: `paddle_bbox_indices` 记录了匹配的 OCR 框索引
+

+ 38 - 0
ocr_tools/universal_doc_parser/utils/__init__.py

@@ -0,0 +1,38 @@
+"""工具模块
+
+此模块已迁移到使用 ocr_utils 中的工具函数,保留此文件仅用于重新导出。
+所有工具函数都从 ocr_utils 导入,确保代码统一和维护性。
+"""
+import sys
+from pathlib import Path
+
+# 添加 ocr_platform 根目录到 Python 路径(用于导入 ocr_utils)
+ocr_platform_root = Path(__file__).parents[4]  # utils -> universal_doc_parser -> ocr_tools -> ocr_platform -> repository.git
+if str(ocr_platform_root) not in sys.path:
+    sys.path.insert(0, str(ocr_platform_root))
+
+# 从 ocr_utils 导入所有工具函数和类
+try:
+    from ocr_utils import (
+        JSONFormatters,
+        MarkdownGenerator,
+        HTMLGenerator,
+        VisualizationUtils,
+        OutputFormatterV2,
+        save_mineru_format,
+        normalize_json_table,
+        normalize_markdown_table,
+    )
+except ImportError as e:
+    raise ImportError(f"ocr_utils is required. Please ensure ocr_utils is available. Error: {e}")
+
+__all__ = [
+    'JSONFormatters',
+    'normalize_json_table',
+    'normalize_markdown_table',
+    'VisualizationUtils',
+    'MarkdownGenerator',
+    'HTMLGenerator',
+    'OutputFormatterV2',
+    'save_mineru_format'
+]

+ 521 - 0
ocr_tools/universal_doc_parser/模型统一框架.md

@@ -0,0 +1,521 @@
+# 金融文档处理统一框架
+
+参考 MinerU 实现的模型统一框架,针对金融场景设计。
+
+## 支持场景
+
+| 场景类型 | 特点 | 表格形式 |
+|---------|------|---------|
+| **银行交易流水** | 单栏列表形式,无合并单元格 | 有线表格 / 无线表格 |
+| **财务报表** | 多栏列表形式,有合并单元格,表头复杂 | 有线表格 / 无线表格 |
+
+## 模型选择
+
+| 模型类型 | 推荐模型 | 说明 |
+|---------|---------|------|
+| **版式检测** | Docling Layout / DocLayout-YOLO | HuggingFace 或 MinerU 模型 |
+| **文字识别** | PaddleOCR (PyTorch) | 效果好,支持角度校正 |
+| **表格结构识别** | MinerU VLM / PaddleOCR-VL | VLM 返回 HTML 结构 |
+| **公式识别** | MinerU VLM | 返回 LaTeX |
+| **方向识别** | PP-LCNet | 沿用 MinerU 实现 |
+| **单元格坐标匹配** | TableCellMatcher | OCR 检测框与 VLM 结构匹配 |
+
+---
+
+## 处理流程
+
+```mermaid
+graph TB
+    A[输入 PDF/图片] --> B{PDF 分类}
+    
+    B -->|扫描件/图片| C1[页面方向识别<br/>PP-LCNet]
+    B -->|数字原生PDF| D
+    C1 --> D[Layout 检测<br/>去重叠框]
+    
+    D --> E[整页 OCR<br/>获取所有 text spans]
+    E --> F[Span-Block 匹配<br/>SpanMatcher]
+    F --> G{元素分类}
+    
+    G --> H[文本类]
+    G --> I[表格类]
+    G --> J[图片类]
+    G --> K[公式类]
+    G --> L[丢弃类]
+    
+    subgraph 文本处理
+        H --> H1{有匹配的spans?}
+        H1 -->|是| H2[合并 spans 文本]
+        H1 -->|否| H3{PDF类型?}
+        H3 -->|数字PDF| H4[PDF 字符提取]
+        H4 --> H5{成功?}
+        H5 -->|否| H6[裁剪区域 OCR]
+        H5 -->|是| H7[文本结果]
+        H3 -->|扫描件| H6
+        H6 --> H7
+        H2 --> H7
+    end
+    
+    subgraph 表格处理
+        I --> I1[OCR 检测<br/>获取文本框坐标]
+        I --> I2[VLM 结构识别<br/>返回 HTML]
+        I1 --> I3[坐标匹配<br/>TableCellMatcher]
+        I2 --> I3
+        I3 --> I4[带坐标的表格]
+    end
+    
+    subgraph 图片处理
+        J --> J1[裁剪保存]
+    end
+    
+    subgraph 公式处理
+        K --> K1[VLM 识别<br/>返回 LaTeX]
+    end
+    
+    subgraph 丢弃元素
+        L --> L1{有匹配的spans?}
+        L1 -->|是| L2[合并 spans 文本]
+        L1 -->|否| L3[裁剪区域 OCR]
+        L2 --> L4[保留备用]
+        L3 --> L4
+    end
+    
+    H7 --> M[合并所有结果]
+    I4 --> M
+    J1 --> M
+    K1 --> M
+    L4 --> M
+    
+    M --> N[按阅读顺序排序]
+    N --> O[坐标转换回原图]
+    O --> P[合并跨页表格]
+    P --> Q[金额数字标准化]
+    Q --> R[多格式输出]
+```
+
+### 关键改进:整页 OCR + Span 匹配
+
+参考 MinerU 的处理方式,新流程采用 **整页 OCR → Span-Block 匹配** 策略:
+
+1. **整页 OCR**:先对整个页面进行 OCR,获取所有 text spans(包含坐标和文本)
+2. **Span 去重**:移除高 IoU 重叠的 spans,保留置信度高的
+3. **Span-Block 匹配**:将 OCR spans 按重叠比例匹配到对应的 layout blocks
+4. **文本合并**:将匹配到同一 block 的 spans 按阅读顺序合并
+
+**优势**:
+- ✅ 避免裁剪小图 OCR 失败的问题
+- ✅ OCR 可以利用更多上下文信息
+- ✅ 坐标更精确(整页坐标系)
+- ✅ 与 MinerU 处理方式一致
+
+### 元素分类说明
+
+| 元素类别 | 包含类型 | 处理方式 |
+|---------|---------|---------|
+| **文本类** | text, title, header, footer, ref_text, table_caption, image_caption 等 | 优先使用匹配的 spans,否则 PDF 提取或裁剪 OCR |
+| **表格类** | table, table_body | OCR 坐标 + VLM 结构 |
+| **图片类** | image, image_body, figure | 裁剪保存 |
+| **公式类** | interline_equation, equation | VLM 识别 |
+| **丢弃类** | abandon, discarded | 优先使用匹配的 spans,否则裁剪 OCR |
+
+---
+
+## 方向识别策略
+
+| 处理阶段 | 方向识别 | 建议 |
+|---------|---------|------|
+| **页面级** | PP-LCNet | 可配置,扫描件开启,数字PDF关闭 |
+| **表格区域** | - | 可选,VLM 有一定容忍度,OCR 自带角度校正 |
+| **文本区域** | - | 不需要,OCR 自带校正 |
+
+---
+
+## Layout 后处理
+
+### 大面积文本块转表格
+
+当Layout检测将大面积的表格区域误识别为文本框时,可以通过后处理自动转换:
+
+**判断规则**:
+- 面积占比:占页面面积超过阈值(默认25%)
+- 尺寸比例:宽度和高度都超过一定比例(避免细长条)
+- 表格冲突:如果页面已有表格,不进行转换(避免误判)
+
+**配置示例**:
+```yaml
+layout:
+  convert_large_text_to_table: true  # 是否启用
+  min_text_area_ratio: 0.25          # 最小面积占比(25%)
+  min_text_width_ratio: 0.4          # 最小宽度占比(40%)
+  min_text_height_ratio: 0.3         # 最小高度占比(30%)
+```
+
+详细说明请参考:`Layout后处理-文本转表格.md`
+
+---
+
+## OCR 使用策略
+
+| PDF 类型 | 文字块处理 | 表格处理 |
+|---------|-----------|---------|
+| **扫描件/图片** | 整页 OCR → Span 匹配 | OCR 检测(坐标) + VLM(结构) |
+| **数字原生 PDF** | 整页 OCR → Span 匹配 / PDF 字符提取 | OCR 检测(坐标) + VLM(结构) |
+
+**关键点**:
+- **整页 OCR 优先**:先对整页进行 OCR,再将结果匹配到 layout blocks
+- 数字原生 PDF 在 spans 匹配失败时,会尝试 PDF 字符提取
+- **表格处理无论 PDF 类型都需要 OCR 检测**,用于获取单元格内文本的精确坐标
+- VLM 只返回表格结构(HTML),不返回单元格坐标,需要与 OCR 检测结果匹配
+- 当前实现仅使用 VLM(MinerU VLM 或 PaddleOCR-VL)进行表格结构识别
+
+### OCR 参数配置
+
+**默认参数(已优化,参考PPStructureV3)**:
+- `det_threshold`: 0.6(检测框置信度阈值,提高可减少噪声框)
+- `unclip_ratio`: 1.5(检测框扩展比例,降低可提高框精确度)
+- `enable_merge_det_boxes`: False(是否合并检测框,表格场景建议False)
+
+**配置示例**:
+```yaml
+ocr:
+  det_threshold: 0.6              # 检测阈值(0.3-0.7,越高越严格)
+  unclip_ratio: 1.5               # 扩展比例(1.3-1.8,越低越精确)
+  enable_merge_det_boxes: false   # 合并框(表格场景建议false)
+  language: ch                     # 语言(ch/ch_lite/en等)
+```
+
+**参数调优建议**:
+- **表格密集场景**:`det_threshold=0.6`, `enable_merge_det_boxes=false`
+- **文本稀疏场景**:`det_threshold=0.4`, `enable_merge_det_boxes=true`
+- **扫描件质量差**:`det_threshold=0.3`, `unclip_ratio=1.8`
+
+详细分析请参考:`OCR识别差异分析与改进方案.md`
+
+---
+
+## 目录结构
+
+```
+universal_doc_parser/
+├── config/                              # 配置文件
+│   ├── bank_statement_yusys_v2.yaml    # 银行流水配置(Docling + PaddleOCR-VL)
+│   ├── bank_statement_mineru_v2.yaml   # 银行流水配置(MinerU layout + MinerU VLM)
+│   ├── bank_statement_mineru_vl.yaml   # 银行流水配置(MinerU VLM)
+│   └── bank_statement_paddle_vl.yaml   # 银行流水配置(PaddleOCR-VL)
+│
+├── core/                                # 核心处理模块
+│   ├── pipeline_manager_v2.py          # 主流水线管理器 ⭐
+│   ├── element_processors.py           # 元素处理器(文本、表格、图片等)
+│   ├── coordinate_utils.py             # 坐标转换工具
+│   ├── layout_utils.py                 # 布局处理工具(排序、去重、SpanMatcher)⭐
+│   ├── pdf_utils.py                    # PDF 处理工具
+│   ├── config_manager.py               # 配置管理
+│   └── model_factory.py                # 模型工厂
+│
+├── models/                              # 模型适配器
+│   └── adapters/
+│       ├── base.py                     # 适配器基类
+│       ├── mineru_adapter.py           # MinerU 适配器
+│       ├── paddle_vl_adapter.py        # PaddleOCR-VL 适配器
+│       ├── paddle_layout_detector.py   # PaddleX RT-DETR 布局检测器
+│       └── docling_layout_adapter.py   # Docling 布局检测器 ⭐
+│
+├── utils/                               # 输出工具模块
+│   ├── output_formatter_v2.py          # 统一输出格式化器 ⭐
+│   ├── json_formatters.py              # JSON 格式化(middle.json, page.json)
+│   ├── markdown_generator.py           # Markdown 生成器
+│   ├── html_generator.py               # HTML 生成器
+│   ├── visualization_utils.py          # 可视化工具(layout/OCR 图片)
+│   └── normalize_financial_numbers.py  # 金额数字标准化
+│
+├── main_v2.py                           # 命令行入口 ⭐
+└── 模型统一框架.md                       # 本文档
+```
+
+---
+
+## 使用方法
+
+### 命令行
+
+```bash
+# 处理单个 PDF 文件
+python main_v2.py -i document.pdf -c config/bank_statement_yusys_v2.yaml
+
+# 处理图片目录
+python main_v2.py -i ./images/ -c config/bank_statement_yusys_v2.yaml
+
+# 开启 debug 模式(输出可视化图片)
+python main_v2.py -i doc.pdf -c config/bank_statement_yusys_v2.yaml --debug
+
+# 指定输出目录
+python main_v2.py -i doc.pdf -c config/bank_statement_yusys_v2.yaml -o ./my_output/
+```
+
+### Python API
+
+```python
+from core.pipeline_manager_v2 import EnhancedDocPipeline
+from utils import OutputFormatterV2
+
+# 初始化流水线
+with EnhancedDocPipeline("config/bank_statement_yusys_v2.yaml") as pipeline:
+    # 处理文档
+    results = pipeline.process_document("document.pdf")
+    
+    # 保存结果
+    formatter = OutputFormatterV2("./output")
+    output_paths = formatter.save_results(results, {
+        'save_json': True,
+        'save_markdown': True,
+        'save_html': True,
+        'save_layout_image': True,  # debug
+        'save_ocr_image': True,     # debug
+        'normalize_numbers': True,   # 金额标准化
+    })
+```
+
+---
+
+## 输出文件说明
+
+| 输出文件 | 说明 |
+|---------|------|
+| `{doc}_middle.json` | MinerU 标准格式 JSON |
+| `{doc}_page_001.json` | 每页独立 JSON(包含单元格坐标) |
+| `{doc}.md` | 完整文档 Markdown |
+| `{doc}_page_001.md` | 每页独立 Markdown(带坐标注释) |
+| `tables/*.html` | 表格 HTML 文件(带 data-bbox 坐标) |
+| `images/` | 提取的图片元素 |
+| `{doc}_page_001_layout.png` | Layout 可视化图片(debug 模式) |
+| `{doc}_page_001_ocr.png` | OCR 可视化图片(debug 模式) |
+| `*_original.*` | 标准化前的原始文件(如有修改) |
+
+---
+
+## 配置说明
+
+配置文件采用 YAML 格式,主要配置项:
+
+```yaml
+# 场景名称
+scene_name: "bank_statement"
+
+# 输入配置
+input:
+  supported_formats: [".pdf", ".png", ".jpg", ".jpeg"]
+  dpi: 200  # PDF 转图片的 DPI
+
+# 预处理(方向识别)
+preprocessor:
+  module: "mineru"
+  orientation_classifier:
+    enabled: true  # 扫描件自动开启
+
+# 版式检测
+layout_detection:
+  module: "docling"  # 可选: "mineru", "paddle", "docling"
+  model_name: "docling-layout-old"
+  model_dir: "ds4sd/docling-layout-old"  # HuggingFace 模型仓库
+  device: "cpu"
+  conf: 0.3
+
+# VL 识别(表格、公式)
+vl_recognition:
+  module: "paddle"  # 可选: "mineru", "paddle"
+  backend: "http-client"
+  server_url: "http://xxx:8110"
+  table_recognition:
+    return_cells_coordinate: true
+
+# OCR 识别
+ocr_recognition:
+  module: "mineru"
+  language: "ch"
+
+# 输出配置
+output:
+  create_subdir: false        # 是否创建子目录
+  save_json: true
+  save_markdown: true
+  save_html: true
+  save_layout_image: false    # debug 模式开启
+  save_ocr_image: false       # debug 模式开启
+  normalize_numbers: true     # 金额数字标准化
+```
+
+---
+
+## 支持的布局检测器
+
+### 1. Docling Layout (推荐)
+
+基于 HuggingFace transformers 的 RT-DETR 模型。
+
+```yaml
+layout_detection:
+  module: "docling"
+  model_name: "docling-layout-old"
+  model_dir: "ds4sd/docling-layout-old"
+```
+
+支持的模型:
+- `ds4sd/docling-layout-old`
+- `ds4sd/docling-layout-heron`
+- `ds4sd/docling-layout-egret-medium`
+- `ds4sd/docling-layout-egret-large`
+
+### 2. PaddleX RT-DETR (ONNX)
+
+基于 ONNX Runtime 的 PaddleX 布局检测器。
+
+```yaml
+layout_detection:
+  module: "paddle"
+  model_name: "RT-DETR-H_layout_17cls"
+  model_dir: "/path/to/RT-DETR-H_layout_17cls.onnx"
+```
+
+### 3. MinerU DocLayout-YOLO
+
+MinerU 内置的布局检测模型。
+
+```yaml
+layout_detection:
+  module: "mineru"
+  model_name: "layout"
+```
+
+---
+
+## 类别映射
+
+所有布局检测器的输出都会统一映射到 MinerU/EnhancedDocPipeline 类别体系:
+
+| 类别分类 | 包含类别 |
+|---------|---------|
+| **文本类** (TEXT) | text, title, header, footer, page_number, ref_text, page_footnote, aside_text, ocr_text |
+| **表格类** (TABLE) | table, table_body, table_caption, table_footnote |
+| **图片类** (IMAGE) | image, image_body, figure, image_caption, image_footnote |
+| **公式类** (EQUATION) | interline_equation, inline_equation, equation |
+| **代码类** (CODE) | code, code_body, code_caption, algorithm |
+| **丢弃类** (DISCARD) | abandon, discarded |
+
+---
+
+## 核心组件
+
+### 1. EnhancedDocPipeline (`pipeline_manager_v2.py`)
+
+主流水线管理器,实现完整处理流程:
+- PDF 分类(扫描件/数字原生)
+- 页面方向识别
+- Layout 检测与去重
+- **整页 OCR + Span-Block 匹配** ⭐
+- 元素分类处理
+- 阅读顺序排序
+- 坐标转换
+
+### 2. SpanMatcher (`layout_utils.py`)
+
+OCR Span 与 Layout Block 匹配器,参考 MinerU 实现:
+- `match_spans_to_blocks()` - 将 spans 匹配到对应的 blocks
+- `merge_spans_to_text()` - 将多个 spans 合并为文本
+- `remove_duplicate_spans()` - 去除重复 spans
+- `poly_to_bbox()` - 多边形坐标转 bbox
+
+### 3. ElementProcessors (`element_processors.py`)
+
+元素处理器,处理不同类型的元素:
+- `process_text_element()` - 文本处理(支持 pre_matched_spans)
+- `process_table_element()` - 表格处理(VLM + OCR 坐标匹配)
+- `process_image_element()` - 图片处理
+- `process_equation_element()` - 公式处理
+- `process_code_element()` - 代码处理
+- `process_discard_element()` - 丢弃元素处理(支持 pre_matched_spans)
+
+### 3. OutputFormatterV2 (`output_formatter_v2.py`)
+
+统一输出格式化器:
+- MinerU 标准 middle.json 格式
+- 每页独立 JSON(含单元格坐标)
+- Markdown 输出(完整版 + 按页)
+- 表格 HTML(带 data-bbox 属性)
+- 可视化图片(Layout/OCR)
+- 金额数字标准化
+
+### 4. TableCellMatcher (来自 `merger`)
+
+表格单元格坐标匹配器:
+- 使用动态规划进行行内单元格匹配
+- 将 OCR 检测框与 VLM 表格结构匹配
+- 输出带坐标的增强 HTML
+
+---
+
+## 依赖说明
+
+### MinerU 组件
+- `mineru.utils.pdf_image_tools` - PDF 图像处理
+- `mineru.utils.pdf_text_tool` - PDF 文本提取
+- `mineru.utils.boxbase` - 边界框计算
+- `mineru.model.ocr` - OCR 模型
+- `mineru.model.ori_cls` - 方向分类模型
+
+### Merger 组件(来自 ocr_verify)
+- `merger.table_cell_matcher.TableCellMatcher` - 单元格坐标匹配
+- `merger.text_matcher.TextMatcher` - 文本匹配
+
+### 其他依赖
+- transformers - Docling 模型加载
+- huggingface_hub - 模型下载
+- onnxruntime - ONNX 模型推理
+- Pillow - 图像处理
+- NumPy - 数值计算
+- BeautifulSoup4 - HTML 解析
+- PyYAML - 配置文件解析
+- loguru - 日志
+
+---
+
+## 安装 merger 模块
+
+```bash
+# 1. 进入 ocr_verify 目录
+cd /Users/zhch158/workspace/repository.git/ocr_verify
+
+# 2. 安装 merger 模块(可编辑模式)
+pip uninstall -y merger && pip install -e .
+
+# 3. 验证安装
+python3 -c "from merger.table_cell_matcher import TableCellMatcher; print('✅ 安装成功')"
+```
+
+完成后,在任何 Python 文件中都可以直接导入:
+
+```python
+from merger.table_cell_matcher import TableCellMatcher
+from merger.text_matcher import TextMatcher
+from merger.bbox_extractor import BBoxExtractor
+```
+
+---
+
+## 项目结构
+
+```
+/Users/zhch158/workspace/repository.git/
+├── ocr_verify/                          # 源项目
+│   ├── setup.py                         # 安装配置
+│   └── merger/                          # 表格匹配模块
+│       ├── table_cell_matcher.py        # 表格单元格匹配
+│       ├── text_matcher.py              # 文本匹配
+│       └── bbox_extractor.py            # 边界框提取
+│
+└── MinerU/                              # 目标项目
+    └── zhch/
+        └── universal_doc_parser/        # 金融文档处理框架
+            ├── core/                    # 核心处理模块
+            ├── models/adapters/         # 模型适配器
+            ├── utils/                   # 输出工具
+            └── config/                  # 配置文件
+```

+ 283 - 0
ocr_tools/universal_doc_parser/流式处理模式说明.md

@@ -0,0 +1,283 @@
+# 流式处理模式说明
+
+## 📋 概述
+
+流式处理模式是对原有批量处理模式的优化,主要解决大文档处理时的内存占用问题。
+
+### 原有模式(批量处理)
+
+- **处理方式**:将所有页面读入内存,处理完成后统一保存
+- **内存占用**:高(所有页面的图像、OCR结果、表格数据都在内存中)
+- **适用场景**:小到中等文档(< 50页)
+
+### 流式处理模式
+
+- **处理方式**:按页处理,处理完一页立即保存并释放内存
+- **内存占用**:低(只保留当前页面的数据)
+- **适用场景**:大文档(> 50页)或内存受限环境
+
+## 🎯 核心优势
+
+### 1. **内存优化**
+
+```
+批量模式内存占用:
+- 100页文档 × 每页约50MB = 5GB内存
+
+流式模式内存占用:
+- 当前页约50MB + 元数据约10MB = 60MB内存
+```
+
+### 2. **容错性提升**
+
+- 批量模式:处理到第99页出错,前98页数据丢失
+- 流式模式:处理到第99页出错,前98页已保存,可继续处理
+
+### 3. **实时反馈**
+
+- 可以实时查看已处理页面的结果
+- 不需要等待所有页面处理完成
+
+## 🔧 使用方法
+
+### 命令行使用
+
+```bash
+# 使用流式处理模式
+python main_v2.py -i large_doc.pdf -c config.yaml --streaming
+
+# 批量处理模式(默认)
+python main_v2.py -i small_doc.pdf -c config.yaml
+```
+
+### 代码使用
+
+```python
+from core.pipeline_manager_v2_streaming import StreamingDocPipeline
+
+# 初始化流式处理流水线
+pipeline = StreamingDocPipeline(config_path, output_dir)
+
+# 处理文档
+results = pipeline.process_document_streaming(
+    document_path="large_doc.pdf",
+    page_range="1-100",
+    output_config={
+        'save_json': True,
+        'save_markdown': True,
+        'save_page_json': True,
+        'normalize_numbers': True,
+        'merge_cross_page_tables': True,
+    }
+)
+```
+
+## 📊 处理流程对比
+
+### 批量处理模式
+
+```
+1. 加载所有页面到内存
+2. 处理第1页 → 存储到 results['pages'][0]
+3. 处理第2页 → 存储到 results['pages'][1]
+4. ...
+5. 处理第N页 → 存储到 results['pages'][N-1]
+6. 统一保存所有结果
+7. 生成完整Markdown
+```
+
+### 流式处理模式
+
+```
+1. 初始化Markdown文件(流式写入)
+2. 处理第1页
+   → 立即保存 page_001.json
+   → 立即保存图片元素
+   → 写入Markdown(单页内容)
+   → 释放内存
+3. 处理第2页
+   → 立即保存 page_002.json
+   → ...
+4. ...
+5. 处理第N页
+   → 立即保存 page_NNN.json
+   → ...
+6. 关闭Markdown文件
+7. 从已保存的JSON文件加载
+8. 跨页表格合并
+9. 重新生成完整Markdown(包含合并后的表格)
+10. 生成middle.json
+```
+
+## 📁 输出文件结构
+
+### 批量模式输出
+
+```
+output/
+├── doc_name_middle.json          # 完整middle.json
+├── doc_name.md                   # 完整Markdown
+├── doc_name_page_001.json       # 第1页JSON
+├── doc_name_page_002.json       # 第2页JSON
+└── images/                       # 图片元素
+```
+
+### 流式模式输出
+
+```
+output/
+├── doc_name_middle.json          # 完整middle.json(最后生成)
+├── doc_name.md                   # 完整Markdown(包含合并表格)
+├── doc_name_page_001.json       # 第1页JSON(立即保存)
+├── doc_name_page_002.json       # 第2页JSON(立即保存)
+├── images/                       # 图片元素(立即保存)
+└── _temp_pages/                  # 临时JSON文件(最后清理)
+    ├── page_001.json
+    └── page_002.json
+```
+
+## ⚙️ 配置选项
+
+### output_config 参数
+
+| 参数 | 类型 | 默认值 | 说明 |
+|-----|------|--------|------|
+| `save_json` | bool | True | 是否生成middle.json |
+| `save_markdown` | bool | True | 是否生成Markdown |
+| `save_page_json` | bool | True | 是否保存每页JSON |
+| `save_images` | bool | True | 是否保存图片元素 |
+| `save_layout_image` | bool | False | 是否保存layout可视化图片 |
+| `save_ocr_image` | bool | False | 是否保存OCR可视化图片 |
+| `normalize_numbers` | bool | True | 是否标准化金额数字 |
+| `merge_cross_page_tables` | bool | True | 是否合并跨页表格 |
+| `cleanup_temp_files` | bool | True | 是否清理临时文件 |
+
+## 🔍 性能对比
+
+### 内存占用
+
+| 文档页数 | 批量模式 | 流式模式 | 节省 |
+|---------|---------|---------|------|
+| 10页 | ~500MB | ~60MB | 88% |
+| 50页 | ~2.5GB | ~60MB | 97.6% |
+| 100页 | ~5GB | ~60MB | 98.8% |
+| 500页 | ~25GB | ~60MB | 99.76% |
+
+### 处理时间
+
+- **批量模式**:略快(无需重复加载JSON)
+- **流式模式**:略慢(需要保存和重新加载JSON)
+
+**差异**:通常 < 5%,对于大文档可以忽略
+
+## ⚠️ 注意事项
+
+### 1. 跨页表格合并
+
+- 流式模式需要重新加载所有页面JSON才能合并跨页表格
+- 这会导致额外的I/O开销,但内存占用仍然很低
+
+### 2. 临时文件
+
+- 流式模式会在 `_temp_pages/` 目录创建临时JSON文件
+- 处理完成后会自动清理(如果 `cleanup_temp_files=True`)
+
+### 3. Markdown生成
+
+- 流式模式会先生成一个临时Markdown(边处理边写入)
+- 跨页表格合并后,会重新生成完整的Markdown
+
+### 4. 错误恢复
+
+- 如果处理中断,已保存的页面JSON可以用于恢复
+- 可以指定 `page_range` 参数继续处理剩余页面
+
+## 🎯 使用建议
+
+### 使用流式模式
+
+- ✅ 文档页数 > 50页
+- ✅ 内存受限环境(< 8GB RAM)
+- ✅ 需要实时查看处理结果
+- ✅ 需要容错性(处理中断后可恢复)
+
+### 使用批量模式
+
+- ✅ 文档页数 < 50页
+- ✅ 内存充足(> 16GB RAM)
+- ✅ 需要最快处理速度
+- ✅ 不需要实时查看结果
+
+## 📝 示例
+
+### 处理大文档(100页)
+
+```bash
+# 使用流式模式,节省内存
+python main_v2.py \
+  -i large_report.pdf \
+  -c config/bank_statement_mineru_v2.yaml \
+  --streaming \
+  --output_dir ./output/large_report_streaming
+```
+
+### 处理指定页面范围
+
+```bash
+# 流式模式 + 页面范围
+python main_v2.py \
+  -i large_report.pdf \
+  -c config.yaml \
+  --streaming \
+  -p 1-50  # 只处理前50页
+```
+
+### 继续处理剩余页面
+
+```bash
+# 如果之前处理到第50页中断,可以继续处理
+python main_v2.py \
+  -i large_report.pdf \
+  -c config.yaml \
+  --streaming \
+  -p 51-  # 从第51页到最后
+```
+
+## 🔄 迁移指南
+
+### 从批量模式迁移到流式模式
+
+1. **添加 `--streaming` 参数**:
+   ```bash
+   # 之前
+   python main_v2.py -i doc.pdf -c config.yaml
+   
+   # 之后
+   python main_v2.py -i doc.pdf -c config.yaml --streaming
+   ```
+
+2. **代码修改**:
+   ```python
+   # 之前
+   from core.pipeline_manager_v2 import EnhancedDocPipeline
+   pipeline = EnhancedDocPipeline(config_path)
+   results = pipeline.process_document(document_path)
+   
+   # 之后
+   from core.pipeline_manager_v2_streaming import StreamingDocPipeline
+   pipeline = StreamingDocPipeline(config_path, output_dir)
+   results = pipeline.process_document_streaming(
+       document_path,
+       output_config=output_config
+   )
+   ```
+
+3. **输出格式保持一致**:
+   - 流式模式和批量模式的输出格式完全相同
+   - 可以直接替换使用,无需修改后续处理代码
+
+## 📚 相关文档
+
+- [模型统一框架.md](./模型统一框架.md) - 整体架构说明
+- [OCR识别差异分析与改进方案.md](./OCR识别差异分析与改进方案.md) - OCR优化说明
+