• 欢迎访问誉卿博客,推荐使用最新版火狐浏览器和 Chrome 浏览器访问本网站。

  • 初不解禅心未住,悟后逍遥游处方。 山水有情皆由心,见山见水皆天堂。

给 Paperless-ngx 装上更懂中文的 OCR:RapidOCR 增强方案,新人也能部署

工具 yqdnsjs 1个月前 (08-30) 139次浏览 扫描二维码
Paperless-ngx 中文 OCR 增强封面

Paperless-ngx 很适合做个人文档库:扫描件、票据、证件、合同、说明书,都能统一归档、打标签、全文搜索。可是一到中文扫描件,尤其是手机拍照、老资料、低对比度图片,原生 Tesseract OCR 经常会出现乱码、漏字、数字错位。

这篇笔记记录一个更稳妥的做法:不替换 Paperless-ngx 本身,也不破坏原来的 OCR 流程,而是在文档导入完成后,外挂一个轻量 OCR API,把更适合中文的识别结果写回 Paperless 文档内容字段。这样搜索、归档、后续 AI 分析都会更好用。

Paperless-ngx 与 RapidOCR 的增强架构

方案亮点

  • 不拆 Paperless 原生 OCR:Paperless 仍然正常导入、生成 PDF/A、缩略图和元数据。
  • 增强中文识别:RapidOCR 基于 ONNXRuntime,中文和中英混排识别更友好。
  • 适合纯 CPU 服务器:不要求显卡,适合 NAS、小主机、VPS、家庭服务器。
  • 可控资源占用:可以限制容器 CPU、内存和单张图片尺寸。
  • 安全边界清晰:OCR API 只给 Paperless 内部调用,不需要暴露到公网。
  • 失败不影响导入:即使增强 OCR 暂时失败,Paperless 原文档仍然保留。
Paperless 原生 OCR 与 RapidOCR 增强效果对比

适合谁用

  • 已经部署了 Paperless-ngx,但中文 OCR 效果不理想。
  • 文档里有大量中文扫描件、证件照、票据、书页、历史资料。
  • 服务器没有显卡,只想用 CPU 稳定跑。
  • 希望用 Docker Compose 管理,方便迁移和备份。
  • 不想把文档发给在线 OCR 或第三方 AI 服务。

整体思路

Paperless-ngx 官方支持导入前脚本和导入后脚本。这里用的是导入后脚本:文档已经进入数据库后,Paperless 会把 DOCUMENT_ID、DOCUMENT_SOURCE_PATH 等变量传给脚本。脚本再把原始图片发给 OCR API,拿到文本后写回 Document.content,最后更新搜索索引。

RapidOCR 官方也提供 ONNXRuntime CPU 推理方案。相比直接跑 PaddleOCR,它对老 CPU 更友好,部署包也更轻。遇到旧款 Xeon、低功耗 NAS CPU 时,这个方案通常比 PaddlePaddle 方案更省心。

目录规划

<code class="language-bash"># 创建 Paperless 与 OCR 的统一工作目录,便于备份和迁移
mkdir -p /opt/paperless-stack

# 创建 RapidOCR API 服务目录,单独保存代码、模型缓存和临时文件
mkdir -p /opt/paperless-stack/rapidocr-api/app

# 创建 RapidOCR 模型缓存目录,首次下载后可长期复用
mkdir -p /opt/paperless-stack/rapidocr-api/models

# 创建 RapidOCR 临时目录,存放上传识别时的中间文件
mkdir -p /opt/paperless-stack/rapidocr-api/tmp

# 创建 Paperless 自定义脚本目录,保存导入前后处理脚本
mkdir -p /opt/paperless-stack/paperless/scripts</code>

创建内部网络

OCR API 不需要开放给外部浏览器访问,只要 Paperless 容器能访问即可。建议建立一个专用 Docker 网络。

<code class="language-bash"># 创建 Paperless 与 OCR API 共用的 Docker 内部网络
docker network create paperless-ocr-net

# 如果网络已经存在,这条命令会提示已存在,可以忽略
docker network inspect paperless-ocr-net</code>

部署 RapidOCR API

先创建 app/server.py。下面这个 API 接收图片或 PDF,默认更推荐用于图片增强;PDF 页数较多时请谨慎打开,避免纯 CPU 服务器压力太大。

<code class="language-python">import os  # 读取环境变量,控制线程和尺寸限制
import time  # 记录 OCR 请求耗时,方便排查慢请求
from pathlib import Path  # 读取上传文件扩展名
from typing import Any  # 标注动态返回结构

import cv2  # 使用 OpenCV 解码和缩放图片
import fitz  # 使用 PyMuPDF 把 PDF 页面渲染为图片
import numpy as np  # 使用 NumPy 承载图片矩阵
from fastapi import FastAPI, File, HTTPException, UploadFile  # 提供 HTTP API 和文件上传能力
from rapidocr_onnxruntime import RapidOCR  # 调用 RapidOCR ONNXRuntime 推理引擎

os.environ.setdefault("OMP_NUM_THREADS", os.getenv("RAPIDOCR_CPU_THREADS", "4"))  # 限制 OpenMP 线程数
os.environ.setdefault("MKL_NUM_THREADS", os.getenv("RAPIDOCR_CPU_THREADS", "4"))  # 限制 MKL 线程数

app = FastAPI(title="RapidOCR CPU API", version="1.0.0")  # 创建 FastAPI 应用
OCR_MODEL: RapidOCR | None = None  # 保存全局 OCR 模型,避免重复加载

def get_ocr_model() -> RapidOCR:  # 获取或初始化 OCR 模型
    global OCR_MODEL  # 声明使用全局模型变量
    if OCR_MODEL is not None:  # 如果模型已经加载
        return OCR_MODEL  # 直接返回已加载模型
    OCR_MODEL = RapidOCR()  # 初始化 RapidOCR 模型
    return OCR_MODEL  # 返回模型实例

def limit_image_size(image: np.ndarray) -> np.ndarray:  # 限制图片尺寸,降低 CPU 压力
    max_side = int(os.getenv("RAPIDOCR_MAX_IMAGE_SIDE", "2400"))  # 读取最大边长
    height, width = image.shape[:2]  # 获取图片尺寸
    longest = max(height, width)  # 计算最长边
    if longest <= max_side:  # 如果图片不超限
        return image  # 直接返回原图
    scale = max_side / float(longest)  # 计算缩放比例
    return cv2.resize(image, (int(width * scale), int(height * scale)), interpolation=cv2.INTER_AREA)  # 缩小图片

@app.get("/health")  # 注册健康检查接口
def health() -> dict[str, Any]:  # 返回服务状态
    return {"ok": True, "model_loaded": OCR_MODEL is not None}  # 返回模型是否已加载

@app.post("/ocr")  # 注册 OCR 上传识别接口
async def ocr(file: UploadFile = File(...)) -> dict[str, Any]:  # 接收上传文件
    started = time.time()  # 记录开始时间
    data = await file.read()  # 读取上传文件内容
    if not data:  # 如果文件为空
        raise HTTPException(status_code=400, detail="上传文件为空")  # 返回客户端错误
    suffix = Path(file.filename or "").suffix.lower()  # 获取文件扩展名
    images = []  # 准备页面图片列表
    if suffix == ".pdf":  # 如果上传的是 PDF
        dpi = int(os.getenv("RAPIDOCR_PDF_DPI", "180"))  # 读取 PDF 渲染 DPI
        max_pages = int(os.getenv("RAPIDOCR_MAX_PDF_PAGES", "12"))  # 读取最大页数
        with fitz.open(stream=data, filetype="pdf") as document:  # 从字节打开 PDF
            for index, page in enumerate(document):  # 遍历 PDF 页面
                if index >= max_pages:  # 如果超过页数上限
                    break  # 停止处理后续页面
                pixmap = page.get_pixmap(matrix=fitz.Matrix(dpi / 72, dpi / 72), alpha=False)  # 渲染页面
                raw = np.frombuffer(pixmap.samples, dtype=np.uint8)  # 转为 NumPy 数组
                rgb = raw.reshape((pixmap.height, pixmap.width, pixmap.n))  # 还原图片形状
                images.append(limit_image_size(cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)))  # 转 BGR 并加入列表
    else:  # 如果上传的是普通图片
        array = np.frombuffer(data, dtype=np.uint8)  # 把图片字节转为数组
        image = cv2.imdecode(array, cv2.IMREAD_COLOR)  # 解码图片
        if image is None:  # 如果解码失败
            raise HTTPException(status_code=400, detail="无法解码图片文件")  # 返回客户端错误
        images.append(limit_image_size(image))  # 加入图片列表
    model = get_ocr_model()  # 获取 OCR 模型
    page_texts = []  # 保存每页文本
    scores = []  # 保存置信度
    for image in images:  # 遍历页面图片
        result, _ = model(image)  # 执行 OCR
        lines = [line for line in (result or []) if len(line) >= 3]  # 过滤合法行
        page_texts.append("n".join(str(line[1]).strip() for line in lines if str(line[1]).strip()))  # 合并页面文本
        scores.extend(float(line[2]) for line in lines)  # 汇总置信度
    return {"text": "nn".join(page_texts).strip(), "pages": len(images), "avg_score": sum(scores) / len(scores) if scores else 0, "seconds": round(time.time() - started, 3)}  # 返回识别结果</code>

再创建 RapidOCR 服务的 Dockerfile。

<code class="language-dockerfile"># 使用 Python slim 镜像,保持容器体积相对可控
FROM python:3.10-slim

# 设置 Python 输出不缓冲,方便查看容器日志
ENV PYTHONUNBUFFERED=1

# 设置 Python 默认 UTF-8,避免中文日志乱码
ENV PYTHONUTF8=1

# 安装系统依赖、OCR 依赖和 Web API 依赖
RUN apt-get update && apt-get install -y --no-install-recommends curl libgomp1 libglib2.0-0 libgl1 libsm6 libxext6 libxrender1 poppler-utils fonts-noto-cjk && python -m pip install --no-cache-dir --upgrade pip && python -m pip install --no-cache-dir fastapi "uvicorn[standard]" python-multipart pillow numpy==1.26.4 opencv-python==4.6.0.66 opencv-contrib-python==4.6.0.66 pyclipper==1.3.0.post6 onnxruntime==1.23.2 rapidocr_onnxruntime==1.4.4 pymupdf && rm -rf /var/lib/apt/lists/*

# 设置服务工作目录
WORKDIR /app

# 复制 API 程序
COPY app/server.py /app/server.py

# 暴露容器内部端口
EXPOSE 39090

# 启动单进程 API 服务,避免 CPU 小机器被多 worker 打满
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "39090", "--workers", "1"]</code>

最后创建 docker-compose.yml。端口建议只绑定到 127.0.0.1,并让 Paperless 通过 Docker 内部网络访问。

<code class="language-yaml">name: rapidocr-api # 定义项目名称,方便 Docker Compose 管理

services: # 定义服务列表
  rapidocr-api: # 定义 OCR API 服务
    build: . # 使用当前目录构建镜像
    image: local/rapidocr-api:cpu # 指定本地镜像名称
    container_name: rapidocr-api # 指定容器名称
    restart: unless-stopped # 容器异常退出后自动重启
    cpus: "4.0" # 最多使用 4 个 CPU 核心
    mem_limit: 6g # 最多使用 6GB 内存
    shm_size: 1g # 设置共享内存,减少图像处理异常
    environment: # 设置运行环境变量
      RAPIDOCR_CPU_THREADS: "4" # 限制 OCR 推理线程数
      RAPIDOCR_MAX_IMAGE_SIDE: "2400" # 限制图片最长边,避免超大图拖慢服务器
      RAPIDOCR_MAX_PDF_PAGES: "12" # 限制 PDF 最大识别页数
      RAPIDOCR_PDF_DPI: "180" # 设置 PDF 渲染 DPI
    ports: # 设置端口映射
      - "127.0.0.1:39090:39090" # 只允许宿主机本机访问 OCR API
    volumes: # 设置数据卷
      - ./models:/models # 保存模型缓存
      - ./tmp:/tmp/rapidocr-api # 保存临时文件
    networks: # 设置容器网络
      - paperless-ocr-net # 加入 Paperless 共用 OCR 网络

networks: # 定义网络
  paperless-ocr-net: # 使用外部共享网络
    external: true # 表示网络由 docker network create 提前创建</code>

接入 Paperless-ngx

Paperless 的 webserver 服务需要加入同一个内部网络。下面只展示需要加入的片段。

<code class="language-yaml">services: # 定义服务列表
  webserver: # Paperless Web 服务
    networks: # 给 Web 服务增加网络
      - default # 保留原来的默认网络,保证能访问数据库和 Redis
      - paperless-ocr-net # 增加 OCR 内部网络,保证能访问 RapidOCR API

networks: # 定义网络
  default: # 保留 Docker Compose 默认网络
  paperless-ocr-net: # 定义 OCR 共享网络
    external: true # 使用外部已创建网络</code>

然后在 Paperless 的环境文件里增加导入后脚本配置。

<code class="language-env">PAPERLESS_POST_CONSUME_SCRIPT=/usr/src/paperless/scripts/post_rapidocr_enhance.sh # 设置导入后执行的增强 OCR 脚本
PAPERLESS_RAPIDOCR_URL=http://rapidocr-api:39090/ocr # 设置 Paperless 容器访问 OCR API 的内部地址
PAPERLESS_RAPIDOCR_TIMEOUT=240 # 设置 OCR API 超时时间,避免大图一直卡住</code>

导入后脚本

创建 post_rapidocr_enhance.sh。它只默认处理图片格式,PDF 仍交给 Paperless 原生 OCR,避免纯 CPU 服务器压力过大。

<code class="language-bash">#!/usr/bin/env bash
# 使用严格模式,让脚本遇到错误立即停止
set -euo pipefail

# 读取 OCR API 地址,如果没有配置就使用默认服务名
OCR_URL="${PAPERLESS_RAPIDOCR_URL:-http://rapidocr-api:39090/ocr}"

# 读取 Paperless 传入的文档 ID
DOCUMENT_ID_VALUE="${DOCUMENT_ID:-}"

# 读取 Paperless 传入的原始文件路径
SOURCE_FILE="${DOCUMENT_SOURCE_PATH:-}"

# 读取 Paperless 传入的原始文件名
ORIGINAL_NAME="${DOCUMENT_ORIGINAL_FILENAME:-}"

# 把文件名转成小写,方便判断扩展名
LOWER_NAME="$(printf '%s' "${ORIGINAL_NAME:-$SOURCE_FILE}" | tr '[:upper:]' '[:lower:]')"

# 只处理常见图片格式,避免大 PDF 批量压垮 CPU
case "$LOWER_NAME" in *.jpg|*.jpeg|*.png|*.tif|*.tiff|*.webp) ;; *) echo "跳过非图片文档:$LOWER_NAME"; exit 0 ;; esac

# 如果文档 ID 为空,则无法写回数据库
if [ -z "$DOCUMENT_ID_VALUE" ]; then echo "缺少 DOCUMENT_ID,跳过增强"; exit 0; fi

# 如果源文件不存在,则无法上传给 OCR API
if [ ! -f "$SOURCE_FILE" ]; then echo "源文件不存在:$SOURCE_FILE"; exit 0; fi

# 创建临时文件保存 OCR API 的 JSON 响应
RESPONSE_FILE="$(mktemp --suffix=.rapidocr.json)"

# 脚本退出时清理临时响应文件
trap 'rm -f "$RESPONSE_FILE"' EXIT

# 调用 OCR API 获取增强识别文本
curl --silent --show-error --fail --max-time "${PAPERLESS_RAPIDOCR_TIMEOUT:-240}" -X POST -F "file=@${SOURCE_FILE}" "$OCR_URL" > "$RESPONSE_FILE"

# 调用 Python 脚本,把增强文本写回 Paperless
python3 /usr/src/paperless/scripts/apply_rapidocr_text.py "$DOCUMENT_ID_VALUE" "$RESPONSE_FILE"</code>

创建 apply_rapidocr_text.py。它会把 RapidOCR 文本写到 Paperless 原内容前面,并保留原生 OCR 文本,方便以后比较和回滚。

<code class="language-python">import json  # 读取 OCR API 返回的 JSON 数据
import os  # 设置 Django 环境变量
import sys  # 读取命令行参数并调整模块路径
from pathlib import Path  # 读取响应文件路径

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")  # 指定 Paperless 的 Django 配置模块
sys.path.insert(0, "/usr/src/paperless/src")  # 加入 Paperless 源码路径

import django  # 导入 Django 初始化模块

django.setup()  # 初始化 Paperless 的 Django 环境

from documents.models import Document  # 导入 Paperless 文档模型
from documents.search import get_backend  # 导入 Paperless 搜索后端

MARKER_START = "--- RapidOCR 增强识别文本 ---"  # 定义增强文本开始标记
MARKER_ORIGINAL = "--- Paperless 原始 OCR 文本 ---"  # 定义原始文本开始标记

def strip_old_block(content: str) -> str:  # 清理旧的增强块,避免重复追加
    if MARKER_START not in content:  # 如果没有旧增强块
        return content.strip()  # 返回原始内容
    if MARKER_ORIGINAL not in content:  # 如果旧内容结构不完整
        return content.replace(MARKER_START, "").strip()  # 尽量移除标记
    return content.split(MARKER_ORIGINAL, 1)[1].strip()  # 返回原始 OCR 文本

document_id = int(sys.argv[1])  # 读取文档 ID
response_path = Path(sys.argv[2])  # 读取 OCR 响应文件路径
payload = json.loads(response_path.read_text(encoding="utf-8"))  # 读取 OCR JSON
rapid_text = str(payload.get("text") or "").strip()  # 提取增强识别文本

if len(rapid_text) >= 4:  # 如果识别文本有效
    document = Document.objects.get(pk=document_id)  # 查询 Paperless 文档
    original_content = strip_old_block(document.content or "")  # 获取原始 OCR 内容
    document.content = f"{MARKER_START}n{rapid_text}nn{MARKER_ORIGINAL}n{original_content}".strip()  # 合并增强文本和原文
    document.save(update_fields=["content"])  # 保存文档内容字段
    with get_backend().batch_update() as batch:  # 打开搜索索引批量更新器
        batch.add_or_update(document)  # 更新 Paperless 搜索索引
    print(f"RapidOCR 已增强文档:{document_id}")  # 输出成功日志
else:  # 如果识别文本太短
    print(f"RapidOCR 文本过短,跳过文档:{document_id}")  # 输出跳过日志</code>

启动与验证

<code class="language-bash"># 启动 RapidOCR API 服务
docker compose up -d

# 检查 OCR API 健康状态
curl http://127.0.0.1:39090/health

# 重启 Paperless,让网络和导入后脚本生效
docker compose up -d

# 在 Paperless 容器内测试访问 OCR API
docker exec paperless-ngx curl http://rapidocr-api:39090/health

# 上传一张中文图片到 Paperless,观察日志是否出现增强成功
docker logs -f paperless-ngx</code>

常见问题

1. 为什么不用 PaddleOCR v3 或 PP-OCRv6?

如果服务器 CPU 较新,PaddleOCR v3 当然可以尝试。但在一些老 Xeon、老 NAS CPU 上,PaddlePaddle 预编译包可能触发 Illegal instruction。这不是模型文件坏了,而是二进制包用了 CPU 不支持的指令。RapidOCR 的 ONNXRuntime CPU 路线更容易在这类机器上跑稳。

2. 已经有本地模型文件,能不能直接复制?

可以,但要注意模型缓存路径和推理框架要对应。PaddleOCR/PaddleX 的缓存不一定能直接给 RapidOCR 使用;RapidOCR 会使用自己的 ONNX 模型缓存。建议把 models 目录持久化,首次下载完成后以后就不用重复下载。

3. 为什么只默认增强图片,不增强 PDF?

PDF 可能几十页甚至上百页,纯 CPU OCR 很容易把服务器打满。更稳的做法是先让 Paperless 原生 OCR 处理 PDF,只把手机拍照、扫描图片、证件照这类中文识别困难的图片交给 RapidOCR。

4. 怎么回滚?

<code class="language-env">PAPERLESS_POST_CONSUME_SCRIPT= # 清空导入后脚本配置即可停止增强 OCR</code>
<code class="language-bash"># 重启 Paperless,让环境变量变更生效
docker compose up -d

# 停止 RapidOCR API 容器
docker compose down</code>

部署建议

  • OCR API 端口只绑定 127.0.0.1,不要直接暴露公网。
  • 用 Docker 内部网络连接 Paperless 和 OCR API,比反向代理更简单也更安全。
  • 给 OCR 容器设置 CPU 和内存限制,避免导入大量图片时影响其他服务。
  • 首次识别会加载模型,速度会慢一点;模型常驻后会明显变快。
  • 处理真实证件、合同、票据时,公开截图前一定要脱敏。
  • 迁移服务器时,备份 Paperless 数据目录、数据库目录、OCR 模型缓存目录和自定义脚本目录。

参考资料

  • Paperless-ngx 配置文档:https://docs.paperless-ngx.com/configuration/
  • Paperless-ngx 高级用法与 post-consume 脚本:https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/advanced_usage.md
  • RapidOCR 项目主页:https://github.com/RapidAI/RapidOCR
  • RapidOCR ONNXRuntime 使用说明:https://github.com/RapidAI/RapidOCRDocs/blob/main/docs/install_usage/rapidocr/how_to_use_infer_engine.md

总结一句:这套方案的价值不是“替代 Paperless”,而是给 Paperless 补上一层更懂中文的识别能力。原系统继续稳定归档,外挂 OCR 专注把中文文本识别得更准,组合起来就很适合个人知识库、家庭文档库和小团队资料库。

喜欢 (0)