华为昇腾服务器部署Paddle OCR VL模型及推理服务

Paddle OCR VL 1.5是百度飞桨推出的0.9B超紧凑多模态文档解析模型,其核心特色包括:全球首个支持异形框定位,能精准处理倾斜、弯折、拍照畸变等真实场景;在OmniDocBench v1.5评测中达到94.5%高精度;集成文本定位、印章识别、表格解析等多任务一体化能力。

我在昇腾服务器上部署了这个模型,并开启了推理服务,使得用户可以上传图像或者多页PDF文件,并自动对多页文件进行整合后输出。

按照VLLM Ascend官网的介绍,通过以下命令启动Paddle OCR VL的服务

bash 复制代码
docker run --rm \
    --name paddleocr_vl \
    --shm-size=64g \
    --net=host \
    --device /dev/davinci6 \
    --device /dev/davinci_manager \
    --device /dev/devmm_svm \
    --device /dev/hisi_hdc \
    -v /usr/local/dcmi:/usr/local/dcmi \
    -v /usr/local/Ascend/driver/tools/hccn_tool:/usr/local/Ascend/driver/tools/hccn_tool \
    -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
    -v /usr/local/Ascend/driver/lib64:/usr/local/Ascend/driver/lib64 \
    -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \
    -v /etc/ascend_install.info:/etc/ascend_install.info \
    -v /Public_dir/PaddleOCR-VL-1.5:/model \
    -itd m.daocloud.io/quay.io/ascend/vllm-ascend:v0.14.0rc1 bash

然后进入容器,通过以下命令启动。因为这个模型的参数量很小,所以可以把gpu-memory-utilization的值设小一点

bash 复制代码
nohup vllm serve /model --host 123.123.123.123 --port 9999 --served-model-name PaddleOCR-VL-1.5-0.9B --mm-processor-cache-gb 0 --compilation-config '{"cudagraph_mode": "FULL_DECODE_ONLY"}'  --trust-remote-code --gpu-memory-utilization 0.4 &

启动之后即可即可通过以下的程序进行调用。

python 复制代码
from openai import OpenAI

client = OpenAI(
    api_key="EMPTY",
    base_url="http://123.123.123.123:9999/v1",
    timeout=3600
)

# Task-specific base prompts
TASKS = {
    "ocr": "OCR:",
    "table": "Table Recognition:",
    "formula": "Formula Recognition:",
    "chart": "Chart Recognition:",
}

messages = [
    {
        "role": "user",
        "content": [
            {
                "type": "image_url",
                "image_url": {
                    "url": "https://ofasys-multimodal-wlcb-3-toshanghai.oss-accelerate.aliyuncs.com/wpf272043/keepme/image/receipt.png"
                }
            },
            {
                "type": "text",
                "text": TASKS["ocr"]
            }
        ]
    }
]

response = client.chat.completions.create(
    model="PaddleOCR-VL-0.9B",
    messages=messages,
    temperature=0.0,
)
print(f"Generated text: {response.choices[0].message.content}")

为了更好的提升OCR识别的效果,还可以搭配Paddle的PP-DocLayoutV2模型,这是百度飞桨推出的文档版面分析模型,基于RT-DETR检测器扩展了指针网络,能精准检测标题、文本、表格、公式等23种版面元素并预测阅读顺序进行版面识别。

按照官方文档的介绍准备镜像,PaddleOCR-VL --- vllm-ascend

然后通过以下命令启动

bash 复制代码
docker run -itd \
    --name paddle-ocr-doclayout \
    -v /Public_dir:/work \
    --privileged \
    --network=host \
    --shm-size=32G \
    -w=/work \
    -v /usr/local/Ascend/driver:/usr/local/Ascend/driver \
    -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
    -v /usr/local/dcmi:/usr/local/dcmi \
    -e Ascend_RT_VISIBLE_DEVICES="6" \
    paddle-npu:v1.2 bash

然后编写一个API服务的程序,提供用户上传图像或者PDF文件,然后进行OCR提取的功能。

python 复制代码
from fastapi import FastAPI, UploadFile, File, Depends, HTTPException, Form
from fastapi.responses import JSONResponse, StreamingResponse
from contextlib import asynccontextmanager
import asyncio
import aiofiles
import os
import uuid
import logging
from typing import List
from paddleocr import PaddleOCRVL
import io
import zipfile
import shutil

#os.environ['PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK'] = True
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# 模型管理器(单例模式)
class ModelManager:
    _instance = None
    pipeline = None
    
    @classmethod
    def get_instance(cls):
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance
    
    async def initialize_model(self):
        if self.pipeline is not None:
            return self.pipeline
            
        try:
            from paddleocr import PaddleOCRVL
            doclayout_model_path = "/work/PP-DocLayoutV2/"
            
            self.pipeline = PaddleOCRVL(
                vl_rec_backend="vllm-server", 
                vl_rec_server_url="http://10.134.252.253:9999/v1",
                vl_rec_model_name="PaddleOCR-VL-1.5-0.9B",
                layout_detection_model_name="PP-DocLayoutV2",  
                layout_detection_model_dir=doclayout_model_path,
                use_layout_detection=True,
                device="npu:6"
            )
            logger.info("✅ PP-DocLayoutV2模型初始化完成")
            return self.pipeline
        except Exception as e:
            logger.error(f"❌ 模型初始化失败: {e}")
            raise
    
    def get_model(self):
        if self.pipeline is None:
            raise RuntimeError("模型未初始化")
        return self.pipeline

# 应用生命周期
@asynccontextmanager
async def app_lifespan(app: FastAPI):
    # 启动时初始化
    logger.info("🚀 启动FastAPI服务...")
    model_manager = ModelManager.get_instance()
    await model_manager.initialize_model()
    yield
    # 关闭时清理
    logger.info("🛑 关闭服务,清理资源...")
    if model_manager.pipeline is not None:
        del model_manager.pipeline
        model_manager.pipeline = None

app = FastAPI(
    title="OCR高并发服务",
    description="基于PP-DocLayoutV2的高性能OCR服务",
    version="1.0.0",
    lifespan=app_lifespan
)

# 依赖注入
async def get_ocr_pipeline():
    return ModelManager.get_instance().get_model()

@app.get("/")
async def root():
    return {"message": "OCR服务运行中", "status": "healthy"}

@app.post("/predict")
async def predict_ocr(
    file: UploadFile = File(..., description="支持JPG,PNG或PDF"),
    merge: bool = Form(False, description="是否对多页PDF识别结果进行合并(true/false)"),
    pipeline: PaddleOCRVL = Depends(get_ocr_pipeline)
):
    """高性能OCR预测接口"""
    start_time = asyncio.get_event_loop().time()
    # 文件验证
    if not file.content_type.startswith('image/') and not file.filename.lower().endswith('.pdf'):
        raise HTTPException(400, "请上传图片文件或PDF文件")
    
    # 处理文件
    temp_dir = "temp"
    os.makedirs(temp_dir, exist_ok=True)
    temp_id = uuid.uuid4()
    temp_session_dir = f"{temp_dir}/{temp_id}"
    os.makedirs(temp_session_dir, exist_ok=True)
    temp_path = os.path.join(temp_session_dir, f"{file.filename}")
    
    try:
        # 保存文件
        async with aiofiles.open(temp_path, 'wb') as f:
            await f.write(await file.read())
        
        # 异步推理
        loop = asyncio.get_event_loop()
        output = await loop.run_in_executor(
            None, 
            lambda: pipeline.predict(temp_path)
        )
        
        # 处理结果
        results = []
        pages_res = list(output)
        if len(pages_res) > 1 and merge:
            output = pipeline.restructure_pages(
                pages_res,
                merge_tables=True,
                relevel_titles=True,
                concatenate_pages=True
            )
        for i, res in enumerate(output):
            save_path = f"{temp_session_dir}/output_{i}.json"
            res.save_to_json(save_path=save_path)
            save_path = f"{temp_session_dir}/output_{i}.md"
            res.save_to_markdown(save_path=save_path)

        zip_buffer = io.BytesIO()
        with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
            for root, dirs, files in os.walk(temp_session_dir):
                for file in files:
                    file_path = os.path.join(root, file)
                    arcname = os.path.relpath(file_path, temp_session_dir)
                    zipf.write(file_path, arcname)
        zip_buffer.seek(0)
        #results.append({"index": i, "save_path": save_path})

        '''
        with open(results[0]['save_path'], 'r', encoding='utf-8') as f:
            json_text = file.read()
        string_stream = io.StringIO(json_text)
        string_stream.seek(0)

        processing_time = asyncio.get_event_loop().time() - start_time
        logger.info(f"✅ 处理完成: {file.filename}, 耗时: {processing_time:.2f}s")
        
        return {
            "filename": file.filename,
            "processing_time": f"{processing_time:.2f}s",
            "results": results
        }
        '''
        response = StreamingResponse(
            zip_buffer,
            media_type="application/x-zip-compressed",
            headers={
                "Content-Disposition": f"attachment; filename={temp_id}",
                "Content-Type": "application/zip"
            }
        )
        shutil.rmtree(temp_session_dir)
        return response
        
    except Exception as e:
        logger.error(f"❌ 处理失败: {file.filename}, 错误: {e}")
        raise HTTPException(500, f"处理失败: {str(e)}")
    finally:
        if os.path.exists(temp_path):
            os.unlink(temp_path)

@app.get("/health")
async def health_check():
    """健康检查端点"""
    try:
        manager = ModelManager.get_instance()
        pipeline = manager.get_model()
        return {
            "status": "healthy",
            "model_loaded": pipeline is not None,
            "message": "服务正常运行"
        }
    except Exception as e:
        raise HTTPException(500, f"服务异常: {str(e)}")

该程序构建了一个基于FastAPI的高性能OCR服务后端,主要功能是调用百度飞桨的PaddleOCR VL 1.5和PP-DocLayoutV2模型,对上传的图像或PDF文件进行版面分析、文本识别和结构化输出。其功能如下:

  1. 采用单例模式管理模型实例,确保服务启动时仅加载一次模型,提高资源利用率;

  2. 完全异步化处理,从文件上传、模型推理到结果打包均使用异步IO,支持高并发请求;

  3. 支持多页PDF处理,提供页面合并选项,可自动合并跨页表格和重组标题层级;

  4. 输出结果多样化,同时生成JSON和Markdown两种格式,并通过ZIP压缩包返回;

  5. 包含完善的错误处理、临时文件管理和健康检查机制,确保服务稳定性。

最后运行以下命令,通过Gunicorn来启动多个进程,提供API服务。

bash 复制代码
export PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK='True'
nohup gunicorn -w 16 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 main:app &

通过以下Python程序可以调用这个OCR服务,测试效果

python 复制代码
import requests
import argparse
import os
import sys
from typing import Optional
import time
import base64

def get_mime_type(file_path):
    extension = os.path.splitext(file_path)[1].lower()
    mime_map = {
        '.jpg': 'image/jpeg',
        '.jpeg': 'image/jpeg',
        '.png': 'image/png',
        'pdf': 'application/pdf'
    }
    return mime_map.get(extension, 'application/octet-stream')

def upload_file_to_api(api_url: str, file_path: str, merge: bool, output_path: str) -> bool:
    """
    上传文件到 API 并保存返回的 ZIP 文件
    
    Args:
        api_url: API 地址
        file_path: 要上传的文件路径
        merge: 是否合并的布尔值
        output_path: 保存返回 ZIP 文件的路径
        
    Returns:
        bool: 是否成功
    """
    # 检查文件是否存在
    if not os.path.exists(file_path):
        print(f"错误: 文件 '{file_path}' 不存在")
        return False
    
    # 检查文件是否可读
    if not os.access(file_path, os.R_OK):
        print(f"错误: 文件 '{file_path}' 不可读")
        return False
    
    # 准备目录(如果输出路径包含目录)
    output_dir = os.path.dirname(output_path)
    if output_dir and not os.path.exists(output_dir):
        try:
            os.makedirs(output_dir, exist_ok=True)
        except Exception as e:
            print(f"错误: 无法创建输出目录 '{output_dir}': {e}")
            return False
    
    try:
        # 打开文件
        with open(file_path, 'rb') as f:
            # 准备 multipart/form-data 数据
            files = {
                'file': (os.path.basename(file_path), f, get_mime_type(file_path))
            }
            data = {
                'merge': str(merge).lower(),  # 转换为 'true' 或 'false'
                'componentCode': '04350939'
            }
            
            cur_date, auth = generate_signature()
            headers = {
                "x-date": cur_date,
                "Authorization": auth
            }

            print(f"正在上传文件: {file_path}")
            print(f"API 地址: {api_url}")
            print(f"Merge 参数: {merge}")
            print(f"输出文件: {output_path}")
            
            # 发送 POST 请求
            response = requests.post(api_url, files=files, data=data, headers=headers, verify=False)
            
            # 检查响应状态
            if response.status_code != 200:
                print(f"错误: API 返回状态码 {response.status_code}")
                if response.text:
                    print(f"响应内容: {response.text[:500]}")
                return False
            
            # 检查响应内容类型
            content_type = response.headers.get('Content-Type', '').lower()
            
            # 保存文件
            zip_content = response.content
            with open(output_path, 'wb') as out_file:
                out_file.write(zip_content)
            # 验证文件是否成功保存
            if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
                file_size = os.path.getsize(output_path)
                print(f"✓ 文件上传成功!")
                print(f"  保存路径: {output_path}")
                print(f"  文件大小: {file_size:,} 字节")
                
                return True
            else:
                print("错误: 保存的文件为空或不存在")
                return False
                
    except requests.exceptions.ConnectionError:
        print(f"错误: 无法连接到服务器 '{api_url}'。请检查网络连接和服务器状态。")
        return False
    except requests.exceptions.Timeout:
        print("错误: 请求超时。请检查网络连接或稍后重试。")
        return False
    except requests.exceptions.RequestException as e:
        print(f"错误: 网络请求异常: {e}")
        return False
    except IOError as e:
        print(f"错误: 文件读写异常: {e}")
        return False
    except Exception as e:
        print(f"错误: 未知异常: {type(e).__name__}: {e}")
        return False

def main():
    parser = argparse.ArgumentParser(
        description='上传图片或PDF并接收 ZIP 文件',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
使用示例:
  %(prog)s -f document.pdf -m true -o result.zip
  %(prog)s -f image.jpg -m false -o ./output/result.zip --url http://localhost:8000/upload/
  %(prog)s --file data.json --merge true --output /tmp/output.zip
        """
    )
    
    # 必需参数
    parser.add_argument(
        '-f', '--file',
        required=True,
        help='要上传的文件路径'
    )
    
    parser.add_argument(
        '-m', '--merge',
        required=True,
        type=str.lower,
        choices=['true', 'false', 't', 'f', '1', '0', 'yes', 'no'],
        help='是否合并处理 (true/false)'
    )
    
    parser.add_argument(
        '-o', '--output',
        required=True,
        help='保存返回 ZIP 文件的路径'
    )
    
    # 可选参数
    parser.add_argument(
        '--url',
        default='https://123.123.123.123:8000/predict',
        help='API地址 (默认: https://123.123.123.123:8000/predict)'
    )
    
    parser.add_argument(
        '--timeout',
        type=int,
        default=60,
        help='请求超时时间(秒)(默认: 30)'
    )
    
    # 解析参数
    args = parser.parse_args()
    
    # 转换 merge 参数
    merge_map = {
        'true': True, 't': True, '1': True, 'yes': True,
        'false': False, 'f': False, '0': False, 'no': False
    }
    merge = merge_map[args.merge]
    
    # 设置全局超时
    requests_timeout = args.timeout
    
    # 上传文件
    success = upload_file_to_api(args.url, args.file, merge, args.output)
    
    # 根据结果返回适当的退出码
    sys.exit(0 if success else 1)

if __name__ == "__main__":
    main()
相关推荐
jialan752 天前
GLM-ocr测试
ocr
含老司开挖掘机3 天前
Chandra OCR多格式输出详解:同页同步生成Markdown/HTML/JSON三版本
ocr·文档解析·结构化输出·chandra
Cccp.1233 天前
【OpenCV】(十八)答题卡识别判卷与文档ocr扫描识别
人工智能·opencv·ocr
合合技术团队3 天前
零代码搭建「招标文件解析智能体」:Coze+TextIn xParse实现PDF上传自动提条款、标风险、出建议
ocr·coze·文档解析·textln
御坂10101号4 天前
爱泼斯坦文件技术细节:伪扫描、元数据清洗与撤销涂黑
图像处理·pdf·ocr
2401_836235866 天前
中安未来SDK15:以AI之眼,解锁企业档案的数字化基因
人工智能·科技·深度学习·ocr·生活
2401_836235866 天前
财务报表识别产品:从“数据搬运”到“智能决策”的技术革命
人工智能·科技·深度学习·ocr·生活
A小码哥7 天前
DeepSeek-OCR-2 开源 OCR 模型的技术
ocr