1
0

13 Revīzijas 4cd6801a2f ... 8bf66bc119

Autors SHA1 Ziņojums Datums
  zhch158_admin 8bf66bc119 feat(增强印章OCR处理): 在ocr_validator_utils.py中新增对印章类别的支持,优化文本解析逻辑,添加印章相关信息的提取与处理,提升印章识别能力与数据解析的准确性。 1 mēnesi atpakaļ
  zhch158_admin 87b0f0a6e8 feat(增强OCR布局管理): 在ocr_validator_layout.py中新增类别颜色转换函数,优化边界框收集逻辑,支持按类别着色,提升可视化效果与用户体验。 1 mēnesi atpakaļ
  zhch158_admin 80d0437081 feat(更新可视化工具): 在visualization_utils.py中新增图表和印章的颜色定义,调整OCR框和单元格框的颜色为亮蓝,以提升可视化效果的一致性。 1 mēnesi atpakaļ
  zhch158_admin bcea502090 feat(增强布局绘制功能): 在module_debug_viz.py中新增印章和图表的颜色定义,优化绘制逻辑,添加OCR框收集功能,提升文档元素的可视化效果与识别能力。 1 mēnesi atpakaļ
  zhch158_admin cb83d24f8c feat(更新布局检测器与新增印章OCR适配器): 修改PaddleLayoutDetector和PPDocLayoutV3Detector类的类别映射,优化印章和图表的处理逻辑,同时新增SealOCRRecognizer适配器以支持印章OCR识别,提升文档解析与印章识别能力。 1 mēnesi atpakaļ
  zhch158_admin b3d375584d feat(更新布局检测器类别): 在MinerUVLLayoutDetector类中添加对印章和图表类别的支持,优化文档解析能力。 1 mēnesi atpakaļ
  zhch158_admin 49a0fefc0e feat(新增SealOCR识别器支持): 在适配器模块中引入SealOCRRecognizer,并更新BaseLayoutDetector类以处理印章类别的重叠情况,优化印章识别流程。 1 mēnesi atpakaļ
  zhch158_admin 797bad05df feat(增强文档处理管道): 在EnhancedDocPipeline类中添加印章OCR识别器的初始化与清理逻辑,更新图片相关元素类别以支持图表,优化印章元素处理流程,提升印章识别的准确性与灵活性。 1 mēnesi atpakaļ
  zhch158_admin d1e3ac399d feat(新增印章OCR识别器创建方法): 在ModelFactory类中添加create_seal_ocr_recognizer方法,用于创建并初始化SealOCRRecognizer,提升印章OCR识别能力。 1 mēnesi atpakaļ
  zhch158_admin 6e1b1bead4 feat(新增印章补充检测器): 在SmartLayoutRouter类中添加seal补充检测功能,初始化PP-DocLayoutV3模型以提升印章区域的识别能力,并实现结果合并与调试信息保存,优化印章检测流程。 1 mēnesi atpakaļ
  zhch158_admin 31ae5b84ca feat(新增印章OCR识别器): 在ElementProcessors类中添加seal_ocr_recognizer参数,优化印章处理逻辑,优先使用SealOCRRecognizer进行识别,回退至VLM,提升印章识别的准确性与灵活性。 1 mēnesi atpakaļ
  zhch158_admin e72a51154f feat(新增印章补充检测功能): 在多个YAML配置文件中添加印章补充检测配置,启用PP-DocLayoutV3模型以提升对密封区域的识别能力,同时更新银行流水描述以反映新功能。 1 mēnesi atpakaļ
  zhch158_admin 48ff3fcd63 feat(新增印章识别处理流程文档): 添加MinerU印章识别处理流程的详细文档,涵盖布局检测、印章文字OCR及相关模型配置,提升用户对印章识别功能的理解与使用指导。 1 mēnesi atpakaļ

+ 436 - 0
docs/mineru/印章识别-seal处理流程.md

@@ -0,0 +1,436 @@
+# MinerU 印章(Seal)识别处理流程
+
+## 概览
+
+MinerU 从 v2.5 版本开始在 pipeline 后端中支持印章(seal)识别。印章识别不依赖单独的专有检测模型,而是分两步完成:
+
+1. **布局检测**:PP-DocLayoutV2 模型在 25 个布局类别中包含了 `seal`(第 20 类),一次性完成印章区域的定位。
+2. **印章文字 OCR**:使用专门针对印章场景调优的 PaddleOCR 变体模型,对检测到的印章区域进行文字检测与识别。
+
+## 使用的模型
+
+| 步骤 | 模型 | 说明 |
+|------|------|------|
+| **Step 1: 印章区域检测** | PP-DocLayoutV2(基于 RT-DETR) | 在页面上定位印章区域,输出 bbox 坐标 |
+| **Step 2: 印章文字检测** | `seal_PP-OCRv4_det_server_infer.pth` | 专用 DB 文字检测器,使用 PP-HGNet_small 骨干网络,多边形检测框 |
+| **Step 2: 印章文字识别** | `ch_PP-OCRv4_rec_server_infer.pth` | 通用中文文字识别模型 |
+| **轻量版** | `seal_PP-OCRv4_det_infer.pth` + `ch_PP-OCRv4_rec_infer.pth` | CPU 环境下自动切换的轻量版 |
+
+**关键点**:不需要单独的"印章检测模型"。印章检测已整合在 PP-DocLayoutV2 中,共享同一个视觉编码器。
+
+---
+
+## 一、Step 1: PP-DocLayoutV2 布局检测
+
+### 1.1 标签定义
+
+PP-DocLayoutV2 定义了 25 个布局类别,`seal` 是第 20 类(索引 20):
+
+```python
+# mineru/model/layout/pp_doclayoutv2.py
+PP_DOCLAYOUT_V2_LABELS = [
+    "abstract",           # 0
+    "algorithm",          # 1
+    "aside_text",         # 2
+    "chart",              # 3
+    "content",            # 4
+    "display_formula",    # 5
+    "doc_title",          # 6
+    "figure_title",       # 7
+    "footer",             # 8
+    "footer_image",       # 9
+    "footnote",           # 10
+    "formula_number",     # 11
+    "header",             # 12
+    "header_image",       # 13
+    "image",              # 14
+    "inline_formula",     # 15
+    "number",             # 16
+    "paragraph_title",    # 17
+    "reference",          # 18
+    "reference_content",  # 19
+    "seal",               # 20 印章 ←
+    "table",              # 21
+    "text",               # 22
+    "vertical_text",      # 23
+    "vision_footnote",    # 24
+]
+```
+
+### 1.2 置信度阈值
+
+印章的置信度阈值设为 **0.45**,低于大多数其他类别,以确保印章不被漏检:
+
+```python
+DEFAULT_CLASS_THRESHOLDS = [
+    # ... 其他类别 ...
+    0.45,  # 20 seal  ← 较低的阈值
+    # ...
+]
+```
+
+### 1.3 MagicModel 中的映射
+
+布局检测结果进入 `MagicModel` 后,`seal` 标签被映射为 `BlockType.SEAL`:
+
+```python
+# mineru/backend/pipeline/pipeline_magic_model.py
+PP_DOCLAYOUT_V2_LABELS_TO_BLOCK_TYPES = {
+    # ...
+    "seal": BlockType.SEAL,
+    # ...
+}
+```
+
+`BlockType.SEAL` 是 MinerU 内部枚举类中定义的类型:
+
+```python
+# mineru/utils/enum_class.py
+class BlockType:
+    # ...
+    SEAL = "seal"
+    # ...
+```
+
+### 1.4 印章块的 span 构造
+
+在 `MagicModel.__build_page_blocks()` 中,`BlockType.SEAL` 类型的块被构造为纯图的 span(类似 image/table/chart):
+
+```python
+# mineru/backend/pipeline/pipeline_magic_model.py (__build_page_blocks)
+elif block["type"] in [BlockType.SEAL]:
+    span_type = ContentType.SEAL
+
+if span_type in [
+    ContentType.IMAGE,
+    ContentType.TABLE,
+    ContentType.CHART,
+    ContentType.INTERLINE_EQUATION,
+    ContentType.SEAL      # ← 印章走纯图路径
+]:
+    span = {
+        "bbox": block["bbox"],
+        "type": span_type,
+    }
+    if span_type == ContentType.SEAL:
+        span["content"] = block.get("text")  # 印章 OCR 识别出的文字
+```
+
+印章块被当作视觉块处理,不会被送入常规的文本 OCR 流。
+
+---
+
+## 二、Step 2: 印章文字 OCR
+
+### 2.1 入口:batch_analyze.py
+
+在 `batch_analyze.py` 中,所有页面的布局检测完成后,专门有一段代码处理印章 OCR(约第 873-918 行)。
+
+#### 流程:
+
+**a) 收集印章块**
+
+遍历所有页面的布局结果,收集 `label == "seal"` 的块:
+
+```python
+# mineru/backend/pipeline/batch_analyze.py
+seal_ocr_items = []
+for ocr_res_list_dict in ocr_res_list_all_page:
+    for layout_res_item in ocr_res_list_dict['layout_res']:
+        if layout_res_item.get("label") == "seal":
+            seal_ocr_items.append((ocr_res_list_dict, layout_res_item))
+```
+
+**b) 裁剪印章子图**
+
+根据布局检测输出的 bbox 从原图中裁剪出印章区域:
+
+```python
+seal_bbox = normalize_to_int_bbox(
+    layout_res_item.get("bbox"),
+    image_size=(image_h, image_w),
+)
+x0, y0, x1, y1 = seal_bbox
+seal_crop_rgb = np_img[y0:y1, x0:x1]  # 裁剪印章子图
+```
+
+**c) 加载 Seal OCR 模型**
+
+通过 `atom_model_manager.get_atom_model()` 获取 `lang="seal"` 的 `PytorchPaddleOCR` 实例:
+
+```python
+if seal_ocr_model is None:
+    seal_ocr_model = atom_model_manager.get_atom_model(
+        atom_model_name=AtomicModel.OCR,
+        lang="seal",       # ← 关键:指定印章模式
+    )
+```
+
+**d) 运行 OCR**
+
+对裁剪出的印章图片执行检测+识别:
+
+```python
+seal_crop_bgr = cv2.cvtColor(seal_crop_rgb, cv2.COLOR_RGB2BGR)
+seal_ocr_res = seal_ocr_model.ocr(seal_crop_bgr, det=True, rec=True)[0]
+```
+
+**e) 汇总文字**
+
+将识别出的所有文本段拼接为列表,存入 `layout_res_item["text"]`:
+
+```python
+seal_texts = []
+for seal_item in seal_ocr_res:
+    rec_result = seal_item[1]       # (text, score) 元组
+    rec_text = rec_result[0]
+    if rec_text:
+        seal_texts.append(rec_text)
+layout_res_item["text"] = seal_texts
+```
+
+### 2.2 Seal OCR 模型配置
+
+#### 模型文件
+
+定义在 `mineru/model/utils/pytorchocr/utils/resources/models_config.yml`:
+
+```yaml
+seal:
+    det: seal_PP-OCRv4_det_server_infer.pth    # 印章文字检测模型
+    rec: ch_PP-OCRv4_rec_server_infer.pth      # 中文文字识别模型
+    dict: ppocr_keys_v1.txt                    # 字符字典
+
+seal_lite:                                     # CPU 环境自动降级为 lite 版
+    det: seal_PP-OCRv4_det_infer.pth
+    rec: ch_PP-OCRv4_rec_infer.pth
+    dict: ppocr_keys_v1.txt
+```
+
+#### 检测模型架构
+
+`seal_PP-OCRv4_det_server_infer` 使用 PP-HGNet_small 骨干 + DB Head(`mineru/model/utils/pytorchocr/utils/resources/arch_config.yaml`):
+
+```yaml
+seal_PP-OCRv4_det_server_infer:
+  model_type: det
+  algorithm: DB
+  Backbone:
+    name: PPHGNet_small
+  Head:
+    name: DBHead
+    k: 50
+```
+
+#### 特殊参数(针对印章场景调优)
+
+在 `PytorchPaddleOCR.__init__()` 中,当 `lang == "seal"` 时,会覆盖默认的 OCR 参数:
+
+```python
+# mineru/model/ocr/pytorch_paddle.py
+if self.is_seal:
+    kwargs['det_limit_side_len'] = 736       # 最小边长限制(确保小印章不被过度缩放)
+    kwargs['det_limit_type'] = 'min'
+    kwargs['det_max_side_limit'] = 4000      # 最大边长限制
+    kwargs['det_db_thresh'] = 0.2            # 极低检测阈值(弧形细小文字不易漏检)
+    kwargs['det_db_box_thresh'] = 0.6        # 检测框阈值
+    kwargs['det_db_unclip_ratio'] = 0.5      # 文本框扩展比例
+    kwargs['det_box_type'] = 'poly'          # 使用多边形检测框(印章文字常沿弧形分布)
+    kwargs['use_dilation'] = False           # 不进行膨胀操作
+    kwargs['enable_merge_det_boxes'] = False # 不合并检测框
+    kwargs['drop_score'] = 0                 # 不丢弃任何低置信度结果
+```
+
+关键区别总结:
+
+| 参数 | seal 模式 | 普通 OCR 模式 | 原因 |
+|------|-----------|---------------|------|
+| `det_box_type` | `'poly'`(多边形) | `'quad'`(四边形) | 印章文字沿弧形分布,矩形框效果差 |
+| `det_db_thresh` | `0.2` | `0.3` | 印章文字细小,需要更低阈值 |
+| `drop_score` | `0` | `0.5` | 印章文字模糊,不丢弃低置信度 |
+| `enable_merge_det_boxes` | `False` | `True` | 印章文字排列稀疏,不合并检测框 |
+| `det_limit_side_len` | `736`(`min`模式) | `960`(`max`模式) | 确保小印章有足够分辨率 |
+
+### 2.3 多边形裁剪与校正
+
+印章文字检测后,使用专用的多边形裁剪和校正管线:
+
+```python
+# mineru/model/ocr/pytorch_paddle.py (ocr 方法)
+if self.is_seal:
+    dt_boxes = self._seal_sort_boxes(dt_boxes)              # 排序检测框
+    img_crop_list = self._seal_crop_by_polys(ori_im, dt_boxes)  # 多边形裁剪
+```
+
+`CropByPolys` 类(`mineru/model/ocr/seal_crop.py`)负责:
+- 对检测到的多边形框进行排序
+- 使用 `cv2.minAreaRect` + 透视变换进行旋转裁剪
+- 对不规则多边形区域,通过 `AutoRectifier` 进行仿射/单应性校正,将弧形文字展平为水平文字
+- 当多边形简化失败时,回退到外接矩形裁剪以保证流程不会中断
+
+---
+
+## 三、输出格式
+
+印章识别结果在多个输出文件中体现:
+
+### 3.1 content_list.json
+
+印章作为独立的内容类型 `seal` 输出:
+
+```json
+{
+    "type": "seal",
+    "img_path": "images/xxx.jpg",
+    "text": ["识别出的印章文字"],
+    "bbox": [x0, y0, x1, y1],
+    "page_idx": 0
+}
+```
+
+生成代码位于 `mineru/backend/pipeline/pipeline_middle_json_mkcontent.py` 的 `_get_seal_span` 和 `_get_seal_text` 函数。
+
+### 3.2 content_list_v2.json (3.0+)
+
+采用新版结构化格式:
+
+```json
+{
+    "type": "seal",
+    "content": {
+        "image_source": {
+            "path": "images/xxx.jpg"
+        },
+        "seal_content": [
+            {"type": "text", "content": "识别出的印章文字"}
+        ]
+    },
+    "bbox": [x0, y0, x1, y1]
+}
+```
+
+### 3.3 middle.json
+
+印章块出现在 `preproc_blocks` 中,类型为 `seal`,包含 `lines[].spans[]` 结构,span 类型为 `seal`。
+
+### 3.4 model.json
+
+印章检测结果在布局检测阶段输出,包含 `cls_id: 20`、`label: "seal"`、`score` 和 `bbox` 信息。
+
+### 3.5 layout.pdf
+
+印章区域在布局可视化 PDF 中以独立颜色块标注,与其他布局类别一同展示。
+
+---
+
+## 四、seal 内容类型的枚举定义
+
+```python
+# mineru/utils/enum_class.py
+
+class BlockType:
+    SEAL = "seal"           # 块级类型
+
+class ContentType:
+    SEAL = 'seal'           # content_list.json 中的类型
+
+class ContentTypeV2:
+    SEAL = 'seal'           # content_list_v2.json 中的类型
+```
+
+---
+
+## 五、完整流程图
+
+```
+输入 PDF 页面
+    │
+    ▼
+┌─────────────────────────────────┐
+│  PP-DocLayoutV2 布局检测        │
+│  (基于 RT-DETR)                  │
+│  检测 25 个类别,含 seal         │
+│  cls_id=20, threshold=0.45      │
+└───────────────┬─────────────────┘
+                │
+    ┌───────────▼───────────┐
+    │  过滤 label=="seal"    │
+    │  的布局检测结果         │
+    └───────────┬───────────┘
+                │
+                ▼
+    ┌───────────────────────────┐
+    │  从原图按 bbox 裁剪        │
+    │  seal_crop = img[y0:y1, x0:x1] │
+    └───────────┬───────────────┘
+                │
+                ▼
+    ┌───────────────────────────┐
+    │  PytorchPaddleOCR(lang="seal") │
+    │                           │
+    │  ┌─ 检测 (det):          │
+    │  │  seal_PP-OCRv4_det    │
+    │  │  DB + PP-HGNet_small  │
+    │  │  多边形框检测           │
+    │  └───────────────────────│
+    │                           │
+    │  ┌─ 识别 (rec):          │
+    │  │  ch_PP-OCRv4_rec      │
+    │  │  中文文字识别           │
+    │  └───────────────────────│
+    └───────────┬───────────────┘
+                │
+                ▼
+    ┌───────────────────────────┐
+    │  CropByPolys +            │
+    │  AutoRectifier            │
+    │  多边形裁剪 + 仿射校正     │
+    │  弧形文字 → 水平文字       │
+    └───────────┬───────────────┘
+                │
+                ▼
+    ┌───────────────────────────┐
+    │  输出结果                  │
+    │  • seal_text (文字列表)    │
+    │  • seal_image (截图)       │
+    │  → content_list.json      │
+    │  → middle.json            │
+    │  → model.json             │
+    │  → layout.pdf             │
+    └───────────────────────────┘
+```
+
+---
+
+## 六、调试功能
+
+Seal OCR 支持环境变量控制的调试输出:
+
+| 环境变量 | 说明 |
+|----------|------|
+| `MINERU_SEAL_OCR_DEBUG=1` | 启用调试模式 |
+| `MINERU_SEAL_OCR_DEBUG_DIR=/path/to/dir` | 指定调试输出目录(默认 `output_images/seal_ocr_debug/`) |
+
+调试模式下,每个印章识别样本会输出:
+- `input.png` — 输入图像
+- `det_vis.png` — 检测框可视化
+- `crop_NN.png` — 每个裁剪后的文字区域
+- `meta.json` — 元数据(文字、置信度)
+
+---
+
+## 七、关键文件索引
+
+| 文件 | 作用 |
+|------|------|
+| `mineru/model/layout/pp_doclayoutv2.py` | PP-DocLayoutV2 模型定义,包含 seal 类别(索引 20)|
+| `mineru/backend/pipeline/batch_analyze.py` | 印章 OCR 主流程(第 873-918 行)|
+| `mineru/backend/pipeline/pipeline_magic_model.py` | MagicModel 中的 seal 块映射与 span 构造 |
+| `mineru/backend/pipeline/model_json_to_middle_json.py` | 中间 JSON 拼装,印章图片截图 |
+| `mineru/model/ocr/pytorch_paddle.py` | PytorchPaddleOCR 类,seal 模式的参数与 OCR 流程 |
+| `mineru/model/ocr/seal_crop.py` | 印章多边形裁剪(`CropByPolys`)与仿射校正 |
+| `mineru/model/ocr/seal_det_warp.py` | 印章检测框仿射校正器(`AutoRectifier`) |
+| `mineru/model/utils/pytorchocr/utils/resources/models_config.yml` | seal/seal_lite 模型文件配置 |
+| `mineru/model/utils/pytorchocr/utils/resources/arch_config.yaml` | seal 检测模型架构配置 |
+| `mineru/utils/enum_class.py` | BlockType.SEAL / ContentType.SEAL 枚举定义 |
+| `mineru/backend/pipeline/pipeline_middle_json_mkcontent.py` | content_list.json / content_list_v2.json 的 seal 输出格式化 |

+ 16 - 0
ocr_tools/universal_doc_parser/config/bank_statement_glm_vl.yaml

@@ -79,6 +79,22 @@ layout_detection:
     min_text_width_ratio: 0.4         # 最小宽度占比(40%)
     min_text_height_ratio: 0.3        # 最小高度占比(30%)
 
+  # 印章补充检测:使用 PP-DocLayoutV3 补充 docling 无法识别的密封区域
+  seal_supplement:
+    enabled: true                # 启用 seal 补充检测
+    replace_existing: false      # false=增量合并; true=完全替换主结果中已有 seal
+    replace_overlapping_image: true   # seal 与 image_body/image 等高 IoU 时替换为 seal(非丢弃)
+    replace_iou_threshold: 0.7        # 触发替换的最小 IoU
+    duplicate_iou_threshold: 0.3      # 未替换时,与任意框 IoU 超此值视为重复 seal
+    # seal_detector 使用的模型配置,默认复用 paddle_ppdoclayoutv3 的配置
+    model_config:
+      module: "paddle"
+      model_name: "PP-DocLayoutV3"
+      model_dir: "PaddlePaddle/PP-DocLayoutV3_safetensors"
+      device: "cpu"
+      conf: 0.3
+      num_threads: 4
+
   # Debug 可视化(底图为 inference_image,与 Layout 检测输入一致)
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-layout 控制

+ 16 - 0
ocr_tools/universal_doc_parser/config/bank_statement_glm_vl_local.yaml

@@ -82,6 +82,22 @@ layout_detection:
     min_text_width_ratio: 0.4         # 最小宽度占比(40%)
     min_text_height_ratio: 0.3        # 最小高度占比(30%)
 
+  # 印章补充检测:使用 PP-DocLayoutV3 补充 docling 无法识别的密封区域
+  seal_supplement:
+    enabled: true                # 启用 seal 补充检测
+    replace_existing: false      # false=增量合并; true=完全替换主结果中已有 seal
+    replace_overlapping_image: true   # seal 与 image_body/image 等高 IoU 时替换为 seal(非丢弃)
+    replace_iou_threshold: 0.7        # 触发替换的最小 IoU
+    duplicate_iou_threshold: 0.3      # 未替换时,与任意框 IoU 超此值视为重复 seal
+    # seal_detector 使用的模型配置,默认复用 paddle_ppdoclayoutv3 的配置
+    model_config:
+      module: "paddle"
+      model_name: "PP-DocLayoutV3"
+      model_dir: "PaddlePaddle/PP-DocLayoutV3_safetensors"
+      device: "cpu"
+      conf: 0.3
+      num_threads: 4
+
   # Debug 可视化(底图为 inference_image,与 Layout 检测输入一致)
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-layout 控制

+ 24 - 1
ocr_tools/universal_doc_parser/config/bank_statement_yusys_local.yaml

@@ -3,7 +3,7 @@
 # llama-server -hf ggml-org/GLM-OCR-GGUF
 scene_name: "bank_statement_yusys_local"
 
-description: "银行流水V4: PP-DocLayoutV3 layout + PaddleOCR + MinerU UNet(有线表格)+ GLM-OCR VLM(无线表格/seal)"
+description: "银行流水V4: Docling-layout-old layout + PaddleOCR + MinerU UNet(有线表格)+ GLM-OCR VLM(无线表格)+ PP-DocLayoutV3 seal补充检测 + MinerU seal专用OCR"
 
 input:
   supported_formats: [".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
@@ -82,6 +82,22 @@ layout_detection:
     min_text_width_ratio: 0.4         # 最小宽度占比(40%)
     min_text_height_ratio: 0.3        # 最小高度占比(30%)
 
+  # 印章补充检测:使用 PP-DocLayoutV3 补充 docling 无法识别的密封区域
+  seal_supplement:
+    enabled: true                # 启用 seal 补充检测
+    replace_existing: false      # false=增量合并; true=完全替换主结果中已有 seal
+    replace_overlapping_image: true   # seal 与 image_body/image 等高 IoU 时替换为 seal(非丢弃)
+    replace_iou_threshold: 0.7        # 触发替换的最小 IoU
+    duplicate_iou_threshold: 0.3      # 未替换时,与任意框 IoU 超此值视为重复 seal
+    # seal_detector 使用的模型配置,默认复用 paddle_ppdoclayoutv3 的配置
+    model_config:
+      module: "paddle"
+      model_name: "PP-DocLayoutV3"
+      model_dir: "PaddlePaddle/PP-DocLayoutV3_safetensors"
+      device: "cpu"
+      conf: 0.3
+      num_threads: 4
+
   # Debug 可视化(底图为 inference_image,与 Layout 检测输入一致)
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-layout 控制
@@ -244,6 +260,13 @@ vl_recognition:
   table_recognition:
 
 # ============================================================
+# 印章 OCR 识别配置 - 基于 MinerU PytorchPaddleOCR(lang="seal")
+# ============================================================
+seal_recognition:
+  enabled: true                # 启用印章专用 OCR,关闭则回退 VLM 识别
+  module: "mineru"             # 使用 MinerU 印章 OCR 模型
+
+# ============================================================
 # 输出配置
 # ============================================================
 output:

+ 23 - 0
ocr_tools/universal_doc_parser/config/bank_statement_yusys_v4.yaml

@@ -81,6 +81,22 @@ layout_detection:
     min_text_width_ratio: 0.4         # 最小宽度占比(40%)
     min_text_height_ratio: 0.3        # 最小高度占比(30%)
 
+  # 印章补充检测:使用 PP-DocLayoutV3 补充 docling 无法识别的密封区域
+  seal_supplement:
+    enabled: true                # 启用 seal 补充检测
+    replace_existing: false      # false=增量合并; true=完全替换主结果中已有 seal
+    replace_overlapping_image: true   # seal 与 image_body/image 等高 IoU 时替换为 seal(非丢弃)
+    replace_iou_threshold: 0.7        # 触发替换的最小 IoU
+    duplicate_iou_threshold: 0.3      # 未替换时,与任意框 IoU 超此值视为重复 seal
+    # seal_detector 使用的模型配置,默认复用 paddle_ppdoclayoutv3 的配置
+    model_config:
+      module: "paddle"
+      model_name: "PP-DocLayoutV3"
+      model_dir: "PaddlePaddle/PP-DocLayoutV3_safetensors"
+      device: "cpu"
+      conf: 0.3
+      num_threads: 4
+
   # Debug 可视化(底图为 inference_image,与 Layout 检测输入一致)
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-layout 控制
@@ -243,6 +259,13 @@ vl_recognition:
   table_recognition:
 
 # ============================================================
+# 印章 OCR 识别配置 - 基于 MinerU PytorchPaddleOCR(lang="seal")
+# ============================================================
+seal_recognition:
+  enabled: true                # 启用印章专用 OCR,关闭则回退 VLM 识别
+  module: "mineru"             # 使用 MinerU 印章 OCR 模型
+
+# ============================================================
 # 输出配置
 # ============================================================
 output:

+ 41 - 13
ocr_tools/universal_doc_parser/core/element_processors.py

@@ -48,6 +48,7 @@ class ElementProcessors:
         wired_table_recognizer: Optional[Any] = None,
         table_classifier: Optional[Any] = None,
         vl_recognizer_lazy_loader: Optional[Any] = None,  # 🆕 懒加载回调
+        seal_ocr_recognizer: Optional[Any] = None,  # 🆕 印章 OCR 识别器
     ):
         """
         初始化元素处理器
@@ -60,6 +61,7 @@ class ElementProcessors:
             wired_table_recognizer: 有线表格识别器(可选)
             table_classifier: 表格分类器(区分有线/无线表格,可选)
             vl_recognizer_lazy_loader: VL识别器懒加载回调函数(可选)
+            seal_ocr_recognizer: 印章 OCR 识别器(可选,不存在时回退 VLM)
         """
         self.preprocessor = preprocessor
         self.ocr_recognizer = ocr_recognizer
@@ -67,6 +69,7 @@ class ElementProcessors:
         self.table_cell_matcher = table_cell_matcher
         self.wired_table_recognizer = wired_table_recognizer
         self.table_classifier = table_classifier
+        self.seal_ocr_recognizer = seal_ocr_recognizer
         
         # VL 识别器懒加载支持
         self._vl_recognizer_lazy_loader = vl_recognizer_lazy_loader
@@ -729,23 +732,47 @@ class ElementProcessors:
         layout_item: Dict[str, Any]
     ) -> Dict[str, Any]:
         """
-        处理印章(seal)元素 - 使用 VLM 识别
-        
+        处理印章(seal)元素 - 优先使用 SealOCRRecognizer,回退 VLM
+
         Args:
             image: 页面图像
             layout_item: 布局检测项
-            
+
         Returns:
             处理后的元素字典
         """
         bbox = layout_item.get('bbox', [0, 0, 0, 0])
         category = layout_item.get('category', 'seal')
         cropped_region = CoordinateUtils.crop_region(image, bbox)
-        
+
         content = {'text': '', 'confidence': 0.0}
-        
+
+        # 优先使用 SealOCRRecognizer(MinerU 印章专用 OCR)
+        if self.seal_ocr_recognizer is not None:
+            try:
+                seal_result = self.seal_ocr_recognizer.recognize(cropped_region)
+                if seal_result.get('text', '').strip():
+                    content = {
+                        'text': seal_result['text'],
+                        'confidence': seal_result.get('confidence', 0.0),
+                        'texts': seal_result.get('texts', []),
+                        'details': seal_result.get('details', []),
+                        'recognition_method': 'seal_ocr',
+                    }
+                    logger.info(f"🔖 Seal recognized (OCR): {content['text'][:50]}..."
+                                if len(content['text']) > 50
+                                else f"🔖 Seal recognized (OCR): {content['text']}")
+                    return {
+                        'type': category,
+                        'bbox': bbox,
+                        'confidence': layout_item.get('confidence', 0.0),
+                        'content': content
+                    }
+            except Exception as e:
+                logger.warning(f"SealOCRRecognizer failed, falling back to VLM: {e}")
+
+        # 回退:使用 VLM 识别
         try:
-            # 懒加载 VL 识别器
             vl_recognizer = self._ensure_vl_recognizer()
             if vl_recognizer is None:
                 logger.error("❌ VL recognizer not available for seal recognition")
@@ -754,19 +781,20 @@ class ElementProcessors:
                     'bbox': bbox,
                     'content': content
                 }
-            
-            # 使用 recognize_text 方法,传入 element_type='seal'
-            # GLM-OCR 适配器会根据 element_type 使用相应的提示词
+
             seal_result = vl_recognizer.recognize_text(cropped_region, element_type='seal')
             content = {
                 'text': seal_result.get('text', ''),
-                'confidence': seal_result.get('confidence', 0.0)
+                'confidence': seal_result.get('confidence', 0.0),
+                'recognition_method': 'vlm',
             }
-            
-            logger.info(f"🔖 Seal recognized: {content['text'][:50]}..." if len(content['text']) > 50 else f"🔖 Seal recognized: {content['text']}")
+
+            logger.info(f"🔖 Seal recognized (VLM): {content['text'][:50]}..."
+                        if len(content['text']) > 50
+                        else f"🔖 Seal recognized (VLM): {content['text']}")
         except Exception as e:
             logger.warning(f"Seal recognition failed: {e}")
-        
+
         return {
             'type': category,
             'bbox': bbox,

+ 218 - 3
ocr_tools/universal_doc_parser/core/layout_model_router.py

@@ -45,6 +45,9 @@ class SmartLayoutRouter(BaseLayoutDetector):
         self.page_name = None  # 将在 detect 方法中设置
         # 分数差距阈值:当模型间分数差距小于此值时,优先选择 docling
         self.score_diff_threshold = config.get('score_diff_threshold', 0.05)
+        # seal 补充检测配置
+        self.seal_supplement_config = config.get('seal_supplement', {})
+        self.seal_detector = None  # PP-DocLayoutV3 用于 seal 补充检测
         
     def initialize(self):
         """初始化所有模型"""
@@ -87,6 +90,28 @@ class SmartLayoutRouter(BaseLayoutDetector):
         
         if not self.models:
             raise RuntimeError("No layout models available")
+        
+        # 初始化 seal 补充检测器(PP-DocLayoutV3)
+        if self.seal_supplement_config.get('enabled', False):
+            try:
+                seal_model_config = self.seal_supplement_config.get('model_config', {})
+                if not seal_model_config:
+                    # 尝试从 model_configs 中查找 PP-DocLayoutV3
+                    for model_name, model_config in self.model_configs.items():
+                        if model_config.get('model_name') == 'PP-DocLayoutV3':
+                            seal_model_config = model_config
+                            break
+                if seal_model_config:
+                    logger.info(f"🔧 Initializing seal supplement detector: PP-DocLayoutV3")
+                    self.seal_detector = ModelFactory.create_layout_detector(
+                        _merge_child_model_config(seal_model_config)
+                    )
+                    logger.info(f"✅ Seal supplement detector initialized")
+                else:
+                    logger.warning(f"⚠️ Seal supplement enabled but no PP-DocLayoutV3 model config found")
+            except Exception as e:
+                logger.warning(f"⚠️ Failed to initialize seal supplement detector: {e}")
+                self.seal_detector = None
     
     def cleanup(self):
         """清理所有模型资源"""
@@ -96,6 +121,12 @@ class SmartLayoutRouter(BaseLayoutDetector):
             except Exception as e:
                 logger.warning(f"⚠️ Failed to cleanup {model_name}: {e}")
         self.models.clear()
+        if self.seal_detector is not None:
+            try:
+                self.seal_detector.cleanup()
+            except Exception as e:
+                logger.warning(f"⚠️ Failed to cleanup seal detector: {e}")
+            self.seal_detector = None
     
     def set_ocr_recognizer(self, ocr_recognizer):
         """设置OCR识别器(用于ocr_eval策略)"""
@@ -160,14 +191,31 @@ class SmartLayoutRouter(BaseLayoutDetector):
         if page_name is not None:
             self.page_name = page_name
         
+        results = []
         if self.strategy == 'ocr_eval':
-            return self._ocr_eval_detect(image, ocr_spans)
+            results = self._ocr_eval_detect(image, ocr_spans)
         elif self.strategy == 'auto':
-            return self._auto_select_detect(image)
+            results = self._auto_select_detect(image)
         elif self.strategy == 'scene':
-            return self._scene_select_detect(image)
+            results = self._scene_select_detect(image)
         else:
             raise ValueError(f"Unknown strategy: {self.strategy}")
+        
+        # 补充 seal 检测结果(如果启用)
+        seal_supplement_applied = (
+            self.seal_supplement_config.get('enabled', False)
+            and self.seal_detector is not None
+        )
+        if seal_supplement_applied:
+            primary_results = results
+            results = self._supplement_seal_detections(image, results)
+            # 子模型 detect() 已在 supplement 前写出 layout_post(仅主模型、无 seal)
+            if self._is_layout_debug_enabled():
+                self._save_router_layout_debug(image, primary_results, suffix='post_primary')
+            # 覆盖 layout_post,与 pipeline 实际使用的 layout(含 seal 补充)一致
+            self._save_router_layout_debug(image, results, suffix='post')
+
+        return results
 
     def _scene_select_detect(
         self,
@@ -356,6 +404,173 @@ class SmartLayoutRouter(BaseLayoutDetector):
             results = first_model.detect(image)
         
         return results
+
+    def _save_router_layout_debug(
+        self,
+        image: Union[np.ndarray, Image.Image],
+        layout_results: List[Dict[str, Any]],
+        suffix: str,
+    ) -> None:
+        """在 SmartLayoutRouter 层写出 layout debug(含 seal 补充后的最终结果)。"""
+        if not self._is_layout_debug_enabled() or not layout_results:
+            return
+        output_dir, page_name = self._resolve_layout_debug_paths()
+        dbg_opts = self._layout_debug_options()
+        if output_dir and dbg_opts.get('save_post_processed', True):
+            self._visualize_layout_results(
+                image, layout_results, output_dir, page_name, suffix=suffix
+            )
+
+    def _run_seal_detector(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """运行 seal 补充检测器,不写出子模型 layout debug。"""
+        seal_det = self.seal_detector
+        if seal_det is None:
+            return []
+
+        prev_debug_mode = getattr(seal_det, 'debug_mode', None)
+        prev_debug_opts: Optional[Dict[str, Any]] = None
+        if hasattr(seal_det, 'config') and isinstance(seal_det.config, dict):
+            opts = seal_det.config.get('debug_options')
+            if isinstance(opts, dict):
+                prev_debug_opts = opts.copy()
+                opts['enabled'] = False
+        seal_det.debug_mode = False  # type: ignore[attr-defined]
+
+        try:
+            if hasattr(seal_det, '_detect_raw'):
+                raw = seal_det._detect_raw(image)
+                pp_config = (
+                    seal_det.config.get('post_process', {})
+                    if hasattr(seal_det, 'config')
+                    else {}
+                )
+                return seal_det.post_process(raw, image, pp_config)
+            return seal_det.detect(image)
+        finally:
+            seal_det.debug_mode = prev_debug_mode  # type: ignore[attr-defined]
+            if prev_debug_opts is not None and hasattr(seal_det, 'config'):
+                seal_det.config['debug_options'] = prev_debug_opts
+    
+    # 主模型常把印章误标为 image;补充 seal 与高 IoU 重叠时应替换而非丢弃
+    _SEAL_REPLACEABLE_CATEGORIES = frozenset({
+        'image_body', 'image', 'figure', 'abandon', 'discarded',
+    })
+
+    @staticmethod
+    def _bbox_iou(box_a: List[float], box_b: List[float]) -> float:
+        xa = max(box_a[0], box_b[0])
+        ya = max(box_a[1], box_b[1])
+        xb = min(box_a[2], box_b[2])
+        yb = min(box_a[3], box_b[3])
+        inter = max(0, xb - xa) * max(0, yb - ya)
+        area_a = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1])
+        area_b = (box_b[2] - box_b[0]) * (box_b[3] - box_b[1])
+        union = area_a + area_b - inter
+        return inter / union if union > 0 else 0.0
+
+    def _supplement_seal_detections(
+        self,
+        image: Union[np.ndarray, Image.Image],
+        existing_results: List[Dict[str, Any]]
+    ) -> List[Dict[str, Any]]:
+        """
+        使用 PP-DocLayoutV3 补充检测印章区域,将 seal 结果合并到主模型输出
+        
+        策略:
+        1. 运行 PP-DocLayoutV3,仅保留 category == 'seal'
+        2. replace_existing=true:丢弃主结果中已有 seal,全部采用补充模型 seal
+        3. 默认:若 seal 与主结果中 image_body/image 等 IoU >= replace_iou_threshold,
+           将该框**替换**为 seal(解决主模型把章标成 image 导致补充 seal 被去重丢弃)
+        4. 否则 IoU > duplicate_iou_threshold 视为重复跳过;否则追加新 seal
+        
+        Args:
+            image: 输入图像
+            existing_results: 主模型的 layout 检测结果
+            
+        Returns:
+            合并 seal 检测后的结果列表
+        """
+        try:
+            seal_results = self._run_seal_detector(image)
+            seal_only_items = [item for item in seal_results if item.get('category') == 'seal']
+            
+            if not seal_only_items:
+                logger.info("🔖 Seal supplement: no seal detected by PP-DocLayoutV3")
+                return existing_results
+
+            if self.seal_supplement_config.get('replace_existing', False):
+                logger.info("🔖 Seal supplement: replacing existing seal detections with PP-DocLayoutV3 results")
+                result = [item for item in existing_results if item.get('category') != 'seal']
+                result.extend(seal_only_items)
+                return result
+
+            replace_image = self.seal_supplement_config.get('replace_overlapping_image', True)
+            replace_iou_threshold = float(
+                self.seal_supplement_config.get('replace_iou_threshold', 0.7)
+            )
+            duplicate_iou_threshold = float(
+                self.seal_supplement_config.get('duplicate_iou_threshold', 0.3)
+            )
+
+            merged = list(existing_results)
+            replaced_count = 0
+            added_count = 0
+            skipped_duplicate = 0
+
+            for seal_item in seal_only_items:
+                seal_bbox = seal_item.get('bbox', [])
+                if not seal_bbox or len(seal_bbox) < 4:
+                    continue
+                seal_bbox = seal_bbox[:4]
+
+                if replace_image:
+                    best_idx = -1
+                    best_iou = 0.0
+                    for idx, existing in enumerate(merged):
+                        if existing.get('category') not in self._SEAL_REPLACEABLE_CATEGORIES:
+                            continue
+                        existing_bbox = existing.get('bbox', [])
+                        if not existing_bbox or len(existing_bbox) < 4:
+                            continue
+                        overlap = self._bbox_iou(seal_bbox, existing_bbox[:4])
+                        if overlap >= replace_iou_threshold and overlap > best_iou:
+                            best_iou = overlap
+                            best_idx = idx
+                    if best_idx >= 0:
+                        old_cat = merged[best_idx].get('category', '')
+                        new_item = dict(seal_item)
+                        new_item['category'] = 'seal'
+                        merged[best_idx] = new_item
+                        replaced_count += 1
+                        logger.debug(
+                            f"🔖 Seal supplement: replaced {old_cat} with seal "
+                            f"(IoU={best_iou:.3f}, bbox={seal_bbox})"
+                        )
+                        continue
+
+                is_duplicate = False
+                for existing in merged:
+                    existing_bbox = existing.get('bbox', [])
+                    if existing_bbox and len(existing_bbox) >= 4:
+                        if self._bbox_iou(seal_bbox, existing_bbox[:4]) > duplicate_iou_threshold:
+                            is_duplicate = True
+                            break
+                if is_duplicate:
+                    skipped_duplicate += 1
+                else:
+                    merged.append(dict(seal_item))
+                    added_count += 1
+
+            logger.info(
+                f"🔖 Seal supplement: PP-DocLayoutV3 seal={len(seal_only_items)}, "
+                f"replaced={replaced_count}, added={added_count}, "
+                f"skipped_duplicate={skipped_duplicate}"
+            )
+            return merged
+            
+        except Exception as e:
+            logger.warning(f"⚠️ Seal supplement failed: {e}")
+            return existing_results
     
     def _get_ocr_spans(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
         """

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

@@ -115,6 +115,14 @@ class ModelFactory:
             raise ValueError(f"Unknown table classifier module: {module_name}")
     
     @classmethod
+    def create_seal_ocr_recognizer(cls, config: Dict[str, Any]):
+        """创建印章 OCR 识别器(基于 MinerU PytorchPaddleOCR lang=seal)"""
+        from models.adapters import SealOCRRecognizer
+        recognizer = SealOCRRecognizer(config)
+        recognizer.initialize()
+        return recognizer
+    
+    @classmethod
     def cleanup_all(cls):
         """清理所有模型资源"""
         # 在实际应用中,可以维护一个活跃模型列表进行清理

+ 17 - 2
ocr_tools/universal_doc_parser/core/pipeline_manager_v2.py

@@ -81,7 +81,7 @@ class EnhancedDocPipeline:
     TABLE_TEXT_CATEGORIES = ['table_caption', 'table_footnote']
     
     # 图片相关元素
-    IMAGE_BODY_CATEGORIES = ['image', 'image_body', 'figure']
+    IMAGE_BODY_CATEGORIES = ['image', 'image_body', 'figure', 'chart']
     IMAGE_TEXT_CATEGORIES = ['image_caption', 'image_footnote']
     
     # 公式类元素
@@ -198,6 +198,18 @@ class EnhancedDocPipeline:
                 self.layout_detector.set_ocr_recognizer(self.ocr_recognizer)
                 logger.info("✅ OCR recognizer set for smart router")
 
+            # 4b. 印章 OCR 识别器(可选,基于 MinerU PytorchPaddleOCR lang=seal)
+            self.seal_ocr_recognizer = None
+            seal_recognition_config = self.config.get('seal_recognition', {})
+            if seal_recognition_config.get('enabled', False):
+                try:
+                    self.seal_ocr_recognizer = ModelFactory.create_seal_ocr_recognizer(
+                        seal_recognition_config
+                    )
+                    logger.info("✅ Seal OCR recognizer initialized")
+                except Exception as e:
+                    logger.warning(f"⚠️ Seal OCR recognizer init failed, will fallback to VLM: {e}")
+
             # 5. 表格分类器(可选)
             self.table_classifier = None
             table_cls_config = self.config.get('table_classification', {})
@@ -249,6 +261,7 @@ class EnhancedDocPipeline:
             wired_table_recognizer=getattr(self, 'wired_table_recognizer', None),
             table_classifier=getattr(self, 'table_classifier', None),
             vl_recognizer_lazy_loader=self._ensure_vl_recognizer,  # 🎯 传入懒加载回调
+            seal_ocr_recognizer=getattr(self, 'seal_ocr_recognizer', None),  # 🆕 印章 OCR 识别器
         )
     
     # ==================== 主处理流程 ====================
@@ -1084,7 +1097,7 @@ class EnhancedDocPipeline:
                 logger.warning(f"⚠️ Equation processing failed: {e}")
                 processed_elements.append(ElementProcessors.create_error_element(item, str(e)))
         
-        # 🔧 处理 Seal(印章)元素 - 使用 VLM 识别
+        # 处理 Seal(印章)元素 - 优先 SealOCRRecognizer,回退 VLM
         for item in classified_elements['seal']:
             try:
                 element = self.element_processors.process_seal_element(
@@ -1145,6 +1158,8 @@ class EnhancedDocPipeline:
                 self.vl_recognizer.cleanup()
             if hasattr(self, 'ocr_recognizer'):
                 self.ocr_recognizer.cleanup()
+            if hasattr(self, 'seal_ocr_recognizer') and self.seal_ocr_recognizer is not None:
+                self.seal_ocr_recognizer.cleanup()
             logger.info("✅ Pipeline cleanup completed")
         except Exception as e:
             logger.warning(f"⚠️ Cleanup failed: {e}")

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

@@ -40,6 +40,7 @@ try:
         MinerUOCRRecognizer
     )
     from .mineru_wired_table import MinerUWiredTableRecognizer
+    from .seal_ocr_adapter import SealOCRRecognizer
     MINERU_AVAILABLE = True
 except ImportError:
     MINERU_AVAILABLE = False
@@ -78,6 +79,7 @@ if MINERU_AVAILABLE:
         'MinerUVLRecognizer',
         'MinerUOCRRecognizer',
         'MinerUWiredTableRecognizer',
+        'SealOCRRecognizer',
     ])
 
 

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

@@ -620,6 +620,12 @@ class BaseLayoutDetector(BaseAdapter):
                 bbox1, bbox2 = results[i].get('bbox', []), results[j].get('bbox', [])
                 if len(bbox1) < 4 or len(bbox2) < 4:
                     continue
+
+                cat_i = results[i].get('category', '')
+                cat_j = results[j].get('category', '')
+                # 印章常压在表格/文字之上,与大面积区域重叠属正常,保留双方
+                if cat_i == 'seal' or cat_j == 'seal':
+                    continue
                 
                 # 计算重叠指标
                 iou = coordinate_utils.calculate_iou(bbox1, bbox2)

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

@@ -246,6 +246,8 @@ class MinerUVLLayoutDetector(BaseLayoutDetector):
         "ref_text": "ref_text",
         "code": "code",
         "algorithm": "algorithm",
+        "seal": "seal",
+        "chart": "chart",
         "list": "text",  # 列表块按文本处理(pipeline TEXT_CATEGORIES)
         "phonetic": "text",
 

+ 1 - 1
ocr_tools/universal_doc_parser/models/adapters/paddle_layout_detector.py

@@ -38,7 +38,7 @@ class PaddleLayoutDetector(BaseLayoutDetector):
         13: 'header',            # header -> header (TEXT_CATEGORIES)
         14: 'algorithm',         # algorithm -> algorithm (CODE_CATEGORIES)
         15: 'footer',            # footer -> footer (TEXT_CATEGORIES)
-        16: 'abandon'            # seal -> abandon (DISCARD_CATEGORIES)
+        16: 'seal'               # seal -> seal (SEAL_CATEGORIES)
     }
     
     ORIGINAL_CATEGORY_NAMES = {

+ 19 - 18
ocr_tools/universal_doc_parser/models/adapters/pp_doclayout_v3_layout_adapter.py

@@ -61,23 +61,23 @@ class PPDocLayoutV3Detector(BaseLayoutDetector):
 
     CATEGORY_MAP = {
         "abstract": "text",
-        "algorithm": "text",
-        "aside_text": "text",
-        "chart": "image_body",
+        "algorithm": "code",
+        "aside_text": "aside_text",
+        "chart": "chart",
         "content": "text",
         "formula": "interline_equation",
         "doc_title": "title",
         "figure_title": "image_caption",
         "footer": "footer",
         "footnote": "page_footnote",
-        "formula_number": "interline_equation",
+        "formula_number": "interline_equation_number",
         "header": "header",
         "image": "image_body",
-        "number": "text",
+        "number": "page_number",
         "paragraph_title": "title",
-        "reference": "text",
-        "reference_content": "text",
-        "seal": "seal",  # 🔧 修改:保留 seal 作为独立类别,用于 VLM 识别
+        "reference": "ref_text",
+        "reference_content": "ref_text",
+        "seal": "seal",
         "table": "table_body",
         "text": "text",
         "vision_footnote": "page_footnote",
@@ -187,6 +187,15 @@ class PPDocLayoutV3Detector(BaseLayoutDetector):
         self.image_processor = None
         self._model_path = None
 
+    def _numpy_to_pil_rgb(self, image: np.ndarray) -> Image.Image:
+        """将 numpy 图像转为 PIL RGB。
+
+        Pipeline / PyMuPDF 渲染结果为 RGB,勿误用 cv2.COLOR_BGR2RGB(会导致红章等漏检)。
+        """
+        if len(image.shape) == 3 and image.shape[2] == 3:
+            return Image.fromarray(image).convert("RGB")
+        return Image.fromarray(image).convert("RGB")
+
     def _detect_raw(
         self,
         image: Union[np.ndarray, Image.Image],
@@ -200,11 +209,7 @@ class PPDocLayoutV3Detector(BaseLayoutDetector):
         assert self.image_processor is not None
 
         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_image = Image.fromarray(image_rgb).convert("RGB")
+            pil_image = self._numpy_to_pil_rgb(image)
             orig_h, orig_w = image.shape[:2]
         else:
             pil_image = image.convert("RGB")
@@ -279,11 +284,7 @@ class PPDocLayoutV3Detector(BaseLayoutDetector):
         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"))
+                pil_images.append(self._numpy_to_pil_rgb(image))
                 orig_sizes.append((image.shape[1], image.shape[0]))
             else:
                 pil_images.append(image.convert("RGB"))

+ 175 - 0
ocr_tools/universal_doc_parser/models/adapters/seal_ocr_adapter.py

@@ -0,0 +1,175 @@
+"""印章 OCR 识别适配器,封装 MinerU 的 PytorchPaddleOCR(lang="seal")"""
+
+from typing import Dict, Any, List, Union
+import numpy as np
+import cv2
+from PIL import Image
+from loguru import logger
+
+from .base import BaseOCRRecognizer
+
+try:
+    from mineru.backend.pipeline.model_init import AtomModelSingleton
+    from mineru.backend.pipeline.model_list import AtomicModel
+    MINERU_AVAILABLE = True
+except ImportError as e:
+    logger.warning(f"MinerU components not available for seal OCR: {e}")
+    MINERU_AVAILABLE = False
+
+
+class SealOCRRecognizer(BaseOCRRecognizer):
+    """印章 OCR 识别适配器,复用 MinerU 的印章专用 OCR 模型
+
+    使用 PytorchPaddleOCR(lang="seal"),该模型针对印章文本做了专项优化:
+    - 检测模型: seal_PP-OCRv4_det_server_infer.pth
+    - 识别模型: ch_PP-OCRv4_rec_server_infer.pth
+    - 使用 polygon 边界框,低阈值 (db_thresh=0.2, box_thresh=0.6)
+    - 不合并检测框 (enable_merge_det_boxes=False)
+    - drop_score=0 以保留低置信度结果
+    """
+
+    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.seal_model = None
+
+    def initialize(self):
+        """初始化印章 OCR 模型"""
+        try:
+            self.seal_model = self.atom_model_manager.get_atom_model(
+                atom_model_name=AtomicModel.OCR,
+                lang="seal",
+            )
+            logger.info("SealOCRRecognizer initialized with lang=seal")
+        except Exception as e:
+            logger.error(f"Failed to initialize SealOCRRecognizer: {e}")
+            raise
+
+    def cleanup(self):
+        """清理资源"""
+        self.seal_model = None
+
+    def recognize(self, image: Union[np.ndarray, Image.Image]) -> Dict[str, Any]:
+        """识别印章图片中的文字
+
+        与 MinerU batch_analyze.py 中的印章 OCR 逻辑保持一致:
+        1. 将 RGB 图像转为 BGR
+        2. 调用 seal_ocr_model.ocr(bgr_img, det=True, rec=True)
+        3. 提取识别出的文本列表
+
+        Args:
+            image: 印章裁剪图像 (RGB/OpenCV numpy array 或 PIL Image)
+
+        Returns:
+            {
+                'text': str,              # 合并后的文本(用空格连接)
+                'texts': List[str],       # 各文本框识别出的文本列表
+                'confidence': float,      # 平均置信度
+                'details': List[Dict]     # 详细结果 (bbox, text, confidence)
+            }
+        """
+        if self.seal_model is None:
+            raise RuntimeError("Seal OCR model not initialized")
+
+        # 转换为 BGR 格式
+        if isinstance(image, Image.Image):
+            img_rgb = np.array(image)
+        else:
+            img_rgb = image
+
+        if img_rgb.size == 0:
+            return {'text': '', 'texts': [], 'confidence': 0.0, 'details': []}
+
+        img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
+
+        try:
+            seal_ocr_res = self.seal_model.ocr(img_bgr, det=True, rec=True)
+            if not seal_ocr_res or not seal_ocr_res[0]:
+                return {'text': '', 'texts': [], 'confidence': 0.0, 'details': []}
+
+            seal_texts: List[str] = []
+            details: List[Dict[str, Any]] = []
+            confidences: List[float] = []
+
+            for seal_item in seal_ocr_res[0]:
+                if not seal_item or len(seal_item) != 2:
+                    continue
+                poly = seal_item[0]  # 多边形坐标
+                rec_result = seal_item[1]
+                if not rec_result or len(rec_result) < 1:
+                    continue
+                rec_text = rec_result[0]
+                rec_conf = rec_result[1] if len(rec_result) >= 2 else 0.0
+                if rec_text:
+                    seal_texts.append(rec_text)
+                    confidences.append(rec_conf)
+                    details.append({
+                        'poly': poly,
+                        'text': rec_text,
+                        'confidence': rec_conf,
+                    })
+
+            avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0
+            combined_text = " ".join(seal_texts)
+
+            logger.debug(
+                f"Seal OCR: '{combined_text[:50]}...' (avg conf: {avg_confidence:.3f})"
+            )
+
+            return {
+                'text': combined_text,
+                'texts': seal_texts,
+                'confidence': avg_confidence,
+                'details': details,
+            }
+
+        except Exception as e:
+            logger.warning(f"Seal OCR recognition failed: {e}")
+            return {'text': '', 'texts': [], 'confidence': 0.0, 'details': []}
+
+    def recognize_text(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """实现 BaseOCRRecognizer 接口,将 recognize() 结果转为标准 OCR 列表格式"""
+        result = self.recognize(image)
+        formatted: List[Dict[str, Any]] = []
+        for detail in result.get('details', []):
+            poly = detail.get('poly')
+            if not poly:
+                continue
+            formatted.append({
+                'poly': poly,
+                'text': detail.get('text', ''),
+                'confidence': detail.get('confidence', 0.0),
+            })
+        return formatted
+
+    def detect_text_boxes(self, image: Union[np.ndarray, Image.Image]) -> List[Dict[str, Any]]:
+        """只检测印章文本框(不识别文字)"""
+        if self.seal_model is None:
+            raise RuntimeError("Seal OCR model not initialized")
+
+        if isinstance(image, Image.Image):
+            img_rgb = np.array(image)
+        else:
+            img_rgb = image
+
+        if img_rgb.size == 0:
+            return []
+
+        img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
+
+        try:
+            ocr_results = self.seal_model.ocr(img_bgr, det=True, rec=False)
+            formatted: List[Dict[str, Any]] = []
+            if ocr_results and ocr_results[0]:
+                for poly in ocr_results[0]:
+                    if poly and len(poly) >= 4:
+                        formatted.append({
+                            'poly': poly,
+                            'confidence': 1.0,
+                        })
+            return formatted
+        except Exception as e:
+            logger.warning(f"Seal text box detection failed: {e}")
+            return []

+ 22 - 2
ocr_utils/module_debug_viz.py

@@ -42,9 +42,18 @@ LAYOUT_CATEGORY_COLORS_BGR = {
     'image_body': (0, 255, 0),
     'image_caption': (0, 200, 0),
     'image_footnote': (0, 150, 0),
+    'chart': (255, 255, 0),   # 青色(BGR 下 B=255,G=255,R=0)
+    # 注意:OpenCV 为 BGR,(0,255,255) 在屏幕上呈黄色(与 title 相同),勿用于 seal
+    'seal': (0, 140, 255),    # 亮橙,与红 table / 黄 title / 蓝 text 均易区分
     'abandon': (128, 128, 128),
 }
 
+# seal 常与 table 重叠:加粗线宽 + 黑色外描边
+LAYOUT_HIGHLIGHT_CATEGORIES = frozenset({'seal'})
+LAYOUT_HIGHLIGHT_LINE_THICKNESS = 4
+LAYOUT_HIGHLIGHT_OUTLINE_BGR = (0, 0, 0)
+LAYOUT_DEFAULT_LINE_THICKNESS = 2
+
 # 亮蓝(BGR),在白底/浅灰流水上比黄色更易辨认;与 layout 红色框区分
 OCR_BOX_COLOR_BGR = (255, 0, 0)
 OCR_BOX_LINE_THICKNESS = 2
@@ -76,14 +85,25 @@ def draw_layout_boxes_cv2(
             continue
         category = result.get('category', 'unknown')
         color = LAYOUT_CATEGORY_COLORS_BGR.get(category, (128, 128, 128))
+        thickness = (
+            LAYOUT_HIGHLIGHT_LINE_THICKNESS
+            if category in LAYOUT_HIGHLIGHT_CATEGORIES
+            else LAYOUT_DEFAULT_LINE_THICKNESS
+        )
         x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])
-        cv2.rectangle(vis, (x1, y1), (x2, y2), color, 2)
+        if category in LAYOUT_HIGHLIGHT_CATEGORIES:
+            cv2.rectangle(
+                vis, (x1, y1), (x2, y2),
+                LAYOUT_HIGHLIGHT_OUTLINE_BGR,
+                thickness + 2,
+            )
+        cv2.rectangle(vis, (x1, y1), (x2, y2), color, thickness)
         label = category
         confidence = result.get('confidence', result.get('score', 0))
         if confidence:
             label += f":{float(confidence):.2f}"
         font = cv2.FONT_HERSHEY_SIMPLEX
-        font_scale = 0.4
+        font_scale = 0.5 if category in LAYOUT_HIGHLIGHT_CATEGORIES else 0.4
         text_thickness = 1
         (text_width, text_height), baseline = cv2.getTextSize(
             label, font, font_scale, text_thickness

+ 7 - 3
ocr_utils/visualization_utils.py

@@ -60,6 +60,10 @@ class VisualizationUtils:
         # 列表类元素
         'list': (40, 169, 92),              # 青绿
         'index': (60, 180, 100),            # 青绿
+
+        # 图表 / 印章
+        'chart': (0, 200, 200),
+        'seal': (255, 140, 0),              # 亮橙(RGB),debug 与最终 layout 图一致
         
         # 丢弃类元素
         'abandon': (100, 100, 100),         # 深灰
@@ -69,9 +73,9 @@ class VisualizationUtils:
         'error': (255, 0, 0),               # 红色
     }
     
-    # OCR 框颜色
-    OCR_BOX_COLOR = (0, 255, 0)  # 绿色
-    CELL_BOX_COLOR = (255, 165, 0)  # 橙色
+    # OCR 框颜色(与 module_debug_viz.OCR_BOX_COLOR_BGR 一致:亮蓝 BGR→RGB)
+    OCR_BOX_COLOR = (0, 0, 255)
+    CELL_BOX_COLOR = (0, 0, 255)
     DISCARD_COLOR = (128, 128, 128)  # 灰色
     
     @staticmethod

+ 127 - 39
ocr_validator/ocr_validator_layout.py

@@ -7,7 +7,7 @@ OCR验证工具的布局管理模块
 import streamlit as st
 from pathlib import Path
 from PIL import Image
-from typing import Dict, List, Optional
+from typing import Any, Dict, List, Optional
 import plotly.graph_objects as go
 from typing import Tuple
 import re
@@ -29,6 +29,10 @@ if str(ocr_platform_root) not in sys.path:
 # 从 ocr_utils 导入通用工具
 from ocr_utils.html_utils import convert_html_table_to_markdown, parse_html_tables
 from ocr_utils.visualization_utils import VisualizationUtils
+from ocr_utils.module_debug_viz import (
+    OCR_BOX_LINE_THICKNESS,
+    ocr_box_color_rgb,
+)
 
 # BeautifulSoup用于精确HTML表格处理
 from bs4 import BeautifulSoup
@@ -39,6 +43,27 @@ from ocr_validator_file_utils import load_css_styles
 # 为了向后兼容,提供函数别名
 draw_bbox_on_image = VisualizationUtils.draw_bbox_on_image
 
+
+def category_to_plotly_rgba(category: str, alpha: float = 0.85) -> str:
+    """将 VisualizationUtils.COLOR_MAP 中的 RGB 转为 Plotly 线条颜色。"""
+    rgb = VisualizationUtils.COLOR_MAP.get(category)
+    if rgb is None:
+        rgb = (128, 128, 128)
+    r, g, b = rgb
+    return f"rgba({r}, {g}, {b}, {alpha})"
+
+
+def ocr_box_plotly_rgba(alpha: float = 0.85) -> str:
+    """OCR 亮蓝(与 module_debug_viz / *_ocr_spans 一致)。"""
+    r, g, b = ocr_box_color_rgb()
+    return f"rgba({r}, {g}, {b}, {alpha})"
+
+
+# 仅 layout 结构框按类别着色;其余按 OCR 亮蓝实线/虚线
+LAYOUT_STRUCTURE_CATEGORIES = frozenset({
+    'table_body', 'table', 'image_body', 'image', 'figure', 'chart',
+})
+
 # detect_image_orientation_by_opencv 保留在 ocr_validator_file_utils
 from ocr_validator_file_utils import detect_image_orientation_by_opencv
 
@@ -636,6 +661,14 @@ class OCRLayoutManager:
                             match_type = "exact"
                         else:
                             match_type = "no_bbox"
+
+                        if info_list[0].get('category') == 'seal':
+                            conf = info_list[0].get('confidence', 0)
+                            method = info_list[0].get('recognition_method', '')
+                            hint = f"🔖 **印章** | 置信度 {conf:.2f}"
+                            if method:
+                                hint += f" | 识别方式 `{method}`"
+                            st.info(hint)
                     
                     # 🎯 应用高亮
                     if len(selected_text) >= self.config.get('ocr', {}).get('min_text_length', 2):
@@ -772,8 +805,10 @@ class OCRLayoutManager:
 
     # st.markdown('</div>', unsafe_allow_html=True)
 
-    def zoom_image(self, image: Image.Image, current_zoom: float) -> Tuple[Image.Image, List[List[int]], List[List[int]]]:
-        """缩放图像"""
+    def zoom_image(
+        self, image: Image.Image, current_zoom: float
+    ) -> Tuple[Image.Image, List[Dict[str, Any]], List[List[int]]]:
+        """缩放图像;all_boxes 为带 category 的框列表,供按类着色。"""
         # 根据缩放级别调整图片大小
         new_width = int(image.width * current_zoom)
         new_height = int(image.height * current_zoom)
@@ -789,15 +824,19 @@ class OCRLayoutManager:
                     selected_box = [int(coord * current_zoom) for coord in bbox]
                     selected_boxes.append(selected_box)
 
-        # 收集所有框
-        all_boxes = []
+        # 收集所有框(含类别,用于按类着色)
+        all_boxes: List[Dict[str, Any]] = []
         if self.show_all_boxes:
             for text, info_list in self.validator.text_bbox_mapping.items():
                 for info in info_list:
-                    bbox = info['bbox']
+                    bbox = info.get('bbox', [])
                     if len(bbox) >= 4:
                         scaled_bbox = [coord * current_zoom for coord in bbox]
-                        all_boxes.append(scaled_bbox)
+                        all_boxes.append({
+                            'bbox': scaled_bbox,
+                            'category': info.get('category', 'text'),
+                            'has_text': bool(text and str(text).strip()),
+                        })
 
         return resized_image, all_boxes, selected_boxes
 
@@ -837,48 +876,56 @@ class OCRLayoutManager:
         # 🎯 一次性更新所有形状
         fig.update_layout(shapes=fig.layout.shapes + tuple(shapes))
 
-    def _add_bboxes_as_scatter(self, fig: go.Figure, bboxes: List[List[int]], 
-                          image_height: int,
-                          line_color: str = "blue", 
-                          line_width: int = 2,
-                          name: str = "boxes"):
-        """
-        使用 Scatter 绘制边界框(极致性能优化)
-        """
+    def _add_bboxes_as_scatter(
+        self,
+        fig: go.Figure,
+        bboxes: List[List[int]],
+        image_height: int,
+        line_color: str = "blue",
+        line_width: int = 2,
+        name: str = "boxes",
+        *,
+        dashed: bool = False,
+    ):
+        """使用 Scatter 绘制边界框(极致性能优化)。"""
         if not bboxes or len(bboxes) == 0:
             return
-        
-        # 🎯 收集所有矩形的边框线坐标
+
         x_coords = []
         y_coords = []
-        
+
         for bbox in bboxes:
             if len(bbox) < 4:
                 continue
-            
+
             x1, y1, x2, y2 = bbox[:4]
-            
-            # 转换坐标
             plot_y1 = image_height - y2
             plot_y2 = image_height - y1
-            
-            # 绘制矩形:5个点(闭合)
-            x_coords.extend([x1, x2, x2, x1, x1, None])  # None用于断开线段
+            x_coords.extend([x1, x2, x2, x1, x1, None])
             y_coords.extend([plot_y1, plot_y1, plot_y2, plot_y2, plot_y1, None])
-        
-        # 🎯 一次性添加所有边框
+
+        line_style = dict(
+            color=line_color,
+            width=line_width,
+            dash='dash' if dashed else 'solid',
+        )
         fig.add_trace(go.Scatter(
             x=x_coords,
             y=y_coords,
             mode='lines',
-            line=dict(color=line_color, width=line_width),
+            line=line_style,
             name=name,
             showlegend=False,
-            hoverinfo='skip'
+            hoverinfo='skip',
         ))
 
-    def create_resized_interactive_plot(self, image: Image.Image, selected_boxes: List[List[int]], 
-                                       zoom_level: float, all_boxes: List[List[int]]) -> go.Figure:
+    def create_resized_interactive_plot(
+        self,
+        image: Image.Image,
+        selected_boxes: List[List[int]],
+        zoom_level: float,
+        all_boxes: List[Dict[str, Any]],
+    ) -> go.Figure:
         """创建可调整大小的交互式图片 - 修复容器溢出问题"""
         fig = go.Figure()
         
@@ -897,16 +944,57 @@ class OCRLayoutManager:
             )
         )
         
-        # 显示所有bbox(淡蓝色)
+        # 显示所有框:layout 结构按 COLOR_MAP;OCR 文字亮蓝实线/无文字虚线
         if all_boxes:
-            self._add_bboxes_as_scatter(
-                fig=fig,
-                bboxes=all_boxes,
-                image_height=image.height,
-                line_color="rgba(0, 100, 200, 0.8)",
-                line_width=2,
-                name="all_boxes"
-            )
+            layout_by_category: Dict[str, List[List[float]]] = {}
+            ocr_solid: List[List[float]] = []
+            ocr_dashed: List[List[float]] = []
+            ocr_color = ocr_box_plotly_rgba()
+            ocr_width = OCR_BOX_LINE_THICKNESS
+
+            for box_item in all_boxes:
+                cat = box_item.get('category', 'text')
+                bbox = box_item.get('bbox', [])
+                if len(bbox) < 4:
+                    continue
+                if cat in LAYOUT_STRUCTURE_CATEGORIES:
+                    layout_by_category.setdefault(cat, []).append(bbox)
+                else:
+                    if box_item.get('has_text', True):
+                        ocr_solid.append(bbox)
+                    else:
+                        ocr_dashed.append(bbox)
+
+            for cat, bboxes in layout_by_category.items():
+                line_width = 4 if cat == 'seal' else 2
+                self._add_bboxes_as_scatter(
+                    fig=fig,
+                    bboxes=bboxes,
+                    image_height=image.height,
+                    line_color=category_to_plotly_rgba(cat),
+                    line_width=line_width,
+                    name=f"all_{cat}",
+                )
+            if ocr_solid:
+                self._add_bboxes_as_scatter(
+                    fig=fig,
+                    bboxes=ocr_solid,
+                    image_height=image.height,
+                    line_color=ocr_color,
+                    line_width=ocr_width,
+                    name="ocr_text",
+                    dashed=False,
+                )
+            if ocr_dashed:
+                self._add_bboxes_as_scatter(
+                    fig=fig,
+                    bboxes=ocr_dashed,
+                    image_height=image.height,
+                    line_color=ocr_color,
+                    line_width=ocr_width,
+                    name="ocr_detect_only",
+                    dashed=True,
+                )
 
         # 高亮显示选中的bbox(红色)
         if selected_boxes:

+ 39 - 7
ocr_validator/ocr_validator_utils.py

@@ -249,6 +249,31 @@ def parse_mineru_data(data: List, config: Dict, tool_name="mineru") -> List[Dict
                         'confidence': confidence,
                         'source_tool': tool_name
                     })
+        elif category == 'seal':
+            content = item.get('content', {})
+            if isinstance(content, dict):
+                if not text:
+                    text = content.get('text', '')
+                if not confidence or confidence == config['ocr']['default_confidence']:
+                    confidence = content.get('confidence', confidence)
+                recognition_method = content.get('recognition_method', '')
+                seal_texts = content.get('texts', [])
+            else:
+                recognition_method = item.get('recognition_method', '')
+                seal_texts = item.get('texts', [])
+            if text and bbox and len(bbox) >= 4:
+                seal_entry = {
+                    'text': str(text).strip(),
+                    'bbox': bbox[:4],
+                    'category': 'seal',
+                    'confidence': confidence,
+                    'source_tool': tool_name,
+                }
+                if recognition_method:
+                    seal_entry['recognition_method'] = recognition_method
+                if seal_texts:
+                    seal_entry['seal_texts'] = seal_texts
+                parsed_data.append(seal_entry)
         else:
             # 其他类型,按文本处理,  header, table_cell, ...
             if text and bbox and len(bbox) >= 4:
@@ -272,14 +297,16 @@ def detect_mineru_structure(data: Union[List, Dict]) -> bool:
     if not isinstance(first_item, dict):
         return False
     
-    # MinerU特征:包含type字段,且值为text/table/image之一
+    # MinerU / pipeline page json:type + bbox(text 可为空,如部分 text 块)
     has_type = 'type' in first_item
     has_bbox = 'bbox' in first_item
-    has_text = 'text' in first_item
     
-    if has_type and has_bbox and has_text:
+    if has_type and has_bbox:
         item_type = first_item.get('type', '')
-        return item_type in ['text', 'table', 'image']
+        return item_type in [
+            'text', 'table', 'table_body', 'image', 'title',
+            'seal', 'list', 'header', 'footer',
+        ]
     
     return False
 
@@ -541,15 +568,20 @@ def process_ocr_data(ocr_data: List, config: Dict) -> Dict[str, List]:
             if isinstance(bbox, list) and len(bbox) == 4:
                 if text not in text_bbox_mapping:
                     text_bbox_mapping[text] = []
-                text_bbox_mapping[text].append({
+                mapping_entry = {
                     'matched_text': item.get('matched_text', ''),
                     'bbox': bbox,
                     'category': item.get('category', 'Text'),
                     'index': i,
                     'confidence': item.get('confidence', config['ocr']['default_confidence']),
                     'source_tool': item.get('source_tool', 'unknown'),
-                    'rotation_angle': item.get('rotation_angle', 0.0)  # 添加旋转角度信息
-                })
+                    'rotation_angle': item.get('rotation_angle', 0.0),
+                }
+                if item.get('recognition_method'):
+                    mapping_entry['recognition_method'] = item['recognition_method']
+                if item.get('seal_texts'):
+                    mapping_entry['seal_texts'] = item['seal_texts']
+                text_bbox_mapping[text].append(mapping_entry)
     
     return text_bbox_mapping