用40行代码搭建自己的无服务器OCR,处理数学公式教科书只需2美元!
本文介绍如何使用 Modal 无服务器平台 + DeepSeek OCR 模型,低成本处理 PDF 教科书,让扫描版书籍变得可搜索、可复制。
📖 背景
几个月前,我想让我的 Gelman《贝叶斯数据分析》(Bayesian Data Analysis)副本变得可搜索,以便在一个专注于统计学的智能体中使用。
市面上确实有一些相当成熟的 OCR 工具,但它们要么有使用限制,要么在处理数千页文档时费用高得离谱。正好 DeepSeek 最近发布了一个开源 OCR 模型,能够很好地处理数学公式,这让我萌生了自己动手的想法。
但现实很骨感 —— 我的主力电脑还是十年前的 Titan Xp 显卡,早已不支持最新的 PyTorch 版本,根本跑不起来 DeepSeek OCR。
怎么办?答案是 Modal!
🚀 什么是 Modal?
Modal 是一个无服务器计算平台,让你可以在云端运行 Python 代码,完全不用操心服务器运维。对于机器学习场景来说,它的杀手级特性是:可以按需挂载 GPU,只按实际运行秒数付费。
来看看代码有多简洁:
import modal
image = modal.Image.from_registry(
"nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04",
add_python="3.11",
).pip_install("torch", "transformers", ...)
app = modal.App("my-gpu-app")
@app.function(image=image, gpu="A100")
def process_something():
# 这段代码会在 A100 GPU 上运行,所有依赖都已就绪
pass
装饰器模式让 Modal 用起来非常丝滑 —— 你只需要写普通的 Python 代码,在需要 GPU 的函数上加个装饰器,Modal 就会帮你搞定一切:构建容器、申请 GPU、路由请求。对于 OCR 这种任务来说,简直是绝配!
🔧 OCR 脚本实现
核心思路很简单:在 Modal 上部署一个 FastAPI 服务器,接收图片返回 Markdown 文本。下面逐步拆解关键代码。
1️⃣ 定义容器镜像
首先构建一个包含所有依赖的容器。DeepSeek OCR 需要 PyTorch、transformers 和一些图像处理库:
from pathlib import Path
import modal
APP_NAME = "deepseek-ocr-books-api-batch"
ROOT = Path(__file__).resolve().parents[1]
BOOKS_DIR = ROOT / "references" / "books"
PARSED_DIR = BOOKS_DIR / "parsed"
image = (
modal.Image.from_registry(
"nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04",
add_python="3.11",
)
.apt_install("git", "libgl1", "libglib2.0-0")
.pip_install(
"torch==2.6.0",
"torchvision==0.21.0",
"transformers==4.46.3",
"PyMuPDF",
"Pillow",
"numpy",
extra_index_url="https://download.pytorch.org/whl/cu118",
)
)
app = modal.App(APP_NAME)
路径配置放在顶部,方便定位 PDF 文件,保持配置和使用的紧密关联。
2️⃣ FastAPI 端点
接下来是核心部分 —— 将 FastAPI 服务器封装在 Modal 的 @modal.asgi_app() 装饰器中:
@app.function(image=image, gpu="A100", timeout=60 * 60 * 2) # 2小时超时
@modal.asgi_app()
def fastapi_app():
from fastapi import FastAPI, File, UploadFile
from PIL import Image
import torch
from transformers import AutoModel, AutoTokenizer
api = FastAPI()
model_name = "deepseek-ai/DeepSeek-OCR"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModel.from_pretrained(model_name, trust_remote_code=True)
model = model.cuda().to(torch.bfloat16).eval()
💡 关键点:模型在容器启动时只加载一次,后续请求会复用已加载的模型,这对于批量处理数百页文档来说至关重要,大幅提升吞吐量。
⚠️ 注意:
trust_remote_code=True这个参数是必须的,因为 DeepSeek 模型在 HuggingFace 仓库中包含了自定义代码。
3️⃣ 批量推理加速
OCR 是典型的可并行化任务,每一页都是独立的。我们可以在单次模型推理中处理多个页面,比逐页处理快得多:
@api.post("/ocr_batch")
async def ocr_batch(files: list[UploadFile] = File(...)) -> dict[str, list[str]]:
images = []
for file in files:
image_bytes = await file.read()
images.append(Image.open(io.BytesIO(image_bytes)).convert("RGB"))
batch_items = [prepare_inputs(image) for image in images]
texts = run_batch(batch_items)
return {"texts": texts}
run_batch 函数负责实际的模型推理 —— 将输入填充到相同长度,一次性跑完,再解码输出:
def run_batch(batch_items):
# 将序列填充到相同长度
lengths = [item[0].size(0) for item in batch_items]
max_len = max(lengths)
input_ids = torch.full((len(batch_items), max_len), pad_id, dtype=torch.long)
# ... (填充逻辑)
with torch.autocast("cuda", dtype=torch.bfloat16):
with torch.no_grad():
output_ids = model.generate(
input_ids.cuda(),
images=images,
max_new_tokens=8192,
temperature=0.0,
)
# 解码输出
outputs = []
for i, out_ids in enumerate(output_ids):
token_ids = out_ids[lengths[i]:].tolist()
text = tokenizer.decode(token_ids, skip_special_tokens=False)
outputs.append(text.strip())
return outputs
💡 小技巧:设置 temperature=0.0 让输出具有确定性,结果更稳定可复现。
4️⃣ 本地客户端
服务器部署好后,需要一个客户端来投喂页面。用 @app.local_entrypoint() 装饰器标记本地运行的入口函数:
@app.local_entrypoint()
def main(api_url: str, book: str = "", max_pages: int = None, batch_size: int = 1):
import fitz # PyMuPDF
if book:
pdf_paths = [BOOKS_DIR / book]
else:
pdf_paths = sorted(BOOKS_DIR.glob("*.pdf"))
for pdf_path in pdf_paths:
with fitz.open(pdf_path) as doc:
batch_pages = []
for page_index in range(doc.page_count):
page = doc[page_index]
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2倍缩放
batch_pages.append(pix.tobytes("png"))
if len(batch_pages) >= batch_size:
# 发送批次到服务器
response = requests.post(f"{api_url}/ocr_batch", files=files)
texts = response.json()["texts"]
# 保存结果...
💡 重要技巧:fitz.Matrix(2, 2) 这个 2 倍渲染技巧很关键!更高分辨率的输入意味着 OCR 模型能更准确地识别小号文字和数学下标。
5️⃣ 输出清理
DeepSeek OCR 输出会带有定位标签(grounding tags),标注文本在页面上的坐标位置。这些对某些场景有用,但纯文本搜索不需要:
tag_pattern = re.compile(
r"<\|ref\|>(.*?)<\|/ref\|><\|det\|>.*?<\|/det\|>",
flags=re.DOTALL,
)
for page_idx, text in zip(batch_page_indices, texts):
text = tag_pattern.sub(r"\1", text) # 保留文本,丢弃坐标
page_path = pages_dir / f"page_{page_idx + 1:04d}.mmd"
page_path.write_text(text, encoding="utf-8")
.mmd 扩展名代表 "多模态 markdown",表示来自 OCR 源可能带有些许瑕疵的 markdown 文件。
🏃♂️ 实际运行
第一步:部署服务器
modal deploy deepseek_ocr_modal.py
部署后会得到一个 URL,类似:
https://your-workspace--deepseek-ocr-books-api-batch-fastapi-app.modal.run
第二步:运行客户端
modal run deepseek_ocr_modal.py \
--api-url "https://..." \
--book "Gelman - Bayesian Data Analysis.pdf"
📊 实测效果
处理《贝叶斯数据分析》这本约 600 页的书:
| 指标 | 数值 |
|------|------|
| 处理时间 | 约 45 分钟 |
| GPU 型号 | A100 |
| 批处理大小 | 4 |
| 总花费 | 约 2 美元 |
输出结果:一个 markdown 文件目录(每页一个文件),加上合并后的 document.mmd 文件(带页码标记)。
OCR 质量展示
数学公式的识别质量相当惊艳!来看看实际效果:
OCR 输出示例:
## 指数模型
指数分布通常用于建模"等待时间"和其他连续、正实值随机变量,通常在时间尺度上测量。给定参数θ,结果y的抽样分布为
p(y|θ) = θ exp(-yθ), 当y > 0,
其中θ = 1/E(y|θ)被称为"率"。从数学上讲,指数分布是伽马分布的一个特例,参数为(α,β) = (1,θ)。
指数分布具有"无记忆"特性,使其成为生存或寿命数据的自然模型;对象存活额外时间长度t的概率独立于到此时已过去的时间:Pr(y > t + s | y > s, θ) = Pr(y > t | θ) 对于任何s,t。
教科书原文截图:
对比来看,OCR 效果相当不错!
🎯 后续玩法
有了可搜索的 markdown 文本,能做的事情就多了:
-
✅
grep全文搜索关键概念 -
✅ 复制章节内容让 AI 解释符号含义
-
✅ 构建向量索引,打造知识库问答
-
✅ 批量处理课程笔记、论文、其他教材
从原本只是一堆图片的 PDF,变成了真正可用的数据资产!
📝 总结
这个方案的核心优势:
-
成本低 - 约 2 美元处理 600 页,比商业 OCR 服务便宜得多
-
质量高 - DeepSeek OCR 对数学公式识别效果出色
-
可复用 - 一次搭建,适用于各种 PDF 文档
-
易上手 - 核心代码不超过 40 行
如果你也有扫描版教科书或技术文档需要处理,花 5 分钟搭一套试试,绝对值!
🔗 参考链接
如果这篇文章对你有帮助,欢迎点赞收藏,有问题评论区见~ 👋