PaddleOCR 开发记录:图片文字识别与 Docker 部署
App 中有一个图片取文字的功能:用户从相册选择图片,识别后把关键信息显示出来,方便复制和编辑。
这个功能我分别试过 Flutter 端侧识别和服务端识别。最后选择服务端,客户端上传图片,通过 HTTP 接口获取文字。这样调整识别模型时,客户端不需要跟着重新发版。
下面按开发过程记录端侧尝试、PaddleOCR 参数、接口封装和 Docker 部署。代码整理成一个自建 HTTP 服务,使用 PaddleOCR 3.2.0 和 CPU 推理。
一、Flutter 端侧的识别方式
Flutter 中使用 google_mlkit_text_recognition 可以调用 ML Kit。中文图片需要配置对应的语言依赖,并在创建识别器时选择中文脚本:
Future<String> recognizeOnDevice(File image) async {
final recognizer = TextRecognizer(
script: TextRecognitionScript.chinese,
);
try {
final input = InputImage.fromFile(image);
final result = await recognizer.processImage(input);
return result.text;
} finally {
await recognizer.close();
}
}
代码用到 dart:io 和 google_mlkit_text_recognition。iOS、Android 的中文语言依赖需要分别配置,具体版本跟随项目使用的插件版本。插件接入说明
端侧识别不需要上传图片,模型准备好后可以离线使用。我还试过多选图片逐张识别,以及分页读取相册后批量处理。
最后选择服务端,主要是为了统一模型和参数。ML Kit 的模型由 SDK 提供,如果要改用 PaddleOCR 或调整服务端推理配置,放在服务器上会更方便。端侧能减少上传和网络等待,服务端则方便集中调整模型,两种方式都能完成基本的取文字需求。
图片压缩也没有再固定为“必须压到 100KB”。文字较多的截图,如果反复降低 JPEG 质量,细小笔画可能先变糊。客户端先控制图片分辨率和上传体积,识别效果不理想时让用户裁剪文字区域,比一味降低图片质量更合适。
二、把 Python 和依赖放进镜像
最开始是在 CentOS 7 上安装。那台机器已有的 Python 环境版本较低,升级 Python 时又遇到了 OpenSSL 和 HTTPS 依赖,后面还处理过 OpenCV 缺少 libGL.so.1 的问题。
整理部署时,我把 Python 和系统库放进镜像,宿主机保留原有环境。模型文件单独挂载,服务代码和依赖写进镜像。
目录如下:
ocr-service/
├── Dockerfile
├── requirements.txt
├── engine.py
└── app.py
这里的版本用于复现本文示例,不表示它们是最新版本。PaddlePaddle、PaddleOCR 和 PaddleX 固定版本,HTTP 服务相关依赖限定范围,镜像构建完成后保存完整依赖清单。
requirements.txt:
paddleocr==3.2.0
paddlex[ocr-core]==3.2.0
fastapi>=0.115,<1
uvicorn>=0.34,<1
python-multipart>=0.0.20,<1
Pillow>=11,<13
PaddleOCR 3.2.0 的依赖声明对应 PaddleX 3.2 系列,这里一起固定,避免安装时跨到其他系列。版本依赖说明
Dockerfile:
FROM python:3.10-slim-bookworm
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PADDLE_PDX_CACHE_HOME=/models \
OCR_CPU_THREADS=4 \
OCR_DET_SIDE=736
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN python -m pip install paddlepaddle==3.2.0 \
-i https://www.paddlepaddle.org.cn/packages/stable/cpu/ \
&& python -m pip install -r requirements.txt \
&& python -m pip check \
&& python -m pip freeze > /app/requirements.lock.txt
COPY engine.py app.py ./
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1"]
CPU 版 PaddlePaddle 从官方 CPU 源安装,其他依赖由普通 Python 包源解析。构建阶段执行 pip check,有依赖冲突就停止构建,不把安装报错留到运行时处理。
本文使用 Linux x86-64 的 CPU 环境。容器隔离 Python 和用户态依赖,但仍使用宿主机内核和 CPU,部署机器需要满足对应运行环境的要求。
三、CPU 上的模型配置
检测和识别都先使用 PP-OCRv5 的 mobile 模型。模型在服务启动时加载一次,请求进来后复用。
engine.py:
import os
from paddleocr import PaddleOCR
def create_ocr():
threads = int(os.getenv("OCR_CPU_THREADS", "4"))
side = int(os.getenv("OCR_DET_SIDE", "736"))
if threads < 1 or side < 32:
raise ValueError("OCR_CPU_THREADS 或 OCR_DET_SIDE 配置不正确")
return PaddleOCR(
device="cpu",
text_detection_model_name="PP-OCRv5_mobile_det",
text_recognition_model_name="PP-OCRv5_mobile_rec",
# 输入以方向正确的截图和照片为主,上传前会处理 EXIF 方向。
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
text_det_limit_side_len=side,
text_det_limit_type="max",
enable_hpi=False,
cpu_threads=threads,
)
三个开关分别控制文档方向判断、图像矫正和文本行方向判断。这里按正向截图和照片处理,关闭额外步骤;遇到倒置文字、明显透视变形的输入,再用对应图片对照这些开关的效果。EXIF 方向处理只负责相机记录的旋转信息,不会把图片中的歪斜文字自动拉正。
text_det_limit_side_len=736 配合 text_det_limit_type="max",控制检测阶段缩放后的最长边。缩小检测输入可以减少计算量,但小字也可能更难检出,所以把它放进环境变量,便于使用同一组图片比较 736、960 等取值。
线程数同样从环境变量读取。后面的运行示例给容器分配 4 个 CPU,推理线程先设为 4;这是示例配置,不是原服务器的性能数据。增大线程数是否更快,要看机器资源和请求耗时。
这些参数采用 PaddleOCR 3.2.0 的名称,不与 2.x 的 det_limit_side_len 混用。OCR 参数实现
HPI 单独作为一组对照配置。基础服务使用普通 CPU 推理,不依赖 HPI 的额外安装步骤;启用时,在独立镜像中安装该版本配套依赖,完成依赖检查和实际识别,再比较结果。依赖没装完整时不继续使用 enable_hpi=True。
四、HTTP 接口接收图片并返回文字
接口约定为 POST /ocr,使用 multipart 表单上传 file。返回文字列表、拼接后的文本和推理耗时,客户端不直接依赖 PaddleOCR 的完整返回结构。
上传图片在服务端再检查一次:文件最多 8MiB,原图最多 1200 万像素,支持 JPEG、PNG、WebP 单张图片。解码后将最长边缩到 2560,检测阶段再按上一节的参数处理。
下面的 app.py 分成两段展示,按顺序放在同一个文件中。
先定义图片处理和模型初始化:
import io
import logging
import threading
import time
import warnings
from contextlib import asynccontextmanager
import numpy as np
from fastapi import FastAPI, File, HTTPException, UploadFile
from PIL import Image, ImageOps, UnidentifiedImageError
from engine import create_ocr
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ocr")
MAX_FILE_BYTES = 8 * 1024 * 1024
MAX_PIXELS = 12_000_000
MAX_SIDE = 2560
ALLOWED_FORMATS = {"JPEG", "PNG", "WEBP"}
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
warnings.filterwarnings("error", category=Image.DecompressionBombWarning)
def decode_image(data: bytes) -> np.ndarray:
try:
with Image.open(io.BytesIO(data)) as source:
if source.format not in ALLOWED_FORMATS:
raise HTTPException(415, "仅支持 JPEG、PNG、WebP 图片")
if getattr(source, "n_frames", 1) != 1:
raise HTTPException(415, "请上传单张静态图片")
width, height = source.size
if width < 1 or height < 1 or width * height > MAX_PIXELS:
raise HTTPException(413, "图片像素数超过限制")
source.load()
image = ImageOps.exif_transpose(source).convert("RGBA")
image.thumbnail((MAX_SIDE, MAX_SIDE), Image.Resampling.LANCZOS)
background = Image.new("RGB", image.size, "white")
background.paste(image, mask=image.getchannel("A"))
rgb = np.asarray(background)
return np.ascontiguousarray(rgb[:, :, ::-1]) # PaddleOCR 使用 BGR 数组
except (Image.DecompressionBombError, Image.DecompressionBombWarning):
raise HTTPException(413, "图片像素数超过限制")
except (UnidentifiedImageError, OSError, ValueError):
raise HTTPException(400, "无法解码图片,请重新选择文件")
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.ocr = create_ocr()
app.state.inference_slot = threading.BoundedSemaphore(1)
yield
app.state.ocr = None
app = FastAPI(lifespan=lifespan)
这里通过实际解码判断图片格式,不以扩展名或客户端传来的 MIME 类型作为依据。透明图片铺白底,再转为 BGR 数组,避免透明区域直接变黑影响文字识别。
接着定义接口:
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/ocr")
def recognize(file: UploadFile = File(...)):
slot = app.state.inference_slot
if not slot.acquire(blocking=False):
file.file.close()
raise HTTPException(429, "识别服务正在处理其他图片,请稍后重试",
headers={"Retry-After": "2"})
started = time.perf_counter()
try:
data = file.file.read(MAX_FILE_BYTES + 1)
if not data:
raise HTTPException(400, "上传文件为空")
if len(data) > MAX_FILE_BYTES:
raise HTTPException(413, "图片文件不能超过 8MiB")
image = decode_image(data)
inference_started = time.perf_counter()
results = list(app.state.ocr.predict(image))
inference_ms = round((time.perf_counter() - inference_started) * 1000, 2)
lines = []
for result in results:
for text, score in zip(result["rec_texts"], result["rec_scores"]):
lines.append({"text": str(text), "score": float(score)})
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
logger.info("ocr bytes=%d width=%d height=%d lines=%d elapsed_ms=%.2f",
len(data), image.shape[1], image.shape[0], len(lines), elapsed_ms)
return {
"text": "\n".join(line["text"] for line in lines),
"lines": lines,
"inference_ms": inference_ms,
"elapsed_ms": elapsed_ms,
}
except HTTPException:
raise
except Exception:
logger.exception("OCR 推理失败")
raise HTTPException(500, "识别失败,请稍后重试")
finally:
file.file.close()
slot.release()
单个进程同一时间处理一张图片,忙时直接返回 429。这样不会在同一个模型实例上同时跑多个推理,也不会让客户端无限排队。推理结束或抛出异常后,finally 都会释放占用。
inference_ms 包含完整 OCR 调用,elapsed_ms 还包含接口内的文件读取、图片解码和结果整理。它们都不包含客户端上传、网络往返及 multipart 解析时间,客户端另外记录请求总耗时。
接口不把上传图片另存到业务目录,结束后关闭上传临时文件。日志记录尺寸和耗时,不输出识别正文。
五、构建镜像和连接客户端
在 ocr-service 目录执行:
docker build --platform linux/amd64 -t paddleocr-http:3.2.0 .
docker volume create ocr-models
docker run -d \
--name paddleocr-http \
--restart unless-stopped \
--cpus=4 \
--memory=4g \
-p 127.0.0.1:8082:8080 \
-e OCR_CPU_THREADS=4 \
-e OCR_DET_SIDE=736 \
-v ocr-models:/models \
paddleocr-http:3.2.0
PADDLE_PDX_CACHE_HOME 指向 /models,PaddleX 使用这个位置保存模型缓存。首次启动需要下载模型,下载、初始化完成后,应用才开始接收请求。之后重建容器继续挂载同一个卷,不必每次重新下载。PaddleX 缓存目录配置
这里的镜像名是本文 Dockerfile 构建的 paddleocr-http,端口和接口也由本文定义,不依赖已有的同名 OCR 镜像。
查看状态、日志和识别结果:
docker logs -f paddleocr-http
curl --fail-with-body http://127.0.0.1:8082/health
curl --fail-with-body \
-F "file=@./sample.jpg" \
http://127.0.0.1:8082/ocr
docker cp paddleocr-http:/app/requirements.lock.txt ./requirements.lock.txt
镜像里的完整依赖清单跟本次发布一起保存。再次发布时保留镜像摘要和依赖清单,方便确认实际运行版本。更换模型或配置后重建容器,模型缓存卷继续保留;切换模型名时,首次启动仍可能下载新的模型。
1. 入口限制上传大小
容器端口只绑定本机,外部请求通过已有的 HTTPS 业务入口转发。把下面配置合并到完成登录鉴权的站点中:
location = /ocr {
client_max_body_size 9m;
client_body_timeout 15s;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_pass http://127.0.0.1:8082/ocr;
}
入口限制整个请求为 9MiB,给 multipart 包装留一些空间;应用仍单独检查图片文件的 8MiB 上限。入口限制也能避免过大的上传先被框架写入临时文件,再到业务方法里判断。
2. Flutter 发起请求
Flutter 使用 Dio 上传文件,连接、发送和接收分别设置超时:
Future<String> recognizeByServer(File image, String accessToken) async {
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5),
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
));
try {
final form = FormData.fromMap({
'file': await MultipartFile.fromFile(
image.path,
filename: 'upload.jpg',
),
});
final response = await dio.post<Map<String, dynamic>>(
'https://你的业务域名/ocr',
data: form,
options: Options(headers: {'Authorization': 'Bearer $accessToken'}),
);
return response.data?['text'] as String? ?? '';
} on DioException catch (e) {
if (e.response?.statusCode == 429) {
throw Exception('识别服务繁忙,请稍后重试');
}
if (e.response?.statusCode == 413) {
throw Exception('图片过大,请裁剪或缩小后重试');
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.receiveTimeout) {
throw Exception('识别请求超时,请稍后重试');
}
throw Exception('图片识别失败,请重新选择图片或稍后重试');
} finally {
dio.close();
}
}
代码使用 dart:io 和 Dio 5 的接口,域名替换为实际业务入口。这里的鉴权由已有业务服务处理,OCR 容器本身不负责用户登录。
客户端超时后结束等待,给用户重新发起请求的入口,不立即自动连发多次。超时不代表服务端推理已被中断;服务端仍会在本次推理真正结束后释放模型占用。
六、分别看检测和识别的耗时
完整接口方便调用,调整参数时则把检测、裁剪和识别分开计时。
检测框通常是四边形。裁剪时先按四个角做透视变换,把倾斜文字区域拉成矩形,再送入识别模型,不通过写 JSON、再读 JSON 的方式传递中间结果。
下面是独立的分析脚本,图片已经过前面的 decode_image() 处理。poly 使用检测结果提供的左上、右上、右下、左下四点顺序:
import time
import cv2
import numpy as np
from paddleocr import TextDetection, TextRecognition
def crop_text(image, poly):
points = np.asarray(poly, dtype=np.float32).reshape(4, 2)
if not np.isfinite(points).all():
return None
top_left, top_right, bottom_right, bottom_left = points
width = int(round(max(np.linalg.norm(top_right - top_left),
np.linalg.norm(bottom_right - bottom_left))))
height = int(round(max(np.linalg.norm(bottom_left - top_left),
np.linalg.norm(bottom_right - top_right))))
if width < 2 or height < 2:
return None
target = np.array([[0, 0], [width - 1, 0],
[width - 1, height - 1], [0, height - 1]], dtype=np.float32)
matrix = cv2.getPerspectiveTransform(points, target)
return cv2.warpPerspective(
image, matrix, (width, height),
flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
detector = TextDetection(
model_name="PP-OCRv5_mobile_det", device="cpu", cpu_threads=4,
limit_side_len=736, limit_type="max")
recognizer = TextRecognition(
model_name="PP-OCRv5_mobile_rec", device="cpu", cpu_threads=4)
def profile_image(image):
started = time.perf_counter()
detection = list(detector.predict(image, batch_size=1))
detected_at = time.perf_counter()
crops = []
for result in detection:
for poly in result["dt_polys"]:
crop = crop_text(image, poly)
if crop is not None:
crops.append(crop)
cropped_at = time.perf_counter()
results = list(recognizer.predict(crops, batch_size=1)) if crops else []
finished = time.perf_counter()
return {
"detection_ms": (detected_at - started) * 1000,
"crop_ms": (cropped_at - detected_at) * 1000,
"recognition_ms": (finished - cropped_at) * 1000,
"results": results,
}
这段用于观察各阶段耗时,HTTP 服务仍使用完整 OCR 管线。正式比较前先跑一遍预热,模型创建和首次初始化的时间另外记录。
batch_size=1 表示识别模型每批处理一张文字小图,即使传入的是列表,也不等于一次并行处理全部图片。比较更大的 batch 时,把识别耗时和内存占用一起记录。
如果业务本来就知道文字在哪,比如固定的截图区域,可以裁出区域后只调用识别模型,省去整张图片的文字检测。
七、把参数和识别结果一起留下来
参数比较使用固定图片,包含普通截图、中文小字、中英文混排、拍照倾斜和低对比度文字。修改模型或检测边长后,用同一组图片重新跑,保存识别文字和耗时。
下面这个脚本调用已经启动的服务,逐张保存结果;使用前安装 requests,把测试图片放到 samples 目录:
import json
import time
from pathlib import Path
import requests
samples = sorted(p for p in Path("samples").iterdir()
if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"})
output = Path("results")
output.mkdir(exist_ok=True)
with requests.Session() as session:
for index, path in enumerate(samples):
started = time.perf_counter()
try:
with path.open("rb") as image:
response = session.post(
"http://127.0.0.1:8082/ocr",
files={"file": (path.name, image)}, timeout=(5, 60))
try:
body = response.json()
except ValueError:
body = {"message": response.text[:500]}
record = {"file": path.name, "status": response.status_code, "result": body}
except requests.RequestException as exc:
record = {"file": path.name, "error": str(exc)}
record["request_ms"] = round((time.perf_counter() - started) * 1000, 2)
(output / f"{index:03d}-{path.stem}.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
每次对照使用独立结果目录,同时记下模型名、检测边长、推理线程数、CPU 配额、内存和镜像版本。识别结果跟图片一起检查,重点看有没有漏字、错字、顺序变化,不能只比较接口耗时。
这份脚本是串行识别记录。并发请求单独测试:持续识别时观察 CPU、内存和 429 返回情况,再决定增加实例还是调整资源。
更换模型时,保留上一版镜像和对应的识别结果。新版本仍返回相同的 text、lines 和耗时字段,客户端继续调用原来的接口。
- 本文标签: Java Linux Flutter
- 本文链接: https://www.tianyajuanke.top/article/101
- 版权声明: 本文由吴沛芙原创发布,转载请遵循《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权