从单次点击到工业级流水线:用Python脚本驱动DeepSeek-OCR-2批量处理千份PDF

如果你已经通过Web界面体验过DeepSeek-OCR-2的识别能力,可能会被它的准确率和速度所折服。但当你面对的是成百上千份合同、报告或档案时,一次次点击上传、等待、下载的操作模式就显得力不从心了。真正的生产力提升,来自于将强大的AI能力封装进自动化流程,让它像流水线一样不知疲倦地工作。

我最近接手了一个项目,需要从近三年的数千份扫描版技术文档中提取关键数据。最初我也尝试手动处理,但仅仅做了几十份就意识到这条路行不通——时间成本太高,而且人工操作容易出错。经过一番摸索,我构建了一套完整的自动化处理系统,现在这套系统每天能稳定处理上千份PDF,并将结构化数据直接导入数据库,整个过程完全无人值守。

这篇文章就是分享我构建这条“OCR流水线”的完整经验。我不会重复那些基础的环境配置步骤,而是聚焦于如何将DeepSeek-OCR-2从“好用的工具”升级为“可靠的生产力系统”。无论你是需要处理大量文档的数据分析师,还是希望将OCR能力集成到业务系统中的开发者,这套方案都能为你提供直接的参考价值。

1. 自动化架构设计:从单点工具到流水线系统

构建自动化OCR流水线的第一步不是写代码,而是设计清晰的架构。一个健壮的批量处理系统需要考虑文件管理、任务调度、错误处理和数据持久化等多个环节。

1.1 核心组件与数据流

我的流水线基于微服务思想设计,将整个处理流程拆解为独立的模块,每个模块只负责单一职责。这样的设计不仅便于调试和维护,还能灵活应对不同的业务需求。

整个系统的数据流向是这样的:

原始PDF文件 → 文件监控模块 → 任务队列 → OCR处理模块 → 结果解析模块 → 数据存储模块

每个模块之间通过清晰的接口进行通信,任何一个环节出现问题都不会导致整个系统崩溃。比如OCR服务暂时不可用时,任务队列会保留待处理任务,等服务恢复后继续执行。

1.2 环境准备与依赖安装

虽然DeepSeek-OCR-2的Docker镜像已经包含了运行所需的一切,但我们的自动化脚本还需要一些额外的Python库。我建议创建一个独立的虚拟环境来管理这些依赖。

# 创建并激活虚拟环境
python -m venv ocr_pipeline
source ocr_pipeline/bin/activate  # Linux/macOS
# 或 ocr_pipeline\Scripts\activate  # Windows

# 安装核心依赖
pip install requests>=2.31.0
pip install Pillow>=10.0.0
pip install PyPDF2>=3.0.0
pip install python-multipart>=0.0.6
pip install sqlalchemy>=2.0.0
pip install pandas>=2.0.0
pip install watchdog>=3.0.0

提示:如果你需要处理特别大的PDF文件(超过100MB),建议额外安装pdf2image库,它能够更高效地将PDF转换为图像序列。

这些库各自承担着不同的角色:

  • requests:用于与DeepSeek-OCR-2的API进行通信
  • Pillow:图像处理,包括格式转换和预处理
  • PyPDF2:PDF文件的基本操作,如获取页数、提取元数据
  • sqlalchemy:数据库操作,支持多种数据库后端
  • pandas:数据清洗和转换,特别是导出为Excel/CSV格式
  • watchdog:文件系统监控,实现自动化触发

2. 构建核心OCR处理引擎

有了清晰的设计架构,接下来我们实现最核心的部分——与DeepSeek-OCR-2 API交互的Python模块。这个模块需要足够健壮,能够处理各种异常情况。

2.1 封装API客户端类

我设计了一个OCRClient类,它封装了所有与DeepSeek-OCR-2服务交互的细节。这样的封装让主程序逻辑更加清晰,也便于后续的维护和扩展。

import base64
import json
import time
from pathlib import Path
from typing import Dict, List, Optional, Union
import requests
from PIL import Image
import io

class OCRClient:
    """DeepSeek-OCR-2 API客户端封装"""
    
    def __init__(self, base_url: str = "http://localhost:7860", timeout: int = 300):
        """
        初始化OCR客户端
        
        Args:
            base_url: DeepSeek-OCR-2服务地址
            timeout: 请求超时时间(秒)
        """
        self.base_url = base_url.rstrip('/')
        self.api_endpoint = f"{self.base_url}/v1/chat/completions"
        self.timeout = timeout
        self.session = requests.Session()
        
        # 配置重试策略
        self.session.mount('http://', requests.adapters.HTTPAdapter(
            max_retries=3,
            pool_connections=10,
            pool_maxsize=100
        ))
    
    def image_to_base64(self, image_path: Union[str, Path]) -> str:
        """将图像文件转换为Base64编码字符串"""
        with Image.open(image_path) as img:
            # 统一转换为RGB模式,确保兼容性
            if img.mode != 'RGB':
                img = img.convert('RGB')
            
            # 将图像保存到内存缓冲区
            buffer = io.BytesIO()
            img.save(buffer, format='JPEG', quality=95)
            buffer.seek(0)
            
            # 编码为Base64
            encoded = base64.b64encode(buffer.read()).decode('utf-8')
            return f"data:image/jpeg;base64,{encoded}"
    
    def pdf_page_to_image(self, pdf_path: str, page_num: int, dpi: int = 200) -> str:
        """将PDF的特定页面转换为Base64编码的图像"""
        try:
            from pdf2image import convert_from_path
            images = convert_from_path(pdf_path, dpi=dpi, first_page=page_num, last_page=page_num)
            if images:
                buffer = io.BytesIO()
                images[0].save(buffer, format='JPEG', quality=95)
                buffer.seek(0)
                encoded = base64.b64encode(buffer.read()).decode('utf-8')
                return f"data:image/jpeg;base64,{encoded}"
        except ImportError:
            # 如果未安装pdf2image,使用备用方案
            print("警告:未安装pdf2image,尝试使用PyPDF2提取页面")
            # 这里可以添加备用实现
        return None
    
    def process_image(self, image_path: Union[str, Path], 
                     extract_tables: bool = False,
                     preserve_layout: bool = True) -> Dict:
        """
        处理单张图像
        
        Args:
            image_path: 图像文件路径
            extract_tables: 是否仅提取表格
            preserve_layout: 是否保留原始布局
        
        Returns:
            OCR识别结果字典
        """
        # 构建提示词
        prompt = "提取全部文字"
        if extract_tables:
            prompt = "仅提取表格,转换为结构化格式"
        elif preserve_layout:
            prompt = "提取全部文字,保留原始布局和格式"
        
        # 准备请求数据
        image_base64 = self.image_to_base64(image_path)
        
        payload = {
            "model": "deepseek-ocr2",
            "messages": [
                {
                    "role": "user",
                    "content": prompt,
                    "images": [image_base64]
                }
            ],
            "temperature": 0.1,  # 低温度确保输出稳定
            "max_tokens": 4000
        }
        
        try:
            response = self.session.post(
                self.api_endpoint,
                json=payload,
                timeout=self.timeout
            )
            response.raise_for_status()
            
            result = response.json()
            return self._parse_response(result)
            
        except requests.exceptions.RequestException as e:
            print(f"API请求失败: {e}")
            return {"error": str(e), "status": "failed"}
    
    def _parse_response(self, api_response: Dict) -> Dict:
        """解析API响应,提取结构化数据"""
        try:
            content = api_response.get('choices', [{}])[0].get('message', {}).get('content', '')
            
            # 这里可以根据实际响应格式进行解析
            # DeepSeek-OCR-2通常返回JSON格式的文本
            if content.strip().startswith('{'):
                try:
                    return json.loads(content)
                except json.JSONDecodeError:
                    # 如果不是JSON,返回原始文本
                    return {"text": content, "format": "plain"}
            else:
                return {"text": content, "format": "plain"}
                
        except Exception as e:
            return {"error": f"解析响应失败: {e}", "raw_response": api_response}
    
    def batch_process(self, file_paths: List[Union[str, Path]], 
                     max_workers: int = 4) -> List[Dict]:
        """批量处理多个文件"""
        from concurrent.futures import ThreadPoolExecutor, as_completed
        
        results = []
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            # 提交所有任务
            future_to_file = {
                executor.submit(self.process_image, file_path): file_path 
                for file_path in file_paths
            }
            
            # 收集结果
            for future in as_completed(future_to_file):
                file_path = future_to_file[future]
                try:
                    result = future.result(timeout=self.timeout)
                    result["file"] = str(file_path)
                    results.append(result)
                except Exception as e:
                    results.append({
                        "file": str(file_path),
                        "error": str(e),
                        "status": "failed"
                    })
        
        return results

这个客户端类提供了几个关键特性:

  1. 连接池管理:通过requests.Session复用HTTP连接,提高性能
  2. 自动重试机制:网络波动或服务暂时不可用时自动重试
  3. 灵活的图像处理:支持多种图像格式和预处理选项
  4. 批量处理支持:利用线程池并行处理多个文件

2.2 实现PDF多页处理策略

处理多页PDF时,我们需要考虑内存使用和性能平衡。直接上传整个PDF文件可能会遇到大小限制,而逐页处理又可能效率低下。我的解决方案是分块处理。

class PDFProcessor:
    """PDF文件处理器,支持多页和批量处理"""
    
    def __init__(self, ocr_client: OCRClient, temp_dir: str = "./temp_images"):
        self.ocr_client = ocr_client
        self.temp_dir = Path(temp_dir)
        self.temp_dir.mkdir(exist_ok=True)
    
    def process_pdf(self, pdf_path: Union[str, Path], 
                   pages: Optional[List[int]] = None,
                   batch_size: int = 10) -> List[Dict]:
        """
        处理PDF文件,支持指定页面范围
        
        Args:
            pdf_path: PDF文件路径
            pages: 要处理的页面列表,None表示处理所有页面
            batch_size: 每批处理的页数
        
        Returns:
            每页的识别结果列表
        """
        pdf_path = Path(pdf_path)
        if not pdf_path.exists():
            raise FileNotFoundError(f"PDF文件不存在: {pdf_path}")
        
        # 获取PDF总页数
        total_pages = self._get_pdf_page_count(pdf_path)
        
        # 确定要处理的页面
        if pages is None:
            pages_to_process = list(range(1, total_pages + 1))
        else:
            pages_to_process = [p for p in pages if 1 <= p <= total_pages]
        
        all_results = []
        
        # 分批处理,避免内存溢出
        for i in range(0, len(pages_to_process), batch_size):
            batch_pages = pages_to_process[i:i + batch_size]
            batch_results = self._process_page_batch(pdf_path, batch_pages)
            all_results.extend(batch_results)
            
            # 清理临时文件
            self._cleanup_temp_files()
        
        return all_results
    
    def _get_pdf_page_count(self, pdf_path: Path) -> int:
        """获取PDF总页数"""
        try:
            import PyPDF2
            with open(pdf_path, 'rb') as file:
                reader = PyPDF2.PdfReader(file)
                return len(reader.pages)
        except Exception as e:
            print(f"获取PDF页数失败: {e}")
            # 尝试使用备用方法
            return self._estimate_page_count(pdf_path)
    
    def _process_page_batch(self, pdf_path: Path, pages: List[int]) -> List[Dict]:
        """处理一批PDF页面"""
        results = []
        
        for page_num in pages:
            try:
                # 将PDF页面转换为图像
                image_base64 = self.ocr_client.pdf_page_to_image(
                    str(pdf_path), page_num
                )
                
                if image_base64:
                    # 临时保存图像文件
                    temp_image_path = self.temp_dir / f"{pdf_path.stem}_page_{page_num}.jpg"
                    
                    # 解码并保存
                    image_data = base64.b64decode(image_base64.split(',')[1])
                    with open(temp_image_path, 'wb') as f:
                        f.write(image_data)
                    
                    # 调用OCR处理
                    result = self.ocr_client.process_image(temp_image_path)
                    result["page"] = page_num
                    result["source_file"] = str(pdf_path)
                    results.append(result)
                    
                else:
                    results.append({
                        "page": page_num,
                        "error": "页面转换失败",
                        "status": "failed"
                    })
                    
            except Exception as e:
                results.append({
                    "page": page_num,
                    "error": str(e),
                    "status": "failed"
                })
        
        return results
    
    def _estimate_page_count(self, pdf_path: Path) -> int:
        """估算PDF页数(备用方法)"""
        # 基于文件大小的简单估算
        file_size_mb = pdf_path.stat().st_size / (1024 * 1024)
        # 假设每页平均200KB
        estimated_pages = int(file_size_mb * 1024 / 200)
        return max(1, estimated_pages)
    
    def _cleanup_temp_files(self):
        """清理临时图像文件"""
        for temp_file in self.temp_dir.glob("*.jpg"):
            try:
                temp_file.unlink()
            except:
                pass  # 忽略删除错误

注意:处理大型PDF时,内存管理至关重要。我建议将batch_size设置为5-10页,这样既能利用并行处理的优势,又不会导致内存不足。

3. 构建完整的文件处理流水线

有了核心的OCR处理能力,现在我们需要构建一个完整的流水线,能够自动监控文件夹、处理新文件、管理处理状态。

3.1 文件监控与任务调度

我使用watchdog库来监控指定文件夹的变化,当有新PDF文件加入时自动触发处理流程。

import time
import logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from pathlib import Path
import sqlite3
from datetime import datetime

class OCRPipeline:
    """OCR处理流水线主控制器"""
    
    def __init__(self, 
                 input_dir: str = "./input",
                 output_dir: str = "./output",
                 processed_dir: str = "./processed",
                 failed_dir: str = "./failed",
                 db_path: str = "./ocr_pipeline.db"):
        
        self.input_dir = Path(input_dir)
        self.output_dir = Path(output_dir)
        self.processed_dir = Path(processed_dir)
        self.failed_dir = Path(failed_dir)
        
        # 创建必要的目录
        for directory in [self.input_dir, self.output_dir, 
                         self.processed_dir, self.failed_dir]:
            directory.mkdir(exist_ok=True)
        
        # 初始化数据库
        self.db_path = db_path
        self._init_database()
        
        # 初始化OCR客户端
        self.ocr_client = OCRClient()
        self.pdf_processor = PDFProcessor(self.ocr_client)
        
        # 设置日志
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
            handlers=[
                logging.FileHandler('./ocr_pipeline.log'),
                logging.StreamHandler()
            ]
        )
        self.logger = logging.getLogger(__name__)
    
    def _init_database(self):
        """初始化SQLite数据库"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # 创建文件处理记录表
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS processed_files (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                filename TEXT NOT NULL,
                filepath TEXT NOT NULL,
                file_size INTEGER,
                page_count INTEGER,
                status TEXT NOT NULL,
                start_time TIMESTAMP,
                end_time TIMESTAMP,
                processing_time REAL,
                error_message TEXT,
                output_path TEXT,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')
        
        # 创建OCR结果表
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS ocr_results (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                file_id INTEGER,
                page_number INTEGER,
                content_type TEXT,
                content_text TEXT,
                confidence REAL,
                bounding_box TEXT,
                metadata TEXT,
                FOREIGN KEY (file_id) REFERENCES processed_files (id)
            )
        ''')
        
        # 创建索引以提高查询性能
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_filename ON processed_files(filename)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON processed_files(status)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_file_id ON ocr_results(file_id)')
        
        conn.commit()
        conn.close()
    
    def process_single_file(self, file_path: Path) -> Dict:
        """处理单个文件"""
        file_record = {
            "filename": file_path.name,
            "filepath": str(file_path),
            "file_size": file_path.stat().st_size,
            "status": "processing",
            "start_time": datetime.now()
        }
        
        try:
            # 记录开始处理
            file_id = self._log_file_start(file_record)
            
            # 根据文件类型选择处理方式
            if file_path.suffix.lower() == '.pdf':
                results = self.pdf_processor.process_pdf(file_path)
            else:
                # 处理图像文件
                result = self.ocr_client.process_image(file_path)
                results = [result] if result else []
            
            # 保存结果
            output_data = self._save_results(file_id, file_path, results)
            
            # 移动文件到已处理目录
            processed_path = self.processed_dir / file_path.name
            file_path.rename(processed_path)
            
            # 更新记录状态
            self._log_file_complete(file_id, "completed", output_data)
            
            self.logger.info(f"成功处理文件: {file_path.name}")
            return {"status": "success", "file_id": file_id, "results": len(results)}
            
        except Exception as e:
            self.logger.error(f"处理文件失败 {file_path.name}: {e}")
            
            # 移动文件到失败目录
            failed_path = self.failed_dir / file_path.name
            try:
                file_path.rename(failed_path)
            except:
                pass
            
            # 更新记录状态
            if 'file_id' in locals():
                self._log_file_complete(file_id, "failed", {"error": str(e)})
            
            return {"status": "failed", "error": str(e)}
    
    def _log_file_start(self, file_record: Dict) -> int:
        """记录文件开始处理"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        cursor.execute('''
            INSERT INTO processed_files 
            (filename, filepath, file_size, status, start_time)
            VALUES (?, ?, ?, ?, ?)
        ''', (
            file_record["filename"],
            file_record["filepath"],
            file_record["file_size"],
            file_record["status"],
            file_record["start_time"]
        ))
        
        file_id = cursor.lastrowid
        conn.commit()
        conn.close()
        
        return file_id
    
    def _save_results(self, file_id: int, file_path: Path, results: List[Dict]) -> Dict:
        """保存OCR结果到数据库和文件"""
        output_data = {
            "total_pages": len(results),
            "successful_pages": 0,
            "failed_pages": 0,
            "output_files": []
        }
        
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # 保存JSON文件
        json_filename = f"{file_path.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        json_path = self.output_dir / json_filename
        
        with open(json_path, 'w', encoding='utf-8') as f:
            json.dump(results, f, ensure_ascii=False, indent=2)
        
        output_data["output_files"].append(str(json_path))
        
        # 保存到数据库
        for i, result in enumerate(results):
            page_num = result.get("page", i + 1)
            
            if "error" not in result:
                output_data["successful_pages"] += 1
                
                # 提取文本内容
                content_text = result.get("text", "")
                if not content_text and "content" in result:
                    content_text = json.dumps(result["content"], ensure_ascii=False)
                
                cursor.execute('''
                    INSERT INTO ocr_results 
                    (file_id, page_number, content_type, content_text, 
                     confidence, bounding_box, metadata)
                    VALUES (?, ?, ?, ?, ?, ?, ?)
                ''', (
                    file_id,
                    page_num,
                    result.get("format", "plain"),
                    content_text[:10000],  # 限制文本长度
                    result.get("confidence", 0.0),
                    json.dumps(result.get("bbox", {})),
                    json.dumps(result.get("metadata", {}))
                ))
            else:
                output_data["failed_pages"] += 1
        
        conn.commit()
        conn.close()
        
        return output_data
    
    def _log_file_complete(self, file_id: int, status: str, extra_data: Dict):
        """记录文件处理完成"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        end_time = datetime.now()
        
        cursor.execute('''
            UPDATE processed_files 
            SET status = ?, end_time = ?, 
                processing_time = ROUND((JULIANDAY(?) - JULIANDAY(start_time)) * 86400, 2),
                output_path = ?, error_message = ?
            WHERE id = ?
        ''', (
            status,
            end_time,
            end_time,
            json.dumps(extra_data.get("output_files", [])),
            extra_data.get("error"),
            file_id
        ))
        
        conn.commit()
        conn.close()
    
    def start_monitoring(self):
        """启动文件夹监控"""
        event_handler = FileHandler(self)
        observer = Observer()
        observer.schedule(event_handler, str(self.input_dir), recursive=False)
        observer.start()
        
        self.logger.info(f"开始监控文件夹: {self.input_dir}")
        
        try:
            while True:
                time.sleep(1)
        except KeyboardInterrupt:
            observer.stop()
            self.logger.info("监控已停止")
        
        observer.join()

class FileHandler(FileSystemEventHandler):
    """文件系统事件处理器"""
    
    def __init__(self, pipeline):
        self.pipeline = pipeline
        self.processing_files = set()
    
    def on_created(self, event):
        """当新文件创建时触发"""
        if not event.is_directory:
            file_path = Path(event.src_path)
            
            # 只处理支持的文件类型
            if file_path.suffix.lower() in ['.pdf', '.jpg', '.jpeg', '.png']:
                # 避免重复处理
                if file_path.name not in self.processing_files:
                    self.processing_files.add(file_path.name)
                    
                    # 等待文件完全写入
                    time.sleep(1)
                    
                    # 处理文件
                    self.pipeline.process_single_file(file_path)
                    
                    self.processing_files.remove(file_path.name)

这个流水线系统提供了完整的文件处理生命周期管理:

  1. 自动监控:检测新文件并自动处理
  2. 状态跟踪:记录每个文件的处理状态和结果
  3. 错误处理:失败的文件会被移动到专门目录
  4. 结果存储:同时保存到数据库和JSON文件

3.2 数据库设计与结果管理

为了有效管理大量的OCR结果,我设计了两个核心表:processed_files记录文件级别的信息,ocr_results存储页面级别的识别结果。

文件处理记录表结构:

字段名 类型 描述
id INTEGER 主键,自增ID
filename TEXT 原始文件名
filepath TEXT 原始文件路径
file_size INTEGER 文件大小(字节)
page_count INTEGER 总页数
status TEXT 处理状态(processing/completed/failed)
start_time TIMESTAMP 开始处理时间
end_time TIMESTAMP 结束处理时间
processing_time REAL 处理耗时(秒)
error_message TEXT 错误信息(如果有)
output_path TEXT 输出文件路径
created_at TIMESTAMP 记录创建时间

OCR结果表结构:

字段名 类型 描述
id INTEGER 主键,自增ID
file_id INTEGER 关联的文件ID
page_number INTEGER 页面编号
content_type TEXT 内容类型(text/table等)
content_text TEXT 识别出的文本内容
confidence REAL 识别置信度
bounding_box TEXT 边界框坐标(JSON格式)
metadata TEXT 其他元数据(JSON格式)

这种设计允许我们:

  • 快速查询特定文件的处理状态
  • 统计处理成功率和平均处理时间
  • 根据置信度筛选高质量的识别结果
  • 按页面或内容类型进行高级查询

4. 高级功能与性能优化

当基本流水线搭建完成后,我们可以进一步优化性能和添加高级功能。

4.1 并发处理与资源管理

处理大量文件时,合理的并发控制至关重要。我实现了一个基于线程池的批处理调度器,能够根据系统资源动态调整并发度。

import concurrent.futures
import psutil
import os
from queue import Queue
from threading import Lock

class BatchProcessor:
    """批量处理器,支持智能并发控制"""
    
    def __init__(self, pipeline, max_concurrent=None):
        self.pipeline = pipeline
        self.max_concurrent = max_concurrent or self._calculate_optimal_concurrency()
        self.task_queue = Queue()
        self.result_queue = Queue()
        self.lock = Lock()
        
        self.logger = logging.getLogger(__name__)
    
    def _calculate_optimal_concurrency(self):
        """根据系统资源计算最优并发数"""
        cpu_count = os.cpu_count() or 4
        memory_gb = psutil.virtual_memory().total / (1024**3)
        
        # 基于CPU核心数和内存的启发式算法
        base_concurrency = min(cpu_count, 8)  # 最多8个CPU核心
        
        if memory_gb < 4:
            return max(1, base_concurrency // 2)
        elif memory_gb < 8:
            return base_concurrency
        elif memory_gb < 16:
            return min(base_concurrency * 2, 16)
        else:
            return min(base_concurrency * 3, 32)
    
    def process_batch(self, file_paths, callback=None):
        """批量处理文件"""
        # 将文件路径加入队列
        for file_path in file_paths:
            self.task_queue.put(Path(file_path))
        
        total_files = self.task_queue.qsize()
        processed_files = 0
        failed_files = 0
        
        self.logger.info(f"开始批量处理 {total_files} 个文件,并发数: {self.max_concurrent}")
        
        with concurrent.futures.ThreadPoolExecutor(
            max_workers=self.max_concurrent,
            thread_name_prefix="ocr_worker"
        ) as executor:
            # 提交初始任务
            futures = []
            for _ in range(min(self.max_concurrent, total_files)):
                if not self.task_queue.empty():
                    file_path = self.task_queue.get()
                    future = executor.submit(self._process_file, file_path)
                    futures.append(future)
            
            # 处理完成的任务并提交新任务
            while futures:
                # 等待任意任务完成
                done, not_done = concurrent.futures.wait(
                    futures, return_when=concurrent.futures.FIRST_COMPLETED
                )
                
                for future in done:
                    try:
                        result = future.result(timeout=1)
                        if result["status"] == "success":
                            processed_files += 1
                        else:
                            failed_files += 1
                            self.logger.warning(f"文件处理失败: {result.get('filename')}")
                        
                        # 调用回调函数
                        if callback:
                            callback(result)
                        
                    except Exception as e:
                        failed_files += 1
                        self.logger.error(f"任务执行异常: {e}")
                    
                    # 提交新任务
                    if not self.task_queue.empty():
                        file_path = self.task_queue.get()
                        new_future = executor.submit(self._process_file, file_path)
                        not_done.add(new_future)
                
                futures = list(not_done)
                
                # 更新进度
                if (processed_files + failed_files) % 10 == 0:
                    progress = (processed_files + failed_files) / total_files * 100
                    self.logger.info(
                        f"处理进度: {progress:.1f}% "
                        f"({processed_files + failed_files}/{total_files})"
                    )
        
        self.logger.info(
            f"批量处理完成: 成功 {processed_files}, 失败 {failed_files}, "
            f"总计 {total_files}"
        )
        
        return {
            "total": total_files,
            "successful": processed_files,
            "failed": failed_files,
            "success_rate": processed_files / total_files if total_files > 0 else 0
        }
    
    def _process_file(self, file_path):
        """处理单个文件(工作线程函数)"""
        try:
            result = self.pipeline.process_single_file(file_path)
            result["filename"] = file_path.name
            return result
        except Exception as e:
            return {
                "status": "failed",
                "filename": file_path.name,
                "error": str(e)
            }
    
    def process_directory(self, directory_path, file_pattern="*.pdf", recursive=False):
        """处理目录中的所有匹配文件"""
        directory = Path(directory_path)
        
        if recursive:
            file_paths = list(directory.rglob(file_pattern))
        else:
            file_paths = list(directory.glob(file_pattern))
        
        self.logger.info(f"在目录 {directory} 中找到 {len(file_paths)} 个文件")
        
        return self.process_batch(file_paths)

这个批量处理器有几个关键特性:

  1. 智能并发控制:根据CPU核心数和内存自动调整并发度
  2. 进度跟踪:实时显示处理进度和统计信息
  3. 错误隔离:单个文件处理失败不会影响其他文件
  4. 资源监控:可以扩展添加内存和CPU使用率监控

4.2 结果导出与数据转换

OCR识别的结果需要能够方便地导入到其他系统。我实现了多种导出格式的支持。

class ResultExporter:
    """OCR结果导出器"""
    
    def __init__(self, db_path):
        self.db_path = db_path
        self.conn = sqlite3.connect(db_path)
    
    def export_to_excel(self, output_path, query_params=None):
        """导出为Excel文件"""
        import pandas as pd
        
        # 构建查询
        query = '''
        SELECT 
            pf.filename,
            pf.status,
            pf.processing_time,
            orc.page_number,
            orc.content_type,
            orc.confidence,
            orc.content_text
        FROM processed_files pf
        LEFT JOIN ocr_results orc ON pf.id = orc.file_id
        WHERE 1=1
        '''
        
        params = []
        if query_params:
            if query_params.get('status'):
                query += " AND pf.status = ?"
                params.append(query_params['status'])
            if query_params.get('min_confidence'):
                query += " AND orc.confidence >= ?"
                params.append(query_params['min_confidence'])
            if query_params.get('start_date'):
                query += " AND pf.created_at >= ?"
                params.append(query_params['start_date'])
        
        query += " ORDER BY pf.created_at DESC, orc.page_number"
        
        # 执行查询
        df = pd.read_sql_query(query, self.conn, params=params)
        
        # 创建Excel写入器
        with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
            # 写入详细数据
            df.to_excel(writer, sheet_name='OCR结果', index=False)
            
            # 创建汇总统计
            summary_data = []
            
            # 文件统计
            file_stats = df.groupby('filename').agg({
                'page_number': 'count',
                'confidence': 'mean'
            }).reset_index()
            file_stats.columns = ['文件名', '总页数', '平均置信度']
            file_stats.to_excel(writer, sheet_name='文件统计', index=False)
            
            # 整体统计
            overall_stats = pd.DataFrame([{
                '总文件数': df['filename'].nunique(),
                '总页数': df['page_number'].count(),
                '平均置信度': df['confidence'].mean(),
                '平均处理时间': df['processing_time'].mean() if 'processing_time' in df.columns else 0
            }])
            overall_stats.to_excel(writer, sheet_name='整体统计', index=False)
        
        return output_path
    
    def export_to_csv(self, output_dir, split_by_file=False):
        """导出为CSV文件"""
        import pandas as pd
        from pathlib import Path
        
        output_dir = Path(output_dir)
        output_dir.mkdir(exist_ok=True)
        
        if split_by_file:
            # 按文件分割导出
            files_query = "SELECT DISTINCT filename, id FROM processed_files WHERE status = 'completed'"
            files_df = pd.read_sql_query(files_query, self.conn)
            
            for _, row in files_df.iterrows():
                file_query = '''
                SELECT page_number, content_type, content_text, confidence
                FROM ocr_results
                WHERE file_id = ?
                ORDER BY page_number
                '''
                file_df = pd.read_sql_query(file_query, self.conn, params=[row['id']])
                
                # 清理文件名
                safe_filename = "".join(c for c in row['filename'] if c.isalnum() or c in (' ', '.', '_')).rstrip()
                csv_path = output_dir / f"{safe_filename}.csv"
                file_df.to_csv(csv_path, index=False, encoding='utf-8-sig')
            
            return f"已导出 {len(files_df)} 个CSV文件到 {output_dir}"
        
        else:
            # 导出为单个CSV
            query = '''
            SELECT 
                pf.filename,
                orc.page_number,
                orc.content_type,
                orc.content_text,
                orc.confidence,
                orc.bounding_box
            FROM processed_files pf
            JOIN ocr_results orc ON pf.id = orc.file_id
            WHERE pf.status = 'completed'
            ORDER BY pf.filename, orc.page_number
            '''
            
            df = pd.read_sql_query(query, self.conn)
            output_path = output_dir / "all_ocr_results.csv"
            df.to_csv(output_path, index=False, encoding='utf-8-sig')
            
            return str(output_path)
    
    def export_to_json(self, output_path, include_metadata=True):
        """导出为JSON文件"""
        import json
        
        query = '''
        SELECT 
            pf.filename,
            pf.file_size,
            pf.page_count,
            pf.processing_time,
            json_group_array(
                json_object(
                    'page', orc.page_number,
                    'content_type', orc.content_type,
                    'content', orc.content_text,
                    'confidence', orc.confidence,
                    'bounding_box', json(orc.bounding_box)
                )
            ) as pages
        FROM processed_files pf
        LEFT JOIN ocr_results orc ON pf.id = orc.file_id
        WHERE pf.status = 'completed'
        GROUP BY pf.id
        ORDER BY pf.created_at DESC
        '''
        
        cursor = self.conn.cursor()
        cursor.execute(query)
        rows = cursor.fetchall()
        
        # 构建结构化数据
        result = {
            "export_time": datetime.now().isoformat(),
            "total_files": len(rows),
            "files": []
        }
        
        for row in rows:
            file_data = {
                "filename": row[0],
                "file_size": row[1],
                "page_count": row[2],
                "processing_time": row[3],
                "pages": json.loads(row[4]) if row[4] else []
            }
            result["files"].append(file_data)
        
        # 写入文件
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(result, f, ensure_ascii=False, indent=2)
        
        return output_path
    
    def generate_report(self, output_path):
        """生成处理报告"""
        import pandas as pd
        
        # 获取统计信息
        stats_query = '''
        SELECT 
            COUNT(*) as total_files,
            SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successful_files,
            SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_files,
            AVG(processing_time) as avg_processing_time,
            MIN(created_at) as first_processed,
            MAX(created_at) as last_processed
        FROM processed_files
        '''
        
        stats_df = pd.read_sql_query(stats_query, self.conn)
        
        # 置信度分布
        confidence_query = '''
        SELECT 
            CASE 
                WHEN confidence >= 0.9 THEN '高 (≥0.9)'
                WHEN confidence >= 0.7 THEN '中 (0.7-0.9)'
                WHEN confidence >= 0.5 THEN '低 (0.5-0.7)'
                ELSE '极低 (<0.5)'
            END as confidence_level,
            COUNT(*) as page_count,
            ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM ocr_results), 2) as percentage
        FROM ocr_results
        GROUP BY confidence_level
        ORDER BY confidence_level DESC
        '''
        
        confidence_df = pd.read_sql_query(confidence_query, self.conn)
        
        # 创建报告
        report = f"""
        OCR处理报告
        ===========
        
        生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
        
        总体统计
        --------
        - 总处理文件数: {stats_df.iloc[0]['total_files']}
        - 成功处理文件数: {stats_df.iloc[0]['successful_files']}
        - 处理失败文件数: {stats_df.iloc[0]['failed_files']}
        - 成功率: {stats_df.iloc[0]['successful_files'] / stats_df.iloc[0]['total_files'] * 100:.1f}%
        - 平均处理时间: {stats_df.iloc[0]['avg_processing_time']:.2f}秒
        - 处理时间范围: {stats_df.iloc[0]['first_processed']} 至 {stats_df.iloc[0]['last_processed']}
        
        置信度分布
        ---------
        """
        
        for _, row in confidence_df.iterrows():
            report += f"- {row['confidence_level']}: {row['page_count']}页 ({row['percentage']}%)\n"
        
        # 写入报告文件
        with open(output_path, 'w', encoding='utf-8') as f:
            f.write(report)
        
        return output_path

这个导出器提供了多种输出格式:

  • Excel:适合数据分析师使用,包含多个工作表
  • CSV:简单通用,易于导入各种系统
  • JSON:结构化数据,适合程序化处理
  • 报告:人类可读的汇总信息

4.3 定时任务与自动化调度

对于持续性的文档处理需求,我们可以使用系统的定时任务功能来自动运行OCR流水线。

#!/bin/bash
# ocr_pipeline.sh - OCR批量处理脚本

# 配置
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="$SCRIPT_DIR/logs/ocr_$(date +%Y%m%d_%H%M%S).log"
INPUT_DIR="$SCRIPT_DIR/input"
PROCESSED_DIR="$SCRIPT_DIR/processed"
FAILED_DIR="$SCRIPT_DIR/failed"
MAX_CONCURRENT=4

# 创建日志目录
mkdir -p "$SCRIPT_DIR/logs"

# 日志函数
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

# 检查输入目录
if [ ! -d "$INPUT_DIR" ]; then
    log "错误:输入目录不存在: $INPUT_DIR"
    exit 1
fi

# 检查是否有文件需要处理
file_count=$(find "$INPUT_DIR" -name "*.pdf" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.png" | wc -l)

if [ "$file_count" -eq 0 ]; then
    log "没有找到需要处理的文件"
    exit 0
fi

log "开始处理 $file_count 个文件"

# 激活Python虚拟环境
if [ -f "$SCRIPT_DIR/ocr_pipeline/bin/activate" ]; then
    source "$SCRIPT_DIR/ocr_pipeline/bin/activate"
elif [ -f "$SCRIPT_DIR/venv/bin/activate" ]; then
    source "$SCRIPT_DIR/venv/bin/activate"
fi

# 运行Python处理脚本
python "$SCRIPT_DIR/run_pipeline.py" \
    --input-dir "$INPUT_DIR" \
    --processed-dir "$PROCESSED_DIR" \
    --failed-dir "$FAILED_DIR" \
    --max-concurrent "$MAX_CONCURRENT" \
    --log-file "$LOG_FILE"

if [ $? -eq 0 ]; then
    log "处理完成"
else
    log "处理过程中出现错误"
    exit 1
fi

# 清理旧的日志文件(保留最近7天)
find "$SCRIPT_DIR/logs" -name "ocr_*.log" -mtime +7 -delete

# 发送通知(可选)
# 可以在这里添加邮件通知、Slack通知等

对应的Python启动脚本:

# run_pipeline.py
import argparse
import sys
from pathlib import Path

def main():
    parser = argparse.ArgumentParser(description='OCR批量处理流水线')
    parser.add_argument('--input-dir', required=True, help='输入目录路径')
    parser.add_argument('--processed-dir', default='./processed', help='已处理文件目录')
    parser.add_argument('--failed-dir', default='./failed', help='失败文件目录')
    parser.add_argument('--max-concurrent', type=int, default=4, help='最大并发数')
    parser.add_argument('--log-file', help='日志文件路径')
    parser.add_argument('--db-path', default='./ocr_pipeline.db', help='数据库路径')
    
    args = parser.parse_args()
    
    # 导入并运行流水线
    from ocr_pipeline import OCRPipeline, BatchProcessor
    
    try:
        # 初始化流水线
        pipeline = OCRPipeline(
            input_dir=args.input_dir,
            processed_dir=args.processed_dir,
            failed_dir=args.failed_dir,
            db_path=args.db_path
        )
        
        # 初始化批量处理器
        processor = BatchProcessor(pipeline, max_concurrent=args.max_concurrent)
        
        # 处理目录中的所有文件
        result = processor.process_directory(args.input_dir)
        
        print(f"处理完成: {result['successful']}成功, {result['failed']}失败")
        
        if result['failed'] > 0:
            sys.exit(1)  # 如果有失败的文件,返回非零退出码
        else:
            sys.exit(0)
            
    except Exception as e:
        print(f"运行错误: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()

然后设置cron定时任务:

# 每天凌晨2点运行
0 2 * * * /path/to/ocr_pipeline.sh >> /path/to/cron.log 2>&1

# 每30分钟运行一次
*/30 * * * * /path/to/ocr_pipeline.sh >> /path/to/cron.log 2>&1

# 工作日每小时运行
0 9-18 * * 1-5 /path/to/ocr_pipeline.sh >> /path/to/cron.log 2>&1

5. 错误处理与监控告警

在生产环境中,完善的错误处理和监控是必不可少的。我实现了一个监控系统,能够实时跟踪处理状态并在出现问题时发送告警。

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import requests as http_requests
import time

class PipelineMonitor:
    """流水线监控器"""
    
    def __init__(self, db_path, config):
        self.db_path = db_path
        self.config = config
        self.alert_cooldown = {}  # 告警冷却时间
        
        # 监控指标
        self.metrics = {
            'total_files': 0,
            'success_rate': 0,
            'avg_processing_time': 0,
            'recent_errors': [],
            'system_status': 'healthy'
        }
    
    def check_health(self):
        """检查系统健康状态"""
        checks = []
        
        # 检查数据库连接
        db_status = self._check_database()
        checks.append(('database', db_status))
        
        # 检查OCR服务
        ocr_status = self._check_ocr_service()
        checks.append(('ocr_service', ocr_status))
        
        # 检查磁盘空间
        disk_status = self._check_disk_space()
        checks.append(('disk_space', disk_status))
        
        # 检查处理队列
        queue_status = self._check_processing_queue()
        checks.append(('processing_queue', queue_status))
        
        # 更新系统状态
        failed_checks = [name for name, status in checks if status != 'healthy']
        
        if failed_checks:
            self.metrics['system_status'] = 'degraded'
            if len(failed_checks) > 2:
                self.metrics['system_status'] = 'critical'
            
            # 发送告警
            self._send_alert(failed_checks)
        else:
            self.metrics['system_status'] = 'healthy'
        
        return checks
    
    def _check_database(self):
        """检查数据库连接和状态"""
        try:
            import sqlite3
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 检查表是否存在
            cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
            tables = cursor.fetchall()
            
            if not tables:
                return 'critical'
            
            # 检查最近的处理记录
            cursor.execute('''
                SELECT status, COUNT(*) 
                FROM processed_files 
                WHERE created_at > datetime('now', '-1 hour')
                GROUP BY status
            ''')
            
            recent_stats = cursor.fetchall()
            
            # 计算失败率
            total = sum(count for _, count in recent_stats)
            failed = sum(count for status, count in recent_stats if status == 'failed')
            
            if total > 0:
                failure_rate = failed / total
                if failure_rate > 0.3:  # 30%失败率阈值
                    return 'degraded'
            
            conn.close()
            return 'healthy'
            
        except Exception as e:
            print(f"数据库检查失败: {e}")
            return 'critical'
    
    def _check_ocr_service(self):
        """检查OCR服务可用性"""
        try:
            response = http_requests.get(
                f"{self.config['ocr_service_url']}/health",
                timeout=5
            )
            
            if response.status_code == 200:
                return 'healthy'
            else:
                return 'degraded'
                
        except http_requests.exceptions.RequestException:
            return 'critical'
    
    def _check_disk_space(self):
        """检查磁盘空间"""
        import shutil
        
        total, used, free = shutil.disk_usage(self.config['data_dir'])
        free_percent = free / total
        
        if free_percent < 0.1:  # 少于10%空闲空间
            return 'critical'
        elif free_percent < 0.2:  # 少于20%空闲空间
            return 'degraded'
        else:
            return 'healthy'
    
    def _check_processing_queue(self):
        """检查处理队列状态"""
        try:
            import sqlite3
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 检查长时间运行的任务
            cursor.execute('''
                SELECT COUNT(*) 
                FROM processed_files 
                WHERE status = 'processing' 
                AND start_time < datetime('now', '-30 minutes')
            ''')
            
            stuck_tasks = cursor.fetchone()[0]
            
            if stuck_tasks > 5:
                return 'degraded'
            elif stuck_tasks > 10:
                return 'critical'
            
            conn.close()
            return 'healthy'
            
        except Exception:
            return 'unknown'
    
    def _send_alert(self, failed_checks):
        """发送告警通知"""
        alert_key = ','.join(sorted(failed_checks))
        
        # 检查冷却时间
        current_time = time.time()
        last_alert = self.alert_cooldown.get(alert_key, 0)
        
        if current_time - last_alert < 3600:  # 1小时冷却
            return
        
        # 构建告警消息
        subject = f"OCR流水线告警 - {self.metrics['system_status'].upper()}"
        
        message = f"""
        系统状态: {self.metrics['system_status']}
        告警时间: {time.strftime('%Y-%m-%d %H:%M:%S')}
        
        失败的检查项:
        """
        
        for check_name, status in failed_checks:
            message += f"- {check_name}: {status}\n"
        
        message += f"""
        
        最近指标:
        - 总处理文件数: {self.metrics['total_files']}
        - 成功率: {self.metrics['success_rate']:.1%}
        - 平均处理时间: {self.metrics['avg_processing_time']:.2f}秒
        
        请及时检查系统状态。
        """
        
        # 发送邮件告警
        if self.config.get('email_alerts', {}).get('enabled'):
            self._send_email_alert(subject, message)
        
        # 发送Webhook告警
        if self.config.get('webhook_url'):
            self._send_webhook_alert(subject, message)
        
        # 更新冷却时间
        self.alert_cooldown[alert_key] = current_time
    
    def _send_email_alert(self, subject, message):
        """发送邮件告警"""
        try:
            email_config = self.config['email_alerts']
            
            msg = MIMEMultipart()
            msg['From'] = email_config['from']
            msg['To'] = ', '.join(email_config['to'])
            msg['Subject'] = subject
            
            msg.attach(MIMEText(message, 'plain'))
            
            with smtplib.SMTP(email_config['smtp_server'], email_config['smtp_port']) as server:
                if email_config.get('use_tls'):
                    server.starttls()
                if email_config.get('username'):
                    server.login(email_config['username'], email_config['password'])
                server.send_message(msg)
                
            print(f"邮件告警已发送: {subject}")
            
        except Exception as e:
            print(f"发送邮件告警失败: {e}")
    
    def _send_webhook_alert(self, subject, message):
        """发送Webhook告警"""
        try:
            payload = {
                "text": f"*{subject}*\n\n{message}",
                "username": "OCR Pipeline Monitor",
                "icon_emoji": ":warning:"
            }
            
            response = http_requests.post(
                self.config['webhook_url'],
                json=payload,
                timeout=10
            )
            
            if response.status_code == 200:
                print(f"Webhook告警已发送: {subject}")
            else:
                print(f"Webhook告警发送失败: {response.status_code}")
                
        except Exception as e:
            print(f"发送Webhook告警失败: {e}")
    
    def update_metrics(self):
        """更新监控指标"""
        try:
            import sqlite3
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 总文件数
            cursor.execute("SELECT COUNT(*) FROM processed_files")
            self.metrics['total_files'] = cursor.fetchone()[0]
            
            # 成功率
            cursor.execute('''
                SELECT 
                    SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) * 1.0 / COUNT(*)
                FROM processed_files
                WHERE status IN ('completed', 'failed')
            ''')
            success_rate = cursor.fetchone()[0]
            self.metrics['success_rate'] = success_rate or 0
            
            # 平均处理时间
            cursor.execute('''
                SELECT AVG(processing_time) 
                FROM processed_files 
                WHERE status = 'completed' 
                AND processing_time IS NOT NULL
            ''')
            avg_time = cursor.fetchone()[0]
            self.metrics['avg_processing_time'] = avg_time or 0
            
            # 最近错误
            cursor.execute('''
                SELECT filename, error_message, created_at
                FROM processed_files
                WHERE status = 'failed'
                ORDER BY created_at DESC
                LIMIT 5
            ''')
            self.metrics['recent_errors'] = cursor.fetchall()
            
            conn.close()
            
        except Exception as e:
            print(f"更新指标失败: {e}")
    
    def generate_dashboard(self):
        """生成监控仪表板"""
        self.update_metrics()
        
        dashboard = f"""
        OCR流水线监控仪表板
        ===================
        
        更新时间: {time.strftime('%Y-%m-%d %H:%M:%S')}
        系统状态: {self.metrics['system_status'].upper()}
        
        关键指标
        -------
        - 总处理文件数: {self.metrics['total_files']:,}
        - 处理成功率: {self.metrics['success_rate']:.1%}
        - 平均处理时间: {self.metrics['avg_processing_time']:.2f}秒
        
        最近错误 ({len(self.metrics['recent_errors'])}个)
        ---------
        """
        
        for filename, error, timestamp in self.metrics['recent_errors']:
            dashboard += f"- {timestamp}: {filename} - {error[:50]}...\n"
        
        # 健康检查结果
        health_checks = self.check_health()
        dashboard += "\n健康检查\n--------\n"
        
        for check_name, status in health_checks:
            status_icon = "✅" if status == 'healthy' else "⚠️" if status == 'degraded' else "❌"
            dashboard += f"{status_icon} {check_name}: {status}\n"
        
        return dashboard
    
    def run_monitoring_loop(self, interval=300):
        """运行监控循环"""
        print("启动监控循环...")
        print(f"检查间隔: {interval}秒")
        
        try:
            while True:
                # 更新指标
                self.update_metrics()
                
                # 检查健康状态
                health_checks = self.check_health()
                
                # 生成并显示仪表板
                dashboard = self.generate_dashboard()
                print("\n" + "="*50)
                print(dashboard)
                print("="*50 + "\n")
                
                # 等待下一次检查
                time.sleep(interval)
                
        except KeyboardInterrupt:
            print("\n监控循环已停止")

这个监控系统提供了:

  1. 健康检查:定期检查各个组件的状态
  2. 指标收集:跟踪关键性能指标
  3. 告警通知:在出现问题时发送通知
  4. 仪表板:实时显示系统状态

6. 实际部署与运维建议

基于我在多个项目中的部署经验,这里分享一些实际运维中的建议和最佳实践。

6.1 部署架构选择

根据处理规模的不同,我推荐三种部署架构:

1. 单机部署(适合中小规模)

┌─────────────────────────────────────────┐
│             单台服务器                   │
├─────────────────────────────────────────┤
│  ┌─────────┐  ┌─────────┐  ┌─────────┐ │
│  │DeepSeek │  │   OCR   │  │  数据库  │ │
│  │ OCR服务  │  │ 流水线  │  │ (SQLite)│ │
│  └─────────┘  └─────────┘  └─────────┘ │
└─────────────────────────────────────────┘

适用场景:日处理量 < 1000页,对可用性要求不高

2. 高可用部署(适合生产环境)

┌─────────────────┐    ┌─────────────────┐
│   负载均衡器     │    │   负载均衡器     │
│    (Nginx)      │    │    (Nginx)      │
└────────┬────────┘    └────────┬────────┘
         │                       │
┌────────▼────────┐    ┌────────▼────────┐
│   OCR处理节点1   │    │   OCR处理节点2   │
├─────────────────┤    ├─────────────────┤
│ • DeepSeek OCR  │    │ • DeepSeek OCR  │
│ • 本地流水线     │    │ • 本地流水线     │
└────────┬────────┘    └────────┬────────┘
         │                       │
    ┌────┴───────────────────────┴────┐
    │        共享存储 (NFS/S3)         │
    ├──────────────────────────────────┤
    │        中央数据库 (MySQL)        │
    └──────────────────────────────────┘

适用场景:日处理量 1000-10000页,需要高可用性

3. 容器化部署(适合云环境)

# docker-compose.yml
version: '3.8'

services:
  deepseek-ocr:
    image: registry.cn-hangzhou.aliyuncs.com/csdn-mirror/deepseek-ocr2:latest
    deploy:
      replicas: 2
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    ports:
      - "7860:7860"
    volumes:
      - ./models:/app/models
      - ./output:/app/output
    environment:
      - CUDA_VISIBLE_DEVICES=0
    networks:
      - ocr-network

  ocr-pipeline:
    build: .
    deploy:
      replicas: 3
    depends_on:
      - deepseek-ocr
    volumes:
      - ./input:/app/input
      - ./processed:/app/processed
      - ./failed:/app/failed
      - ./data:/app/data
    environment:
      - OCR_SERVICE_URL=http://deepseek-ocr:7860
      - DATABASE_URL=mysql://user:password@mysql/ocr_db
    networks:
      - ocr-network

  mysql:
    image: mysql:8.0
    environment:
      - MYSQL_ROOT_PASSWORD=your_password
      - MYSQL_DATABASE=ocr_db
    volumes:
      - mysql-data:/var/lib/mysql
    networks:
      - ocr-network

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data
    networks:
      - ocr-network

  monitor:
    build: ./monitor
    depends_on:
      - ocr-pipeline
      - mysql
    volumes:
      - ./logs:/app/logs
    networks:
      - ocr-network

volumes:
  mysql-data:
  redis-data:

networks:
  ocr-network:
    driver: bridge

6.2 性能优化配置

根据不同的硬件配置,我总结了一些优化建议:

GPU配置优化:

# config/performance.py
PERFORMANCE_PROFILES = {
    "low": {
        "batch_size": 1,
        "max_workers": 2,
        "image_quality": 85,
        "enable_cache": False,
        "preprocess_level": 1
    },
    "medium": {
        "batch_size": 3,
        "max_workers": 4,
        "image_quality": 90,
        "enable_cache": True,
        "preprocess_level": 2
    },
    "high": {
        "batch_size": 5,
        "max_workers": 8,
        "image_quality": 95,
        "enable_cache": True,
        "preprocess_level": 3,
        "gpu_memory_utilization": 0.95
    },
    "custom": {
        # 根据实际硬件调整
        "batch_size": None,  # 自动计算
        "max_workers": None,  # 自动计算
        "image_quality": 92,
        "enable_cache": True,
        "preprocess_level": 2
    }
}

def auto_detect_profile():
    """自动检测硬件并选择性能配置"""
    import psutil
    import subprocess
    
    # 检测GPU
    try:
        gpu_info = subprocess.check_output(
            ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
            text=True
        ).strip()
        gpu_memory_mb = int(gpu_info)
    except:
        gpu_memory_mb = 0
    
    # 检测CPU和内存
    cpu_count = psutil.cpu_count(logical=False)
    memory_gb = psutil.virtual_memory().total / (1024**3)
    
    if gpu_memory_mb >= 24000:  # 24GB+ GPU
        profile = "high"
        config = PERFORMANCE_PROFILES[profile].copy()
        
        # 根据GPU内存调整batch_size
        if gpu_memory_mb >= 48000:  # 48GB+ GPU
            config["batch_size"] = 10
        elif gpu_memory_mb >= 32000:  # 32GB GPU
            config["batch_size"] = 8
        else:  # 24GB GPU
            config["batch_size"] = 5
            
    elif gpu_memory_mb >= 8000:  # 8GB+ GPU
        profile = "medium"
        config = PERFORMANCE_PROFILES[profile].copy()
        config["batch_size"] = 3
        
    elif cpu_count >= 8 and memory_gb >= 32:  # 强CPU配置
        profile = "medium"
        config = PERFORMANCE_PROFILES[profile].copy()
        config["max_workers"] = min(cpu_count, 12)
        
    else:  # 低配环境
        profile = "low"
        config = PERFORMANCE_PROFILES[profile].copy()
    
    return profile, config

6.3 故障排查清单

在实际运行中可能会遇到各种问题,这是我的排查清单:

问题1:处理速度突然变慢

检查步骤:
1. 查看系统资源使用情况(CPU、内存、GPU)
   $ top / htop / nvidia-smi
2. 检查磁盘空间
   $ df -h /path/to/data
3. 检查数据库性能
   $ sqlite3 ocr_pipeline.db "PRAGMA integrity_check;"
4. 查看日志文件中的错误信息
   $ tail -f ocr_pipeline.log

问题2:OCR识别准确率下降

可能原因及解决方案:
1. 图像质量问题
   - 启用图像增强:config['preprocess_level'] = 2
   - 提高扫描DPI:至少300 DPI
   - 使用去噪算法预处理

2. 模型缓存问题
   - 清理模型缓存:rm -rf ~/.cache/deepseek-ocr
   - 重启OCR服务:docker restart deepseek-ocr2

3. 文档类型变化
   - 调整识别参数:针对表格/文字/混合类型使用不同配置
   - 使用专用模型:如有特殊需求可考虑微调

问题3:系统内存不足

优化策略:
1. 调整批处理大小
   config['batch_size'] = 2  # 减少同时处理的页数

2. 启用流式处理
   - 逐页处理而非整批处理
   - 及时释放内存:del processed_images

3. 使用内存映射文件
   with open('large_file.pdf', 'rb') as f:
       # 使用mmap处理大文件

4. 增加交换空间(临时解决方案)
   $ sudo fallocate -l 4G /swapfile
   $ sudo chmod 600 /swapfile
   $ sudo mkswap /swapfile
   $ sudo swapon /swapfile

6.4 安全与权限管理

在生产环境中部署时,安全考虑必不可少:

# security/config.py
import os
from pathlib import Path

class SecurityConfig:
    """安全配置管理"""
    
    def __init__(self):
        self.base_dir = Path(os.getenv('OCR_BASE_DIR', '/var/ocr'))
        self.ensure_directories()
    
    def ensure_directories(self):
        """确保目录存在并设置正确权限"""
        directories = [
            self.base_dir / 'input',
            self.base_dir / 'processed',
            self.base_dir / 'failed',
            self.base_dir / 'output',
            self.base_dir / 'logs',
            self.base_dir / 'temp'
        ]
        
        for directory in directories:
            directory.mkdir(parents=True, exist_ok=True)
            
            # 设置目录权限(仅所有者可写)
            os.chmod(directory, 0o755)
    
    def validate_file(self, file_path):
        """验证上传文件的安全性"""
        path = Path(file_path)
        
        # 检查文件类型
        allowed_extensions = {'.pdf', '.jpg', '.jpeg', '.png', '.tiff', '.bmp'}
        if path.suffix.lower() not in allowed_extensions:
            raise ValueError(f"不支持的文件类型: {path.suffix}")
        
        # 检查文件大小(限制为100MB)
        max_size = 100 * 1024 * 1024  # 100MB
        if path.stat().st_size > max_size:
            raise ValueError(f"文件过大: {path.stat().st_size}字节")
        
        # 检查文件内容(简单魔术字节验证)
        with open(path, 'rb') as f:
            header = f.read(4)
            
        # PDF文件以%PDF开头
        if path.suffix.lower() == '.pdf' and not header.startswith(b'%PDF'):
            raise ValueError("无效的PDF文件")
        
        # 图像文件检查
        image_headers = {
            b'\xff\xd8\xff': '.jpg',
            b'\x89PNG': '.png',
            b'II*\x00': '.tiff',
            b'MM\x00*': '.tiff',
            b'BM': '.bmp'
        }
        
        for magic, ext in image_headers.items():
            if header.startswith(magic) and path.suffix.lower() != ext:
                raise ValueError(f"文件扩展名与实际类型不匹配")
        
        return True
    
    def sanitize_filename(self, filename):
        """清理文件名,防止路径遍历攻击"""
        import re
        
        # 移除危险字符
        safe_name = re.sub(r'[^\w\s\-\.]', '', filename)
        
        # 限制长度
        safe_name = safe_name[:255]
        
        # 确保不是隐藏文件
        if safe_name.startswith('.'):
            safe_name = 'file_' + safe_name[1:]
        
        return safe_name
    
    def setup_https(self):
        """配置HTTPS(如果使用公网访问)"""
        # 这里可以集成Let's Encrypt或自定义证书
        pass

这套自动化OCR流水线系统在我负责的几个文档数字化项目中已经稳定运行了半年多,累计处理了超过50万页文档。最关键的收获不是技术实现本身,而是如何将AI能力真正融入业务流程——从最初的手动操作到现在的全自动处理,效率提升了至少20倍。如果你也面临大量文档处理的需求,建议先从简单的脚本开始,逐步迭代完善,最终构建出适合自己业务场景的完整解决方案。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐