Skip to main content

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 恰好是各方都能低成本消费的公约数。