7 次代碼提交 457131c794 ... 712d004e85

作者 SHA1 備註 提交日期
  zhch158_admin 712d004e85 refactor(pdf_coordinate_transform): remove outdated bbox transformation function 4 天之前
  zhch158_admin d560cc01e0 fix(paddle_vl_adapter): update model name retrieval logic to ensure default value is used 4 天之前
  zhch158_admin 11dcec8769 feat(paddle_table_classifier): integrate debugging tools for enhanced table line detection visualization 4 天之前
  zhch158_admin ac8cca5d22 feat(pipeline): add debug options for table classification to enhance visualization control 4 天之前
  zhch158_admin 162d4eb7c2 fix(config): explicit set VLM model name in yusys_v2 config 4 天之前
  zhch158_admin a880f363ac feat(config): add table classification debug options and update VLM server port 4 天之前
  zhch158_admin fd22e3d8da 表格分类模型-线检测 4 天之前

+ 30 - 0
docs/ocr_tools/universal_doc_parser/表格分类模型-线检测.md

@@ -0,0 +1,30 @@
+简单用形态学做线检测(也就是你在 `paddle_table_classifier` 里的做法)在你的这个例子(数字原生 PDF,线条清晰)中效果确实很好,但在通用场景下,之所以还需要 UNet,是因为**鲁棒性(Robustness)**和**语义理解能力**。
+
+以下是详细对比:
+
+### 1. 核心差异
+
+| 特性 | 传统线检测 (OpenCV 形态学) | 深度学习模型 (UNet) |
+| :--- | :--- | :--- |
+| **原理** | **基于像素**:只看哪里有连续的黑像素。 | **基于上下文**:理解"表格长什么样"。 |
+| **断线处理** | **失败**:扫描件如果线条有断点或打印不清晰,它就认为是断开的,无法构成封闭单元格。 | **成功**:能够"脑补"连接断线,因为它学过表格的结构,知道这里应该有一条线。 |
+| **抗干扰** | **差**:容易把文字下划线、删除线、或者背景噪点误判为表格线。 | **强**:经过训练,能学会忽略文字内容的干扰(如下划线),只提取表格骨架。 |
+| **复杂场景** | **困难**:面对双线边框、虚线、或者线宽不一致的情况很难调参。 | **灵活**:预测的是逻辑上的网格,对线条样式不敏感。 |
+| **泛化性** | **低**:换个分辨率或线宽可能就需要重新调参(kernel size)。 | **高**:模型训练后能适应各种分辨率和来源的文档。 |
+
+### 2. 为什么分类器 ([Classifier](cci:2://file:///Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/universal_doc_parser/models/adapters/paddle_table_classifier.py:34:0-298:13)) 用简单方法?
+[PaddleTableClassifier](cci:2://file:///Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/universal_doc_parser/models/adapters/paddle_table_classifier.py:34:0-298:13) 的任务只是**快速筛选**:
+- 它只需要回答“这像不像一个有线表格?”
+- 它只需要一个大概的信号(比如“检测到了大量横竖线”)。
+- **速度至上**:OpenCV 运算极快,不需要加载沉重的模型权重。
+
+### 3. 为什么识别器 ([Recognizer](cci:2://file:///Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/universal_doc_parser/models/adapters/base.py:72:0-83:12)) 用 UNet?
+[MinerUWiredTableRecognizer](cci:2://file:///Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/universal_doc_parser/models/adapters/mineru_wired_table.py:29:0-482:64) 的任务是**精准重构**:
+- 它必须保证每一个单元格都是**闭合**的。
+- 它必须保证重构出的网格**严丝合缝**,不能切断文字。
+- 它需要处理歪斜、扭曲、模糊等各种现实世界的“脏”数据。
+
+### 总结
+对于你当前这种**高质量、数字原生**的银行流水单,简单的 OpenCV 线检测确实足够准,甚至可能比 UNet 更快更干净。
+
+但在工业级流水线中,为了兼容扫描件、拍照件、模糊传真等各种不可预测的输入,UNet 是目前的标准选择,因为它更“聪明”,能看懂图像的语义而不仅仅是像素。

+ 11 - 1
ocr_tools/universal_doc_parser/config/bank_statement_wired_unet.yaml

@@ -47,6 +47,15 @@ table_classification:
   confidence_threshold: 0.5   # 分类置信度阈值
   batch_size: 16              # 批处理大小
 
+  # Debug 可视化配置(与 MinerUWiredTableRecognizer.DebugOptions 对齐)
+  # 默认关闭。开启后将保存:表格线
+  debug_options:
+    enabled: true               # 是否开启调试可视化输出
+    output_dir: null             # 调试输出目录;null不输出
+    save_table_lines: true       # 保存表格线可视化(unet横线/竖线叠加)
+    image_format: "png"          # 可视化图片格式:png/jpg
+    prefix: ""                  # 保存文件名前缀(如设置为页码/表格序号)
+
 # 有线表格识别专用配置
 table_recognition_wired:
   use_wired_unet: true
@@ -77,10 +86,11 @@ table_recognition_wired:
 vl_recognition:
   # 可选: "mineru" (MinerU VLM) 或 "paddle" (PaddleOCR-VL)
   module: "paddle"
+  model_name: "PaddleOCR-VL-0.9B"
   
   # 后端配置
   backend: "http-client"  # 可选: "http-client", "vllm-engine", "transformers"
-  server_url: "http://10.192.72.11:20016"  # PaddleOCR-VL 服务地址
+  server_url: "http://10.192.72.11:20116"  # PaddleOCR-VL 服务地址
   
   # 图片尺寸限制(避免序列长度超限)
   max_image_size: 4096

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

@@ -50,6 +50,7 @@ layout:
 vl_recognition:
   # 可选: "mineru" (MinerU VLM) 或 "paddle" (PaddleOCR-VL)
   module: "paddle"
+  model_name: "PaddleOCR-VL-0.9B"
   
   # 后端配置
   backend: "http-client"  # 可选: "http-client", "vllm-engine", "transformers"

+ 12 - 1
ocr_tools/universal_doc_parser/core/pipeline_manager_v2.py

@@ -823,7 +823,18 @@ class EnhancedDocPipeline:
                 if use_table_classification and self.table_classifier:
                     bbox = item.get('bbox', [])
                     table_img = CoordinateUtils.crop_region(detection_image, bbox)
-                    cls_result = self.table_classifier.classify(table_img)
+                    
+                    # 构造调试选项
+                    cls_debug_opts = {}
+                    if output_dir:
+                        cls_debug_opts['output_dir'] = output_dir
+                    if basename:
+                        cls_debug_opts['prefix'] = f"{basename}_{idx}"
+                    
+                    cls_result = self.table_classifier.classify(
+                        table_img, 
+                        debug_options=cls_debug_opts
+                    )
                     table_type = cls_result.get('table_type', 'wireless')
                     confidence = cls_result.get('confidence', 0.0)
                     logger.info(f"📊 Table {idx} classified as '{table_type}' (conf: {confidence:.3f})")

+ 35 - 4
ocr_tools/universal_doc_parser/models/adapters/paddle_table_classifier.py

@@ -6,7 +6,7 @@ PaddleOCR表格分类适配器
 适配 MinerU 的 PaddleTableClsModel,用于区分有线表格和无线表格。
 """
 import sys
-from typing import Dict, Any, Union
+from typing import Dict, Any, Union, Optional
 from pathlib import Path
 import numpy as np
 from PIL import Image
@@ -14,6 +14,8 @@ import cv2
 from loguru import logger
 
 from .base import BaseAdapter
+from .wired_table.visualization import WiredTableVisualizer
+from .wired_table.debug_utils import WiredTableDebugUtils, WiredTableDebugOptions
 
 # # 确保 MinerU 库可导入
 # mineru_root = Path(__file__).parents[5] / "MinerU"
@@ -53,6 +55,11 @@ class PaddleTableClassifier(BaseAdapter):
         self.confidence_threshold = config.get('confidence_threshold', 0.5)
         self.batch_size = config.get('batch_size', 16)
         
+        # 初始化调试工具
+        self.debug_utils = WiredTableDebugUtils()
+        self.debug_options = self.debug_utils.merge_debug_options(self.config)
+        self.visualizer = WiredTableVisualizer()
+        
     def initialize(self):
         """初始化模型"""
         if not MINERU_TABLE_CLS_AVAILABLE:
@@ -75,7 +82,8 @@ class PaddleTableClassifier(BaseAdapter):
     def classify(
         self, 
         image: Union[np.ndarray, Image.Image],
-        use_line_detection: bool = True
+        use_line_detection: bool = True,
+        debug_options: Optional[Dict[str, Any]] = None
     ) -> Dict[str, Any]:
         """
         分类单个表格图像
@@ -95,6 +103,12 @@ class PaddleTableClassifier(BaseAdapter):
         """
         if self.model is None:
             raise RuntimeError("Model not initialized. Call initialize() first.")
+            
+        # 合并调试选项
+        merged_debug_opts = self.debug_utils.merge_debug_options(
+            self.config, 
+            override=debug_options
+        )
         
         try:
             # Step 1: 调用 MinerU 的预测接口
@@ -117,7 +131,7 @@ class PaddleTableClassifier(BaseAdapter):
             
             # Step 2: 使用线条检测辅助判断(覆盖低置信度结果)
             if use_line_detection:
-                line_info = self._detect_table_lines(image)
+                line_info = self._detect_table_lines(image, merged_debug_opts)
                 result['line_detection'] = line_info
                 
                 # 🔑 关键逻辑:只有横线没有竖线 → 强制无线表格
@@ -158,7 +172,11 @@ class PaddleTableClassifier(BaseAdapter):
                 'error': str(e)
             }
     
-    def _detect_table_lines(self, image: Union[np.ndarray, Image.Image]) -> Dict[str, int]:
+    def _detect_table_lines(
+        self, 
+        image: Union[np.ndarray, Image.Image],
+        debug_options: Optional[WiredTableDebugOptions] = None
+    ) -> Dict[str, int]:
         """
         检测表格图像中的横线和竖线数量
         
@@ -195,6 +213,19 @@ class PaddleTableClassifier(BaseAdapter):
         vertical_mask = cv2.morphologyEx(binary, cv2.MORPH_OPEN, vertical_kernel)
         vertical_lines = cv2.findContours(vertical_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
         
+        # 调试可视画
+        # 使用传入的 debug_options (包含了可能的 override)
+        opts = debug_options or self.debug_options
+        if self.debug_utils.debug_is_on("save_table_lines", opts):
+            out_path = self.debug_utils.debug_path("paddle_table_lines", opts)
+            if out_path:
+                self.visualizer.visualize_table_lines(
+                    img_array if len(img_array.shape) == 3 else cv2.cvtColor(img_array, cv2.COLOR_GRAY2BGR),
+                    horizontal_mask,  # morphologyEx result is already a binary mask
+                    vertical_mask,
+                    output_path=out_path
+                )
+
         return {
             'horizontal_lines': len(horizontal_lines),
             'vertical_lines': len(vertical_lines)

+ 4 - 2
ocr_tools/universal_doc_parser/models/adapters/paddle_vl_adapter.py

@@ -29,7 +29,8 @@ class PaddleVLRecognizer(MinerUVLRecognizer):
     
     def __init__(self, config: Dict[str, Any]):
         # 🔧 强制设置 PaddleOCR-VL 模型名称
-        config['model_name'] = 'PaddleOCR-VL-0.9B'
+        # config['model_name'] = 'PaddleOCR-VL-0.9B'
+        config['model_name'] = config.get('model_name', 'PaddleOCR-VL-0.9B')
         
         # 🔧 确保使用正确的后端配置
         if config.get('backend') not in ['http-client']:
@@ -50,6 +51,7 @@ class PaddleVLRecognizer(MinerUVLRecognizer):
             backend = self.config.get('backend', 'http-client')
             server_url = self.config.get('server_url')
             model_params = self.config.get('model_params', {})
+            model_name = self.config.get('model_name', 'PaddleOCR-VL-0.9B')
             
             # 🔧 提取 MinerUClient 所需的参数
             # 从 model_params 中获取,如果没有则使用默认值
@@ -74,7 +76,7 @@ class PaddleVLRecognizer(MinerUVLRecognizer):
                 # HTTP客户端模式
                 self.vlm_model = MinerUClient(
                     backend=backend,
-                    model_name=self.config['model_name'],
+                    model_name=model_name,
                     server_url=server_url,
                     prompts=prompts,
                     max_concurrency=max_concurrency,

+ 0 - 74
ocr_utils/pdf_coordinate_transform.py

@@ -242,77 +242,3 @@ def transform_bbox_for_rotation_pypdfium2(
         max(new_x1, new_x2),
         max(new_y1, new_y2)
     ]
-
-def transform_bbox_for_rotation_pypdfium2_old(
-    bbox: List[float],
-    rotation: int,
-    pdf_width: float,
-    pdf_height: float,
-    scale: float
-) -> List[float]:
-    """
-    pypdfium2引擎的坐标转换(坐标值交换)
-    
-    pypdfium2的pdftext返回的坐标已经过部分处理(已旋转到正确位置),
-    但bbox的(x1,y1)和(x2,y2)的大小关系可能出错,只需交换坐标值即可。
-    
-    Args:
-        bbox: 已旋转的坐标 [x1, y1, x2, y2](但顺序可能错误)
-        rotation: PDF页面rotation (0/90/180/270)
-        pdf_width: PDF页面宽度(原始方向,本函数中未使用)
-        pdf_height: PDF页面高度(原始方向,本函数中未使用)
-        scale: 渲染缩放比例
-        
-    Returns:
-        图像坐标 [x1, y1, x2, y2],已确保 x1<x2, y1<y2
-        
-    变换规则:
-        rotation=0:   (x1,y1,x2,y2) → (x1,y1,x2,y2)     # 不变
-        rotation=90:  (x1,y1,x2,y2) → (x1,y2,x2,y1)     # y坐标交换
-        rotation=180: (x1,y1,x2,y2) → (x1,y2,x2,y1)     # y坐标交换
-        rotation=270: (x1,y1,x2,y2) → (x2,y1,x1,y2)     # x坐标交换
-    """
-    x1, y1, x2, y2 = bbox
-    
-    if rotation == 0:
-        # rotation=0时,直接缩放
-        new_x1 = x1 * scale
-        new_y1 = y1 * scale
-        new_x2 = x2 * scale
-        new_y2 = y2 * scale
-        
-    elif rotation == 90:
-        # 顺时针转90度:交换y坐标
-        new_x1 = x1 * scale
-        new_y1 = y2 * scale
-        new_x2 = x2 * scale
-        new_y2 = y1 * scale
-
-    elif rotation == 180:
-        # 旋转180度:交换y坐标
-        new_x1 = x1 * scale
-        new_y1 = y2 * scale
-        new_x2 = x2 * scale
-        new_y2 = y1 * scale
-        
-    elif rotation == 270:
-        # 顺时针转270度:交换x坐标
-        new_x1 = x2 * scale
-        new_y1 = y1 * scale
-        new_x2 = x1 * scale
-        new_y2 = y2 * scale
-        
-    else:
-        logger.warning(f"Unknown rotation: {rotation}, using default transformation")
-        new_x1 = x1 * scale
-        new_y1 = y1 * scale
-        new_x2 = x2 * scale
-        new_y2 = y2 = y2 * scale
-
-    return [
-        min(new_x1, new_x2),
-        min(new_y1, new_y2),
-        max(new_x1, new_x2),
-        max(new_y1, new_y2)
-    ]
-