minveru_faq
图片一开始要转为pdf,然后pdf又要 pypdfium2 渲染页面 → PIL Image[]转换为PIL Image,为什么要这样做呢?
好问题。这个看似绕圈子的操作确实奇怪。直接看代码里怎么做的:
原因不复杂:这不是重复渲染,而是一个轻量的适配层。
实际发生的事情
# 第一步:Pillow 的 PDF "包装"
image.save(pdf_buffer, format="PDF", resolution=200)
# ↑ 这不是 OCR 重渲染!Pillow 只是把 JPEG/PNG 字节流包了一层 PDF 容器
# 图片数据本身没有变,只是换了个"信封"
# 第二步:pypdfium2 渲染
page.render(scale=scale) # scale = 200/72 = 2.78
# ↑ 从 PDF 容器里取出图片,按指定 DPI 渲染为 PIL Image
为什么不直接用原图?
四个原因,按权重排序:
1. 统一代码路径(最主要)
整个下游管线——doc_analyze_streaming、load_images_from_pdf_doc、append_batch_results_to_middle_json——全是围绕"PDF 文档 + 页码"这个抽象写的。它们用 pdf_doc 对象获取页面数量、页面尺寸、翻页等。
# 如果不用 PDF 统一,就需要两条代码路径:
if is_pdf:
page_count = len(pdf_doc) # 直接翻页
page_size = page.get_size()
else:
page_count = len(image_list) # 自己管理列表
page_size = image.size
# 还有 EXIF 方向、色彩空间、DPI...全部要特殊处理
2. DPI 标准化
原始图片可能来自任何地方——手机拍照(72 DPI)、扫描仪(300 DPI)、截图(96 DPI)。images_bytes_to_pdf_bytes 在包装时统一设为 200 DPI:
image.save(pdf_buffer, format="PDF", resolution=200) # DEFAULT_PDF_IMAGE_DPI = 200
然后 page_to_image 以同样的 200 DPI 渲染,并且限制最长边不超过 3500px:
scale = dpi / 72 # 200/72 ≈ 2.78
if (long_side * scale) > 3500:
scale = 3500 / long_side # 超大图自动缩小
这保证了输入到模型的图片尺寸一致,不会因为原图过大导致显存爆掉。
3. EXIF 方向修正
手机拍的照片经常带 Orientation 标记(旋转 90°/180°),直接送给 OCR 会翻车。Pillow 的 exif_transpose 在转 PDF 时自动纠正:
image = ImageOps.exif_transpose(image) or image # 自动转正
4. 色彩空间统一
Pillow 图片格式五花八门(RGBA、CMYK、P 模式、1-bit),pypdfium2 只稳定处理 RGB。转 PDF 时会统一:
if image.mode != "RGB":
image = image.convert("RGB")
代价有多大?
基本为 0。Pillow 的 save(format="PDF") 本质上就是把 JPEG 位流嵌入到一个 PDF 对象里,不解码不重编码,只是换了个容器。真正的"渲染"在 pypdfium2 那边,那是 PDF 输入也要走的同一条路。
可以理解为:
原图 ──[包一层 PDF 信封]──→ PDF bytes ──[pypdfium2 渲染]──→ PIL Image
↑
真正的 PDF 也从这里走
就是个适配器模式,用 PDF 信封统一了所有输入格式的接口。
为什么最终统一为了PIL Image?我看VLM 推理最终接受的也是PIL Image格式。这样做的考虑是什么
好问题。翻一下代码里 PIL Image 具体是怎么被下游消费的:
代码里两条消费路径很清楚:
pipeline(batch_analyze.py:421-423):
pil_images = [image for image, _, _ in images_with_extra_info] # ← 先拿到 PIL
np_images = [np.asarray(image) for image, _, _ in images_with_extra_info] # ← 一行转 numpy
PIL → numpy:np.asarray() 一行搞定,不拷贝内存。
VLM(vlm_analyze.py:472,479):
images_pil_list = [image_dict["img_pil"] for image_dict in images_list] # PIL
window_results = predictor.batch_two_step_extract(images=images_pil_list) # 直接喂
VLM 的 processor(HuggingFace/vLLM/LMDeploy)原生接受 PIL Image。
为什么不是 numpy?
因为 pypdfium2 的 page.render().to_pil() 直接产出 PIL Image。如果一开始输出 numpy,需要:
bitmap = page.render()
np_img = bitmap.to_numpy() # pypdfium2 没有这个方法,需要手动转换
PIL 是这个链条上的自然产物,不是刻意选择的。如果 pypdfium2 产 numpy,那统一格式就是 numpy 了。
为什么不是文件路径/bytes?
如果统一为文件路径,那 cut_image_and_table()(从整页中裁剪表格/公式区域)就必须频繁读写磁盘——一个 64 页的 batch 可能裁剪出几十个子区域,写磁盘再读回来太慢。
# 现在的做法:内存裁剪,零 IO
for span in all_spans:
if span["type"] in [IMAGE, TABLE, CHART]:
span = cut_image_and_table(span, page_pil_img, ...) # page_pil_img 是 PIL,内存操作
总结
统一成 PIL Image 是 pypdfium2 的输出格式倒逼的,不是主动设计。但它刚好有两个好处——
| 下游 | 消费方式 | 成本 |
|---|---|---|
| pipeline(PyTorch/ONNX) | np.asarray(pil) → tensor | 零拷贝 |
| VLM(transformers) | processor(pil) | 原生接受 |
| 子区域裁剪 | pil.crop(bbox) | 内存操作,无 IO |
| 调试可视化 | pil.save("debug.png") | 随时落盘 |
换成 numpy 的话,VLM 那边要多一次转换;换成文件路径的话,裁剪子区域会慢很多。PIL 恰好是各方都能低成本消费的公约数。