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文件进行版面分析、文本识别和结构化输出。其功能如下:
-
采用单例模式管理模型实例,确保服务启动时仅加载一次模型,提高资源利用率;
-
完全异步化处理,从文件上传、模型推理到结果打包均使用异步IO,支持高并发请求;
-
支持多页PDF处理,提供页面合并选项,可自动合并跨页表格和重组标题层级;
-
输出结果多样化,同时生成JSON和Markdown两种格式,并通过ZIP压缩包返回;
-
包含完善的错误处理、临时文件管理和健康检查机制,确保服务稳定性。
最后运行以下命令,通过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()