5 Commitit c11f2ea045 ... 0b7809226c

Tekijä SHA1 Viesti Päivämäärä
  zhch158_admin 0b7809226c feat: 添加PaddleOCR表格分类器适配器,支持有线/无线表格分类 6 päivää sitten
  zhch158_admin 630cf15a2d feat: 添加表格分类器支持,优化表格识别路径选择 6 päivää sitten
  zhch158_admin 57bcb4628d feat: 添加创建表格分类器的方法以区分有线/无线表格 6 päivää sitten
  zhch158_admin 768858cbff feat: 添加可选的表格分类器参数以支持有线/无线表格的区分 6 päivää sitten
  zhch158_admin a4b4be0968 feat: 添加布局后处理和表格分类配置,优化表格识别逻辑 6 päivää sitten

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

@@ -20,6 +20,16 @@ layout_detection:
   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%)
+
 ocr_recognition:
   module: "mineru"
   language: "ch"
@@ -29,6 +39,14 @@ ocr_recognition:
   batch_size: 8
   device: "cpu"
 
+# 表格分类配置(自动区分有线/无线表格)
+# 启用后将自动调用分类模型,根据结果选择合适的表格识别器
+table_classification:
+  enabled: true               # 是否启用自动表格分类(默认关闭,使用手动配置)
+  module: "paddle"            # 分类模型:paddle(MinerU PaddleTableClsModel)
+  confidence_threshold: 0.5   # 分类置信度阈值
+  batch_size: 16              # 批处理大小
+
 # 有线表格识别专用配置
 table_recognition_wired:
   use_wired_unet: true
@@ -55,6 +73,30 @@ table_recognition_wired:
     image_format: "png"          # 可视化图片格式:png/jpg
     prefix: ""                  # 保存文件名前缀(如设置为页码/表格序号)
 
+# VLM 表格识别配置(当分类为 'wireless' 时使用)
+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  # 返回单元格坐标
+
 output:
   create_subdir: false
   save_pdf_images: true

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

@@ -44,6 +44,7 @@ class ElementProcessors:
         vl_recognizer: Any,
         table_cell_matcher: Optional[Any] = None,
         wired_table_recognizer: Optional[Any] = None,
+        table_classifier: Optional[Any] = None,
     ):
         """
         初始化元素处理器
@@ -54,12 +55,14 @@ class ElementProcessors:
             vl_recognizer: VL识别器(表格、公式)
             table_cell_matcher: 表格单元格匹配器
             wired_table_recognizer: 有线表格识别器(可选)
+            table_classifier: 表格分类器(区分有线/无线表格,可选)
         """
         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
+        self.table_classifier = table_classifier
     
     def _convert_ocr_details_to_absolute(
         self,

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

@@ -89,6 +89,19 @@ class ModelFactory:
         return recognizer
     
     @classmethod
+    def create_table_classifier(cls, config: Dict[str, Any]) -> Optional[Any]:
+        """创建表格分类器(区分有线/无线表格)"""
+        module_name = config.get('module', 'paddle')
+        
+        if module_name == 'paddle':
+            from models.adapters.paddle_table_classifier import PaddleTableClassifier
+            classifier = PaddleTableClassifier(config)
+            classifier.initialize()
+            return classifier
+        else:
+            raise ValueError(f"Unknown table classifier module: {module_name}")
+    
+    @classmethod
     def cleanup_all(cls):
         """清理所有模型资源"""
         # 在实际应用中,可以维护一个活跃模型列表进行清理

+ 41 - 7
ocr_tools/universal_doc_parser/core/pipeline_manager_v2.py

@@ -139,7 +139,17 @@ class EnhancedDocPipeline:
                 self.config['ocr_recognition']
             )
 
-            # 5. 有线表格识别器(可选)
+            # 5. 表格分类器(可选)
+            self.table_classifier = None
+            table_cls_config = self.config.get('table_classification', {})
+            if table_cls_config.get('enabled', False):
+                try:
+                    self.table_classifier = ModelFactory.create_table_classifier(table_cls_config)
+                    logger.info("✅ Table classifier initialized")
+                except Exception as e:
+                    logger.warning(f"⚠️ Table classifier init failed: {e}")
+
+            # 6. 有线表格识别器(可选)
             self.table_config = self.config.get('table_recognition_wired', {})
             self.wired_table_recognizer = None
             if self.table_config.get('use_wired_unet', False):
@@ -178,6 +188,7 @@ class EnhancedDocPipeline:
             vl_recognizer=self.vl_recognizer,
             table_cell_matcher=table_cell_matcher,
             wired_table_recognizer=getattr(self, 'wired_table_recognizer', None),
+            table_classifier=getattr(self, 'table_classifier', None),
         )
     
     # ==================== 主处理流程 ====================
@@ -784,12 +795,32 @@ class EnhancedDocPipeline:
             try:
                 spans = get_matched_spans_for_item(item)
                 
-                # 🔑 关键:根据配置选择表格识别路径
+                # 🔑 关键:智能选择表格识别路径(支持自动分类)
                 use_wired_unet = self.table_config.get('use_wired_unet', False)
+                use_table_classification = self.config.get('table_classification', {}).get('enabled', False)
+                
+                # Step 1: 如果启用了自动分类,先对表格进行分类
+                table_type = None
+                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)
+                    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})")
+                
+                # Step 2: 根据分类结果或配置选择识别器
+                should_use_wired = False
+                if use_table_classification:
+                    # 自动分类模式:根据分类结果决定
+                    should_use_wired = (table_type == 'wired' and self.wired_table_recognizer)
+                # else:
+                #     # 手动配置模式:根据配置决定
+                #     should_use_wired = (use_wired_unet and self.wired_table_recognizer)
                 
-                if use_wired_unet and self.wired_table_recognizer:
+                if should_use_wired:
                     # 有线表格路径:UNet 识别
-                    logger.info(f"🔷 Using wired UNet table recognition (configured)")
+                    logger.info(f"🔷 Table {idx}: Using wired UNet recognition")
                     element = self.element_processors.process_table_element_wired(
                         detection_image, item, scale, pre_matched_spans=spans, pdf_type=pdf_type,
                         output_dir=output_dir, basename=f"{basename}_{idx}"
@@ -797,10 +828,13 @@ class EnhancedDocPipeline:
                     
                     # 如果有线识别失败(返回空 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}")
+                        logger.warning(f"⚠️ Wired recognition failed for table {idx}, fallback to VLM")
+                        element = self.element_processors.process_table_element_vlm(
+                            detection_image, item, scale, pre_matched_spans=spans
+                        )
                 else:
                     # VLM 无线表格路径(默认)
-                    logger.info(f"🔷 Using VLM table recognition (configured)")
+                    logger.info(f"🔷 Table {idx}: Using VLM recognition")
                     element = self.element_processors.process_table_element_vlm(
                         detection_image, item, scale, pre_matched_spans=spans
                     )
@@ -867,7 +901,7 @@ class EnhancedDocPipeline:
                 self.preprocessor.cleanup()
             if hasattr(self, 'layout_detector'):
                 self.layout_detector.cleanup()
-            if hasattr(self, 'vl_recognizer'):
+            if hasattr(self, 'vl_recognizer') and self.vl_recognizer is not None:
                 self.vl_recognizer.cleanup()
             if hasattr(self, 'ocr_recognizer'):
                 self.ocr_recognizer.cleanup()

+ 192 - 0
ocr_tools/universal_doc_parser/models/adapters/paddle_table_classifier.py

@@ -0,0 +1,192 @@
+# 文件路径: /Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/universal_doc_parser/models/adapters/paddle_table_classifier.py
+
+"""
+PaddleOCR表格分类适配器
+
+适配 MinerU 的 PaddleTableClsModel,用于区分有线表格和无线表格。
+"""
+import sys
+from typing import Dict, Any, Union
+from pathlib import Path
+import numpy as np
+from PIL import Image
+from loguru import logger
+
+from .base import BaseAdapter
+
+# # 确保 MinerU 库可导入
+# mineru_root = Path(__file__).parents[5] / "MinerU"
+# if str(mineru_root) not in sys.path:
+#     sys.path.insert(0, str(mineru_root))
+
+try:
+    from mineru.model.table.cls.paddle_table_cls import PaddleTableClsModel
+    from mineru.backend.pipeline.model_list import AtomicModel
+    MINERU_TABLE_CLS_AVAILABLE = True
+except ImportError as e:
+    logger.warning(f"MinerU table classifier not available: {e}")
+    MINERU_TABLE_CLS_AVAILABLE = False
+    PaddleTableClsModel = None
+    AtomicModel = None
+
+class PaddleTableClassifier(BaseAdapter):
+    """
+    PaddleOCR表格分类器适配器
+    
+    用于将表格图像分类为:
+    - wired_table: 有线表格(带边框)
+    - wireless_table: 无线表格(无边框)
+    """
+    
+    def __init__(self, config: Dict[str, Any]):
+        """
+        初始化表格分类器
+        
+        Args:
+            config: 配置字典,支持以下参数:
+                - confidence_threshold: 置信度阈值(默认 0.5)
+                - batch_size: 批处理大小(默认 16)
+        """
+        super().__init__(config)
+        self.model = None
+        self.confidence_threshold = config.get('confidence_threshold', 0.5)
+        self.batch_size = config.get('batch_size', 16)
+        
+    def initialize(self):
+        """初始化模型"""
+        if not MINERU_TABLE_CLS_AVAILABLE:
+            raise RuntimeError("MinerU table classifier not available")
+        
+        try:
+            self.model = PaddleTableClsModel()
+            logger.info("✅ PaddleTableClsModel initialized successfully")
+        except Exception as e:
+            logger.error(f"❌ Failed to initialize PaddleTableClsModel: {e}")
+            raise
+    
+    def cleanup(self):
+        """清理资源"""
+        if self.model:
+            del self.model
+            self.model = None
+            logger.info("✅ PaddleTableClsModel cleaned up")
+    
+    def classify(
+        self, 
+        image: Union[np.ndarray, Image.Image]
+    ) -> Dict[str, Any]:
+        """
+        分类单个表格图像
+        
+        Args:
+            image: 表格图像(numpy数组或PIL图像)
+            
+        Returns:
+            分类结果字典:
+            {
+                'table_type': 'wired' | 'wireless',
+                'confidence': float,
+                'raw_label': str  # AtomicModel.WiredTable 或 AtomicModel.WirelessTable
+            }
+        """
+        if self.model is None:
+            raise RuntimeError("Model not initialized. Call initialize() first.")
+        
+        try:
+            # 调用 MinerU 的预测接口
+            label, confidence = self.model.predict(image)
+            
+            # 转换标签为简化形式
+            if AtomicModel and label == AtomicModel.WiredTable:
+                table_type = 'wired'
+            elif AtomicModel and label == AtomicModel.WirelessTable:
+                table_type = 'wireless'
+            else:
+                # 兜底:基于字符串判断
+                table_type = 'wired' if 'wired' in str(label).lower() else 'wireless'
+            
+            result = {
+                'table_type': table_type,
+                'confidence': float(confidence),
+                'raw_label': str(label)
+            }
+            
+            logger.debug(f"Table classified as '{table_type}' (confidence: {confidence:.3f})")
+            return result
+            
+        except Exception as e:
+            logger.error(f"Table classification failed: {e}")
+            # 降级:返回默认值
+            return {
+                'table_type': 'wireless',  # 默认使用无线表格(更通用)
+                'confidence': 0.0,
+                'raw_label': 'unknown',
+                'error': str(e)
+            }
+    
+    def batch_classify(
+        self, 
+        images: list[Union[np.ndarray, Image.Image]]
+    ) -> list[Dict[str, Any]]:
+        """
+        批量分类表格图像
+        
+        Args:
+            images: 表格图像列表
+            
+        Returns:
+            分类结果列表
+        """
+        if self.model is None:
+            raise RuntimeError("Model not initialized. Call initialize() first.")
+        
+        if not images:
+            return []
+        
+        try:
+            # 构造 MinerU 期望的输入格式
+            img_info_list = []
+            for i, img in enumerate(images):
+                img_info_list.append({
+                    'wired_table_img': img,
+                    'table_res': {}  # MinerU 会在这里填充结果
+                })
+            
+            # 调用批量预测
+            self.model.batch_predict(img_info_list, batch_size=self.batch_size)
+            
+            # 提取结果
+            results = []
+            for img_info in img_info_list:
+                table_res = img_info['table_res']
+                label = table_res.get('cls_label', '')
+                confidence = table_res.get('cls_score', 0.0)
+                
+                # 转换标签
+                if AtomicModel and label == AtomicModel.WiredTable:
+                    table_type = 'wired'
+                elif AtomicModel and label == AtomicModel.WirelessTable:
+                    table_type = 'wireless'
+                else:
+                    table_type = 'wired' if 'wired' in str(label).lower() else 'wireless'
+                
+                results.append({
+                    'table_type': table_type,
+                    'confidence': float(confidence),
+                    'raw_label': str(label)
+                })
+            
+            return results
+            
+        except Exception as e:
+            logger.error(f"Batch table classification failed: {e}")
+            # 降级:返回默认值
+            return [
+                {
+                    'table_type': 'wireless',
+                    'confidence': 0.0,
+                    'raw_label': 'unknown',
+                    'error': str(e)
+                }
+                for _ in images
+            ]