Procházet zdrojové kódy

feat: Introduce DotsOCR vLLM batch processing tool

- Added main processing script for batch handling of PDF and image files.
- Implemented DotsOCRProcessor for document processing and result management.
- Created README with usage instructions and feature highlights.
- Included initial version and author metadata in the module.
zhch158_admin před 2 týdny
rodič
revize
7980eebb36

+ 247 - 0
ocr_tools/dots.ocr_vl_tool/README.md

@@ -0,0 +1,247 @@
+# DotsOCR vLLM 批量处理工具
+
+基于 DotsOCR 的批量文档处理工具,支持 PDF 和图片文件的批量处理。
+
+## 功能特性
+
+- ✅ 统一输入接口:支持 PDF 文件、图片文件、图片目录、文件列表(.txt)、CSV 文件
+- ✅ 自动判断输入类型:根据输入路径自动识别文件类型并处理
+- ✅ 页面范围支持:PDF 文件和图片目录支持指定页面范围(如 `1-5,7,9-12`)
+- ✅ 成功判断优化:基于输出文件存在性判断处理是否成功
+- ✅ 数字标准化:自动将全角数字转换为半角(可选)
+- ✅ Dry run 模式:验证配置和输入,不执行实际处理
+- ✅ 调试模式:保存原始版本用于对比
+- ✅ 并发处理:支持多线程并发处理(可选)
+- ✅ 进度显示:实时显示处理进度和统计信息
+
+## 安装依赖
+
+```bash
+conda activate py312
+
+# 安装 DotsOCR
+pip install dots-ocr
+
+# 安装其他依赖
+pip install loguru tqdm pillow
+```
+
+## 使用方法
+
+### 基本用法
+
+```bash
+# 处理单个PDF文件
+python main.py --input document.pdf --output_dir ./output
+
+# 处理图片目录
+python main.py --input ./images/ --output_dir ./output
+
+# 处理文件列表
+python main.py --input file_list.txt --output_dir ./output
+
+# 处理CSV文件(失败的文件)
+python main.py --input results.csv --output_dir ./output
+```
+
+### 高级用法
+
+```bash
+# 指定页面范围(PDF或图片目录)
+python main.py --input document.pdf --output_dir ./output --pages "1-5,7"
+
+# 只处理前10页(PDF或图片目录)
+python main.py --input document.pdf --output_dir ./output --pages "-10"
+
+# 从第5页到最后(PDF或图片目录)
+python main.py --input document.pdf --output_dir ./output --pages "5-"
+
+# 启用调试模式
+python main.py --input document.pdf --output_dir ./output --debug
+
+# 仅验证配置(dry run)
+python main.py --input document.pdf --output_dir ./output --dry_run
+
+# 指定服务器地址
+python main.py --input document.pdf --output_dir ./output --ip 10.192.72.11 --port 8101
+
+# 调整批次大小
+python main.py --input ./images/ --output_dir ./output --batch_size 4
+
+# 禁用数字标准化
+python main.py --input document.pdf --output_dir ./output --no-normalize
+
+# 启用并发处理
+python main.py --input ./images/ --output_dir ./output --use_threading --max_workers 3
+```
+
+## 参数说明
+
+### 输入输出参数
+
+- `--input, -i`: 输入路径(必需)
+  - PDF 文件:自动转换为图片处理
+  - 图片文件:直接处理
+  - 图片目录:扫描所有图片文件
+  - 文件列表(.txt):每行一个文件路径
+  - CSV 文件:读取失败的文件列表
+
+- `--output_dir, -o`: 输出目录(必需)
+
+### DotsOCR vLLM 参数
+
+- `--ip`: vLLM 服务器 IP(默认: `127.0.0.1`)
+- `--port`: vLLM 服务器端口(默认: `8101`)
+- `--model_name`: 模型名称(默认: `DotsOCR`)
+- `--prompt_mode`: 提示模式(默认: `prompt_layout_all_en`)
+  - 可选值:`dict_promptmode_to_prompt.keys()` 中的所有模式
+- `--min_pixels`: 最小像素数(默认: `MIN_PIXELS`)
+- `--max_pixels`: 最大像素数(默认: `MAX_PIXELS`)
+- `--dpi`: PDF 转图片的 DPI(默认: `200`)
+
+### 处理参数
+
+- `--batch_size`: 批次大小(默认: `1`)
+- `--pages, -p`: 页面范围(PDF和图片目录有效)
+  - 格式:`"1-5,7,9-12"`(第1-5页、第7页、第9-12页)
+  - `"1-"`:从第1页到最后
+  - `"-10"`:前10页
+- `--collect_results`: 收集处理结果到指定CSV文件
+
+### 并发参数
+
+- `--use_threading`: 启用多线程并发处理
+- `--max_workers`: 最大并发工作线程数(默认: `3`,应与 vLLM data-parallel-size 匹配)
+
+### 功能开关
+
+- `--no-normalize`: 禁用数字标准化(默认启用)
+- `--debug`: 启用调试模式(保存原始版本用于对比)
+- `--dry_run`: 仅验证配置,不执行处理
+
+### 日志参数
+
+- `--log_level`: 日志级别(`DEBUG`, `INFO`, `WARNING`, `ERROR`,默认: `INFO`)
+- `--log_file`: 日志文件路径
+
+## 输出格式
+
+输出目录结构:
+
+```
+output_dir/
+├── filename.md              # Markdown 内容
+├── filename.json            # Layout info JSON
+├── filename_layout.jpg      # 布局可视化图片
+├── filename_original.md     # 原始 Markdown(如果启用标准化且发生变化)
+└── filename_original.json   # 原始 JSON(如果启用标准化且发生变化)
+```
+
+### 成功判断标准
+
+处理成功的判断标准:
+- 输出目录中存在对应的 `.md` 文件
+- 输出目录中存在对应的 `.json` 文件
+
+如果两个文件都存在,则认为处理成功。
+
+## 统计信息
+
+处理完成后会显示:
+
+- 文件统计:总文件数、成功数、失败数、跳过数
+- 性能指标:总耗时、吞吐量、平均处理时间
+
+结果会保存到 `{output_dir}_results.json` 文件中。
+
+## 示例
+
+### 示例1:处理PDF文件
+
+```bash
+python main.py \
+  --input /path/to/document.pdf \
+  --output_dir ./output \
+  --pages "1-10" \
+  --ip 10.192.72.11 \
+  --port 8101 \
+  --debug
+```
+
+### 示例2:批量处理图片目录(并发)
+
+```bash
+python main.py \
+  --input /path/to/images/ \
+  --output_dir ./output \
+  --batch_size 4 \
+  --use_threading \
+  --max_workers 3 \
+  --log_file ./processing.log
+```
+
+### 示例3:Dry run 验证
+
+```bash
+python main.py \
+  --input /path/to/document.pdf \
+  --output_dir ./output \
+  --dry_run
+```
+
+### 示例4:处理失败的文件(从CSV)
+
+```bash
+python main.py \
+  --input processed_files.csv \
+  --output_dir ./output \
+  --ip 10.192.72.11 \
+  --port 8101
+```
+
+## 注意事项
+
+1. **服务器连接**:确保 DotsOCR vLLM 服务器正在运行并可访问
+2. **内存使用**:处理大文件时注意内存使用情况
+3. **文件命名**:PDF 页面会转换为 `filename_page_001.png` 格式
+4. **页面范围**:页面编号从 1 开始(不是 0)
+5. **并发处理**:使用 `--use_threading` 时,确保 `--max_workers` 与 vLLM 服务器的 `data-parallel-size` 匹配
+
+## 故障排查
+
+### 问题:连接服务器失败
+
+- 检查服务器地址和端口是否正确
+- 确认服务器是否正在运行
+- 检查网络连接和防火墙设置
+
+### 问题:处理失败
+
+- 启用 `--debug` 模式查看详细错误信息
+- 检查输出目录权限
+- 查看日志文件获取更多信息
+
+### 问题:输出文件不存在
+
+- 检查处理是否真的失败(查看错误信息)
+- 确认输出目录路径正确
+- 检查磁盘空间是否充足
+
+### 问题:并发处理性能不佳
+
+- 确保 `--max_workers` 与 vLLM 服务器的 `data-parallel-size` 匹配
+- 检查服务器资源使用情况
+- 调整 `--batch_size` 参数
+
+## 相关工具
+
+- `ocr_utils`: OCR 工具包,提供 PDF 处理、文件处理等功能
+- DotsOCR: 文档解析框架
+
+## 与 MinerU 工具的差异
+
+1. **输出格式**:DotsOCR 输出 `_layout.jpg`,MinerU 输出 `_layout.pdf`
+2. **服务器参数**:DotsOCR 使用 `--ip` 和 `--port`,MinerU 使用 `--server_url`
+3. **提示模式**:DotsOCR 支持多种 `prompt_mode`,MinerU 使用固定的提示方式
+4. **并发处理**:DotsOCR 支持可选的并发处理(`--use_threading`),MinerU 仅支持单进程
+

+ 10 - 0
ocr_tools/dots.ocr_vl_tool/__init__.py

@@ -0,0 +1,10 @@
+"""
+DotsOCR vLLM 工具
+
+基于 DotsOCR 的批量文档处理工具
+支持 PDF 和图片文件的批量处理
+"""
+
+__version__ = "1.0.0"
+__author__ = "zhch158"
+

+ 568 - 0
ocr_tools/dots.ocr_vl_tool/main.py

@@ -0,0 +1,568 @@
+#!/usr/bin/env python3
+"""
+批量处理图片/PDF文件并生成符合评测要求的预测结果(DotsOCR版本)
+
+根据 OmniDocBench 评测要求:
+- 输入:支持 PDF 和各种图片格式(统一使用 --input 参数)
+- 输出:每个文件对应的 .md、.json 和带标注的 layout 图片文件
+- 调用方式:通过 DotsOCR vLLM 服务器处理
+
+使用方法:
+    python main.py --input document.pdf --output_dir ./output
+    python main.py --input ./images/ --output_dir ./output
+    python main.py --input file_list.txt --output_dir ./output
+    python main.py --input results.csv --output_dir ./output --dry_run
+"""
+
+import os
+import sys
+import json
+import time
+import traceback
+from pathlib import Path
+from typing import List, Dict, Any
+from tqdm import tqdm
+import argparse
+
+from loguru import logger
+
+# 导入 ocr_utils
+ocr_platform_root = Path(__file__).parents[2]
+if str(ocr_platform_root) not in sys.path:
+    sys.path.insert(0, str(ocr_platform_root))
+
+from ocr_utils import (
+    get_input_files,
+    collect_pid_files,
+    setup_logging
+)
+
+# 导入处理器
+try:
+    from .processor import DotsOCRProcessor
+except ImportError:
+    from processor import DotsOCRProcessor
+
+# 导入 dots.ocr 相关模块
+from dots_ocr.utils import dict_promptmode_to_prompt
+from dots_ocr.utils.consts import MIN_PIXELS, MAX_PIXELS
+
+
+def process_images_single_process(
+    image_paths: List[str],
+    processor: DotsOCRProcessor,
+    batch_size: int = 1,
+    output_dir: str = "./output"
+) -> List[Dict[str, Any]]:
+    """
+    单进程版本的图像处理函数
+    
+    Args:
+        image_paths: 图像文件路径列表
+        processor: DotsOCR处理器实例
+        batch_size: 批处理大小
+        output_dir: 输出目录
+        
+    Returns:
+        处理结果列表
+    """
+    # 创建输出目录
+    output_path = Path(output_dir)
+    output_path.mkdir(parents=True, exist_ok=True)
+    
+    all_results = []
+    total_images = len(image_paths)
+    
+    logger.info(f"Processing {total_images} images with batch size {batch_size}")
+    
+    with tqdm(total=total_images, desc="Processing images", unit="img", 
+              bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]') as pbar:
+        
+        for i in range(0, total_images, batch_size):
+            batch = image_paths[i:i + batch_size]
+            batch_start_time = time.time()
+            batch_results = []
+            
+            try:
+                for image_path in batch:
+                    try:
+                        result = processor.process_single_image(image_path, output_dir)
+                        batch_results.append(result)
+                    except Exception as e:
+                        logger.error(f"Error processing {image_path}: {e}")
+                        batch_results.append({
+                            "image_path": image_path,
+                            "processing_time": 0,
+                            "success": False,
+                            "device": f"{processor.ip}:{processor.port}",
+                            "error": str(e)
+                        })
+                
+                batch_processing_time = time.time() - batch_start_time
+                all_results.extend(batch_results)
+                
+                # 更新进度条
+                success_count = sum(1 for r in batch_results if r.get('success', False))
+                skipped_count = sum(1 for r in batch_results if r.get('skipped', False))
+                total_success = sum(1 for r in all_results if r.get('success', False))
+                total_skipped = sum(1 for r in all_results if r.get('skipped', False))
+                avg_time = batch_processing_time / len(batch) if len(batch) > 0 else 0
+                
+                pbar.update(len(batch))
+                pbar.set_postfix({
+                    'batch_time': f"{batch_processing_time:.2f}s",
+                    'avg_time': f"{avg_time:.2f}s/img",
+                    'success': f"{total_success}/{len(all_results)}",
+                    'skipped': f"{total_skipped}",
+                    'rate': f"{total_success/len(all_results)*100:.1f}%" if len(all_results) > 0 else "0%"
+                })
+                
+            except Exception as e:
+                logger.error(f"Error processing batch {[Path(p).name for p in batch]}: {e}")
+                error_results = []
+                for img_path in batch:
+                    error_results.append({
+                        "image_path": str(img_path),
+                        "processing_time": 0,
+                        "success": False,
+                        "device": f"{processor.ip}:{processor.port}",
+                        "error": str(e)
+                    })
+                all_results.extend(error_results)
+                pbar.update(len(batch))
+    
+    return all_results
+
+
+def process_images_concurrent(
+    image_paths: List[str],
+    processor: DotsOCRProcessor,
+    batch_size: int = 1,
+    output_dir: str = "./output",
+    max_workers: int = 3
+) -> List[Dict[str, Any]]:
+    """并发版本的图像处理函数"""
+    
+    from concurrent.futures import ThreadPoolExecutor, as_completed
+    
+    Path(output_dir).mkdir(parents=True, exist_ok=True)
+    
+    def process_batch(batch_images):
+        """处理一批图像"""
+        batch_results = []
+        for image_path in batch_images:
+            try:
+                result = processor.process_single_image(image_path, output_dir)
+                batch_results.append(result)
+            except Exception as e:
+                batch_results.append({
+                    "image_path": image_path,
+                    "processing_time": 0,
+                    "success": False,
+                    "device": f"{processor.ip}:{processor.port}",
+                    "error": str(e)
+                })
+        return batch_results
+    
+    # 将图像分批
+    batches = [image_paths[i:i + batch_size] for i in range(0, len(image_paths), batch_size)]
+    
+    all_results = []
+    
+    with ThreadPoolExecutor(max_workers=max_workers) as executor:
+        # 提交所有批次
+        future_to_batch = {executor.submit(process_batch, batch): batch for batch in batches}
+        
+        # 使用 tqdm 显示进度
+        with tqdm(total=len(image_paths), desc="Processing images") as pbar:
+            for future in as_completed(future_to_batch):
+                try:
+                    batch_results = future.result()
+                    all_results.extend(batch_results)
+                    
+                    # 更新进度
+                    success_count = sum(1 for r in batch_results if r.get('success', False))
+                    pbar.update(len(batch_results))
+                    pbar.set_postfix({'batch_success': f"{success_count}/{len(batch_results)}"})
+                    
+                except Exception as e:
+                    batch = future_to_batch[future]
+                    # 为批次中的所有图像添加错误结果
+                    error_results = [
+                        {
+                            "image_path": img_path,
+                            "processing_time": 0,
+                            "success": False,
+                            "device": f"{processor.ip}:{processor.port}",
+                            "error": str(e)
+                        }
+                        for img_path in batch
+                    ]
+                    all_results.extend(error_results)
+                    pbar.update(len(batch))
+    
+    return all_results
+
+
+def main():
+    """主函数"""
+    parser = argparse.ArgumentParser(
+        description="DotsOCR vLLM Batch Processing",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+示例:
+  # 处理单个PDF文件
+  python main.py --input document.pdf --output_dir ./output
+  
+  # 处理图片目录
+  python main.py --input ./images/ --output_dir ./output
+  
+  # 处理文件列表
+  python main.py --input file_list.txt --output_dir ./output
+  
+  # 处理CSV文件(失败的文件)
+  python main.py --input results.csv --output_dir ./output
+  
+  # 指定页面范围(仅PDF)
+  python main.py --input document.pdf --output_dir ./output --pages "1-5,7"
+  
+  # 启用调试模式
+  python main.py --input document.pdf --output_dir ./output --debug
+  
+  # 仅验证配置(dry run)
+  python main.py --input document.pdf --output_dir ./output --dry_run
+        """
+    )
+    
+    # 输入参数(统一使用 --input)
+    parser.add_argument(
+        "--input", "-i",
+        required=True,
+        type=str,
+        help="输入路径(支持PDF文件、图片文件、图片目录、文件列表.txt、CSV文件)"
+    )
+    
+    # 输出参数
+    parser.add_argument(
+        "--output_dir", "-o",
+        type=str,
+        required=True,
+        help="输出目录"
+    )
+    
+    # DotsOCR vLLM 参数
+    parser.add_argument(
+        "--ip",
+        type=str,
+        default="10.192.72.11",
+        help="vLLM 服务器 IP"
+    )
+    parser.add_argument(
+        "--port",
+        type=int,
+        default=8101,
+        help="vLLM 服务器端口"
+    )
+    parser.add_argument(
+        "--model_name",
+        type=str,
+        default="DotsOCR",
+        help="模型名称"
+    )
+    parser.add_argument(
+        "--prompt_mode",
+        type=str,
+        default="prompt_layout_all_en",
+        choices=list(dict_promptmode_to_prompt.keys()),
+        help="提示模式"
+    )
+    parser.add_argument(
+        "--min_pixels",
+        type=int,
+        default=MIN_PIXELS,
+        help="最小像素数"
+    )
+    parser.add_argument(
+        "--max_pixels",
+        type=int,
+        default=MAX_PIXELS,
+        help="最大像素数"
+    )
+    parser.add_argument(
+        "--dpi",
+        type=int,
+        default=200,
+        help="PDF 转图片的 DPI"
+    )
+    parser.add_argument(
+        '--no-normalize',
+        action='store_true',
+        help='禁用数字标准化'
+    )
+    parser.add_argument(
+        '--debug',
+        action='store_true',
+        help='启用调试模式'
+    )
+    
+    # 处理参数
+    parser.add_argument(
+        "--batch_size",
+        type=int,
+        default=1,
+        help="Batch size"
+    )
+    parser.add_argument(
+        "--pages", "-p",
+        type=str,
+        help="页面范围(PDF和图片目录有效),如: '1-5,7,9-12', '1-', '-10'"
+    )
+    parser.add_argument(
+        "--collect_results",
+        type=str,
+        help="收集处理结果到指定CSV文件"
+    )
+    
+    # 并发参数
+    parser.add_argument(
+        "--max_workers",
+        type=int,
+        default=3,
+        help="Maximum number of concurrent workers (should match vLLM data-parallel-size)"
+    )
+    parser.add_argument(
+        "--use_threading",
+        action="store_true",
+        help="Use multi-threading"
+    )
+    
+    # 日志参数
+    parser.add_argument(
+        "--log_level",
+        default="INFO",
+        choices=["DEBUG", "INFO", "WARNING", "ERROR"],
+        help="日志级别(默认: INFO)"
+    )
+    parser.add_argument(
+        "--log_file",
+        type=str,
+        help="日志文件路径"
+    )
+    
+    # Dry run 参数
+    parser.add_argument(
+        "--dry_run",
+        action="store_true",
+        help="仅验证配置和输入,不执行实际处理"
+    )
+    
+    args = parser.parse_args()
+    
+    # 设置日志
+    setup_logging(args.log_level, args.log_file)
+    
+    try:
+        # 创建参数对象(用于 get_input_files)
+        class Args:
+            def __init__(self, input_path, output_dir, pdf_dpi):
+                self.input = input_path
+                self.output_dir = output_dir
+                self.pdf_dpi = pdf_dpi
+        
+        args_obj = Args(args.input, args.output_dir, args.dpi)
+        
+        # 获取并预处理输入文件(页面范围过滤已在 get_input_files 中处理)
+        logger.info("🔄 Preprocessing input files...")
+        if args.pages:
+            logger.info(f"📄 页面范围: {args.pages}")
+        image_files = get_input_files(args_obj, page_range=args.pages)
+        
+        if not image_files:
+            logger.error("❌ No input files found or processed")
+            return 1
+        
+        output_dir = Path(args.output_dir).resolve()
+        logger.info(f"📁 Output dir: {output_dir}")
+        logger.info(f"📊 Found {len(image_files)} image files to process")
+        
+        # Dry run 模式
+        if args.dry_run:
+            logger.info("🔍 Dry run mode: 仅验证配置,不执行处理")
+            logger.info(f"📋 配置信息:")
+            logger.info(f"  - 输入: {args.input}")
+            logger.info(f"  - 输出目录: {output_dir}")
+            logger.info(f"  - 服务器: {args.ip}:{args.port}")
+            logger.info(f"  - 模型: {args.model_name}")
+            logger.info(f"  - 提示模式: {args.prompt_mode}")
+            logger.info(f"  - 批次大小: {args.batch_size}")
+            logger.info(f"  - PDF DPI: {args.dpi}")
+            logger.info(f"  - 数字标准化: {not args.no_normalize}")
+            logger.info(f"  - 调试模式: {args.debug}")
+            if args.pages:
+                logger.info(f"  - 页面范围: {args.pages}")
+            if args.use_threading:
+                logger.info(f"  - 并发工作数: {args.max_workers}")
+            logger.info(f"📋 将要处理的文件 ({len(image_files)} 个):")
+            for i, img_file in enumerate(image_files[:20], 1):  # 只显示前20个
+                logger.info(f"  {i}. {img_file}")
+            if len(image_files) > 20:
+                logger.info(f"  ... 还有 {len(image_files) - 20} 个文件")
+            logger.info("✅ Dry run 完成:配置验证通过")
+            return 0
+        
+        logger.info(f"🌐 Using server: {args.ip}:{args.port}")
+        logger.info(f"📦 Batch size: {args.batch_size}")
+        logger.info(f"🎯 Prompt mode: {args.prompt_mode}")
+        
+        # 创建处理器
+        processor = DotsOCRProcessor(
+            ip=args.ip,
+            port=args.port,
+            model_name=args.model_name,
+            prompt_mode=args.prompt_mode,
+            dpi=args.dpi,
+            min_pixels=args.min_pixels,
+            max_pixels=args.max_pixels,
+            normalize_numbers=not args.no_normalize,
+            debug=args.debug
+        )
+        
+        # 开始处理
+        start_time = time.time()
+        
+        # 选择处理方式
+        if args.use_threading:
+            results = process_images_concurrent(
+                image_files,
+                processor,
+                args.batch_size,
+                str(output_dir),
+                args.max_workers
+            )
+        else:
+            results = process_images_single_process(
+                image_files,
+                processor,
+                args.batch_size,
+                str(output_dir)
+            )
+        
+        total_time = time.time() - start_time
+        
+        # 统计结果
+        success_count = sum(1 for r in results if r.get('success', False))
+        skipped_count = sum(1 for r in results if r.get('skipped', False))
+        error_count = len(results) - success_count
+        pdf_page_count = sum(1 for r in results if r.get('is_pdf_page', False))
+        
+        print(f"\n" + "="*60)
+        print(f"✅ Processing completed!")
+        print(f"📊 Statistics:")
+        print(f"  Total files processed: {len(image_files)}")
+        print(f"  PDF pages processed: {pdf_page_count}")
+        print(f"  Regular images processed: {len(image_files) - pdf_page_count}")
+        print(f"  Successful: {success_count}")
+        print(f"  Skipped: {skipped_count}")
+        print(f"  Failed: {error_count}")
+        if len(image_files) > 0:
+            print(f"  Success rate: {success_count / len(image_files) * 100:.2f}%")
+        
+        print(f"⏱️ Performance:")
+        print(f"  Total time: {total_time:.2f} seconds")
+        if total_time > 0:
+            print(f"  Throughput: {len(image_files) / total_time:.2f} images/second")
+            print(f"  Avg time per image: {total_time / len(image_files):.2f} seconds")
+        
+        print(f"\n📁 Output Structure:")
+        print(f"  output_dir/")
+        print(f"  ├── filename.md              # Markdown content")
+        print(f"  ├── filename.json            # Layout info JSON")
+        print(f"  └── filename_layout.jpg       # Layout visualization")
+
+        # 保存结果统计
+        stats = {
+            "total_files": len(image_files),
+            "pdf_pages": pdf_page_count,
+            "regular_images": len(image_files) - pdf_page_count,
+            "success_count": success_count,
+            "skipped_count": skipped_count,
+            "error_count": error_count,
+            "success_rate": success_count / len(image_files) if len(image_files) > 0 else 0,
+            "total_time": total_time,
+            "throughput": len(image_files) / total_time if total_time > 0 else 0,
+            "avg_time_per_image": total_time / len(image_files) if len(image_files) > 0 else 0,
+            "batch_size": args.batch_size,
+            "server": f"{args.ip}:{args.port}",
+            "model": args.model_name,
+            "prompt_mode": args.prompt_mode,
+            "pdf_dpi": args.dpi,
+            "normalization_enabled": not args.no_normalize,
+            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+        }
+        
+        # 保存最终结果
+        output_file_name = Path(output_dir).name
+        output_file = output_dir / f"{output_file_name}_results.json"
+        final_results = {
+            "stats": stats,
+            "results": results
+        }
+        
+        with open(output_file, 'w', encoding='utf-8') as f:
+            json.dump(final_results, f, ensure_ascii=False, indent=2)
+        
+        logger.info(f"💾 Results saved to: {output_file}")
+
+        # 收集处理结果
+        if not args.collect_results:
+            output_file_processed = output_dir / f"processed_files_{time.strftime('%Y%m%d_%H%M%S')}.csv"
+        else:
+            output_file_processed = Path(args.collect_results).resolve()
+            
+        processed_files = collect_pid_files(str(output_file))
+        with open(output_file_processed, 'w', encoding='utf-8') as f:
+            f.write("image_path,status\n")
+            for file_path, status in processed_files:
+                f.write(f"{file_path},{status}\n")
+        logger.info(f"💾 Processed files saved to: {output_file_processed}")
+
+        return 0
+        
+    except Exception as e:
+        logger.error(f"Processing failed: {e}")
+        traceback.print_exc()
+        return 1
+
+
+if __name__ == "__main__":
+    logger.info(f"🚀 启动DotsOCR vLLM统一PDF/图像处理程序...")
+    logger.info(f"🔧 CUDA_VISIBLE_DEVICES: {os.environ.get('CUDA_VISIBLE_DEVICES', 'Not set')}")
+    
+    if len(sys.argv) == 1:
+        # 如果没有命令行参数,使用默认配置运行
+        logger.info("ℹ️  No command line arguments provided. Running with default configuration...")
+        
+        # 默认配置
+        default_config = {
+            # "input": "/Users/zhch158/workspace/data/流水分析/马公账流水_工商银行.pdf",
+            "input": "/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/dots.ocr_vl_tool/output/processed_files_20251218_164332.csv",
+            "output_dir": "./output",
+            "ip": "10.192.72.11",
+            "port": "8101",
+            "model_name": "DotsOCR",
+            "prompt_mode": "prompt_layout_all_en",
+            "batch_size": "1",
+            "dpi": "200",
+            "pages": "-2",
+        }
+        
+        # 构造参数
+        sys.argv = [sys.argv[0]]
+        for key, value in default_config.items():
+            sys.argv.extend([f"--{key}", str(value)])
+        
+        # 调试模式
+        sys.argv.append("--debug")
+    
+    sys.exit(main())
+

+ 306 - 0
ocr_tools/dots.ocr_vl_tool/processor.py

@@ -0,0 +1,306 @@
+"""
+DotsOCR vLLM 处理器
+
+基于 DotsOCR 的文档处理类
+"""
+import os
+import shutil
+import time
+import tempfile
+import uuid
+import traceback
+from pathlib import Path
+from typing import List, Dict, Any
+from PIL import Image
+from loguru import logger
+
+# 导入 dots.ocr 相关模块
+from dots_ocr.parser import DotsOCRParser
+from dots_ocr.utils import dict_promptmode_to_prompt
+from dots_ocr.utils.consts import MIN_PIXELS, MAX_PIXELS
+
+# 导入 ocr_utils
+import sys
+ocr_platform_root = Path(__file__).parents[2]
+if str(ocr_platform_root) not in sys.path:
+    sys.path.insert(0, str(ocr_platform_root))
+
+from ocr_utils import normalize_markdown_table, normalize_json_table
+
+
+class DotsOCRProcessor:
+    """DotsOCR 处理器"""
+    
+    def __init__(self, 
+                 ip: str = "127.0.0.1", 
+                 port: int = 8101, 
+                 model_name: str = "DotsOCR",
+                 prompt_mode: str = "prompt_layout_all_en",
+                 dpi: int = 200,
+                 min_pixels: int = MIN_PIXELS,
+                 max_pixels: int = MAX_PIXELS,
+                 normalize_numbers: bool = False,
+                 debug: bool = False):
+        """
+        初始化处理器
+        
+        Args:
+            ip: vLLM 服务器 IP
+            port: vLLM 服务器端口
+            model_name: 模型名称
+            prompt_mode: 提示模式
+            dpi: PDF 处理 DPI
+            min_pixels: 最小像素数
+            max_pixels: 最大像素数
+            normalize_numbers: 是否标准化数字
+            debug: 是否启用调试模式
+        """
+        self.ip = ip
+        self.port = port
+        self.model_name = model_name
+        self.prompt_mode = prompt_mode
+        self.dpi = dpi
+        self.min_pixels = min_pixels
+        self.max_pixels = max_pixels
+        self.normalize_numbers = normalize_numbers
+        self.debug = debug
+
+        # 初始化解析器
+        self.parser = DotsOCRParser(
+            ip=ip,
+            port=port,
+            dpi=dpi,
+            min_pixels=min_pixels,
+            max_pixels=max_pixels,
+            model_name=model_name
+        )
+        
+        logger.info(f"DotsOCR Parser 初始化完成:")
+        logger.info(f"  - 服务器: {ip}:{port}")
+        logger.info(f"  - 模型: {model_name}")
+        logger.info(f"  - 提示模式: {prompt_mode}")
+        logger.info(f"  - 像素范围: {min_pixels} - {max_pixels}")
+        logger.info(f"  - 数字标准化: {normalize_numbers}")
+        logger.info(f"  - 调试模式: {debug}")
+    
+    def create_temp_session_dir(self) -> tuple:
+        """创建临时会话目录"""
+        session_id = uuid.uuid4().hex[:8]
+        temp_dir = os.path.join(tempfile.gettempdir(), f"dotsocr_batch_{session_id}")
+        os.makedirs(temp_dir, exist_ok=True)
+        return temp_dir, session_id
+    
+    def save_results_to_output_dir(self, result: Dict, image_name: str, output_dir: str) -> Dict[str, str]:
+        """
+        将处理结果保存到输出目录
+        
+        Args:
+            result: 解析结果
+            image_name: 图片文件名(不含扩展名)
+            output_dir: 输出目录
+            
+        Returns:
+            dict: 保存的文件路径
+        """
+        saved_files = {}
+        
+        try:
+            # 1. 保存 Markdown 文件
+            output_md_path = os.path.join(output_dir, f"{image_name}.md")
+            md_content = ""
+            
+            # 优先使用无页眉页脚的版本(符合 OmniDocBench 评测要求)
+            if 'md_content_nohf_path' in result and os.path.exists(result['md_content_nohf_path']):
+                with open(result['md_content_nohf_path'], 'r', encoding='utf-8') as f:
+                    md_content = f.read()
+            elif 'md_content_path' in result and os.path.exists(result['md_content_path']):
+                with open(result['md_content_path'], 'r', encoding='utf-8') as f:
+                    md_content = f.read()
+            else:
+                md_content = "# 解析失败\n\n未能提取到有效的文档内容。"
+            
+            # 如果启用数字标准化,处理 Markdown 内容
+            original_text = md_content
+            if self.normalize_numbers:
+                md_content = normalize_markdown_table(md_content)
+                
+                # 统计标准化的变化
+                changes_count = len([1 for o, n in zip(original_text, md_content) if o != n])
+                if changes_count > 0:
+                    saved_files['md_normalized'] = f"✅ 已标准化 {changes_count} 个字符(全角→半角)"
+                else:
+                    saved_files['md_normalized'] = "ℹ️ 无需标准化(已是标准格式)"
+            
+            with open(output_md_path, 'w', encoding='utf-8') as f:
+                f.write(md_content)
+            saved_files['md'] = output_md_path
+            
+            # 如果启用了标准化,也保存原始版本用于对比
+            if self.normalize_numbers and original_text != md_content:
+                original_markdown_path = Path(output_dir) / f"{Path(image_name).stem}_original.md"
+                with open(original_markdown_path, 'w', encoding='utf-8') as f:
+                    f.write(original_text)
+            
+            # 2. 保存 JSON 文件
+            output_json_path = os.path.join(output_dir, f"{image_name}.json")
+            
+            if 'layout_info_path' in result and os.path.exists(result['layout_info_path']):
+                with open(result['layout_info_path'], 'r', encoding='utf-8') as f:
+                    json_content = f.read()
+            else:
+                json_content = '{"error": "未能提取到有效的布局信息"}'
+            
+            # 对json中的表格内容进行数字标准化
+            original_json_text = json_content
+            if self.normalize_numbers:
+                json_content = normalize_json_table(json_content)
+                
+                # 统计标准化的变化
+                changes_count = len([1 for o, n in zip(original_json_text, json_content) if o != n])
+                if changes_count > 0:
+                    saved_files['json_normalized'] = f"✅ 已标准化 {changes_count} 个字符(全角→半角)"
+                else:
+                    saved_files['json_normalized'] = "ℹ️ 无需标准化(已是标准格式)"
+            
+            with open(output_json_path, 'w', encoding='utf-8') as f:
+                f.write(json_content)
+            saved_files['json'] = output_json_path
+            
+            # 如果启用了标准化,也保存原始版本用于对比
+            if self.normalize_numbers and original_json_text != json_content:
+                original_json_path = Path(output_dir) / f"{Path(image_name).stem}_original.json"
+                with open(original_json_path, 'w', encoding='utf-8') as f:
+                    f.write(original_json_text)
+            
+            # 3. 保存带标注的布局图片
+            output_layout_image_path = os.path.join(output_dir, f"{image_name}_layout.jpg")
+            
+            if 'layout_image_path' in result and os.path.exists(result['layout_image_path']):
+                # 直接复制布局图片
+                shutil.copy2(result['layout_image_path'], output_layout_image_path)
+                saved_files['layout_image'] = output_layout_image_path
+            else:
+                # 如果没有布局图片,使用原始图片作为占位符
+                try:
+                    original_image = Image.open(result.get('original_image_path', ''))
+                    original_image.save(output_layout_image_path, 'JPEG', quality=95)
+                    saved_files['layout_image'] = output_layout_image_path
+                except Exception as e:
+                    logger.warning(f"Failed to save layout image: {e}")
+                    saved_files['layout_image'] = None
+            
+        except Exception as e:
+            logger.error(f"Error saving results for {image_name}: {e}")
+            if self.debug:
+                traceback.print_exc()
+            
+        return saved_files
+    
+    def process_single_image(self, image_path: str, output_dir: str) -> Dict[str, Any]:
+        """
+        处理单张图片
+        
+        Args:
+            image_path: 图片路径
+            output_dir: 输出目录
+            
+        Returns:
+            dict: 处理结果,包含 success 字段(基于输出文件存在性判断)
+        """
+        start_time = time.time()
+        image_path_obj = Path(image_path)
+        image_name = image_path_obj.stem
+        
+        # 判断是否为PDF页面(根据文件名模式)
+        is_pdf_page = "_page_" in image_path_obj.name
+        
+        # 根据输入类型生成预期的输出文件名
+        expected_md_path = Path(output_dir) / f"{image_name}.md"
+        expected_json_path = Path(output_dir) / f"{image_name}.json"
+        
+        result_info = {
+            "image_path": image_path,
+            "processing_time": 0,
+            "success": False,
+            "device": f"{self.ip}:{self.port}",
+            "error": None,
+            "output_files": {},
+            "is_pdf_page": is_pdf_page
+        }
+        
+        try:
+            # 检查输出文件是否已存在(成功判断标准:.md 和 .json 文件都存在)
+            if expected_md_path.exists() and expected_json_path.exists():
+                result_info.update({
+                    "success": True,
+                    "processing_time": 0,
+                    "output_files": {
+                        "md": str(expected_md_path),
+                        "json": str(expected_json_path)
+                    },
+                    "skipped": True
+                })
+                logger.info(f"✅ 文件已存在,跳过处理: {image_name}")
+                return result_info
+            
+            # 创建临时会话目录
+            temp_dir, session_id = self.create_temp_session_dir()
+            
+            try:
+                # 读取图片
+                image = Image.open(image_path)
+                
+                # 使用 DotsOCRParser 处理图片
+                filename = f"dotsocr_{session_id}"
+                results = self.parser.parse_image(
+                    input_path=image,
+                    filename=filename,
+                    prompt_mode=self.prompt_mode,
+                    save_dir=temp_dir,
+                    fitz_preprocess=True  # 对图片使用 fitz 预处理
+                )
+                
+                # 解析结果
+                if not results:
+                    raise Exception("未返回解析结果")
+                
+                result = results[0]  # parse_image 返回单个结果的列表
+                
+                # 保存所有结果文件到输出目录
+                saved_files = self.save_results_to_output_dir(result, image_name, output_dir)
+                
+                # 处理完成后,再次检查输出文件是否存在(成功判断标准)
+                if expected_md_path.exists() and expected_json_path.exists():
+                    result_info.update({
+                        "success": True,
+                        "output_files": saved_files
+                    })
+                    logger.info(f"✅ 处理成功: {image_name}")
+                else:
+                    # 文件不存在,标记为失败
+                    missing_files = []
+                    if not expected_md_path.exists():
+                        missing_files.append("md")
+                    if not expected_json_path.exists():
+                        missing_files.append("json")
+                    result_info["error"] = f"输出文件不存在: {', '.join(missing_files)}"
+                    result_info["success"] = False
+                    logger.error(f"❌ 处理失败: {image_name} - {result_info['error']}")
+                
+            finally:
+                # 清理临时目录
+                if os.path.exists(temp_dir):
+                    shutil.rmtree(temp_dir, ignore_errors=True)
+                
+        except Exception as e:
+            result_info["error"] = str(e)
+            result_info["success"] = False
+            logger.error(f"Error processing {image_name}: {e}")
+            if self.debug:
+                traceback.print_exc()
+        
+        finally:
+            result_info["processing_time"] = time.time() - start_time
+        
+        return result_info
+