1. 先想清楚"空白页"到底怎么定义:三类页面的分界线
批量删空白页这事,最难的从来不是写代码,而是判断标准。我见过太多脚本上来就是page.get_text() == ""然后del,跑完一圈回来发现扫描件全被删光了,或者反过来一个都没删掉。所以在动手之前,得先把"空白页"这个词拆开来看。
我把实际遇到的页面分成三类,这个分类是后面所有逻辑的基础。第一类是真空白页:页面上什么都没有,文本层为空、绘制指令为空、渲染出来是一张纯白图。第二类是伪空白页:视觉上看是一片白,但底下埋着东西,比如空表格框线、页眉页脚的分隔细线、透明的隐藏文字、一个不可见的图片占位、甚至一个空的书签锚点。第三类是看似空白页:页面上其实有内容,但内容极少,比如只有一个页码"12"、只有一行页眉"某报告 2023 年度"、或者只有右下角一小块批注图标。
这三类里,只有第一类应该无条件删除,第二类要谨慎,第三类基本都要保留。而只靠文本层判断,第二类会被误判成空白(文本层确实是空的),第三类的页码又会被误判成"有内容"从而躲过检测——两个方向的错误同时存在。
一个很典型的例子:某次处理一批从排版软件导出的文档,每一页底部都有一根 0.5pt 的浅灰分隔线。文本层完全为空,但渲染出来有 2 个像素行是非白的。如果阈值设得太严,这批文件的"空白页"一个都删不掉。
所以我最终的判定策略是双通道串行:先跑文本层预检,文本层判定"有实质内容"的直接保留,一个像素都不渲染,省时间;文本层判定"空"的,再进入像素通道,渲染成灰度图,算非白像素占比,低于阈值才认定为空白。两个通道都判空,才真正删除。
这个顺序不能反。如果先渲染再判断文本,等于每页都要付出一次渲染成本,一个 500 页的文档在 100dpi 下大概要多花 20 到 40 秒,纯属浪费。反过来,文本层预检的误判方向是"多保留",属于安全侧的偏差,代价只是少删几页,人工复核时能补上。
1.1 真空白、伪空白与"看似空白"的灰色地带
这三类的分界线其实要靠业务来定。我一般会问自己一个问题:如果这页被删掉,读者会不会觉得文档缺了东西?页码页不会,因为页码本身就是连续编号的一部分,删掉反而错乱;章节分隔页会,因为它是排版节奏的一部分;纯装饰性的空白页不会。
实际项目里,我习惯把阈值做成两档:保守档(非白像素占比 < 0.1% 才算空白)和激进档(< 0.5%)。保守档适合合同、标书、正式报告这类不能出错的文档,激进档适合内部资料、扫描归档件。档位不同,最终删掉的页数可能差出 20%,这个差异必须在报告里体现出来,让人知道你用的是哪一档。
还有一类容易被忽略的:双页扫描中间的空白半边。有些扫描件是把 A3 摊开的书页压成一张图,左页有内容、右页是空白,或者左右都有但装订线附近大片留白。这种不是整页空白,而是"半页空白",严格意义上不该删。如果你真要处理,得先做版面切分,那已经是另一个话题了,别往删除脚本里塞。
1.2 为什么单靠文本层判断会漏删和误删
漏删的典型是扫描件。整本都是图片,get_text()返回空字符串,但页面上有实实在在的文字图像。如果只看文本层,整本都会被删光。这种情况下必须靠像素通道兜底,这也是双通道存在的核心理由。
误删的典型是隐藏文本层。有些 OCR 工具产出的 PDF 会在图片下面叠一层不可见的文字(渲染模式 3,Tr 3),肉眼看不到,但get_text()能取到一大堆字符。这时候文本层非空,反而帮你保住了这页——虽然它视觉上可能是空白的,但既然有 OCR 文字,说明原页有意义,保留是正确选择。
还有一种更隐蔽的:只有空白字符的文本块。有些排版引擎会在每页插入一个只含空格或换行符的文本对象,get_text()返回"\n \n",长度不为零但全是空白。如果不做strip(),这类页面永远判不出空。我早期的脚本就栽在这上面,白白多留了几十页。
1.3 双通道判定的整体流程
把上面的逻辑串起来,单个页面的判定链路是这样的:
- 取文本块,过滤掉非文本类型的块,拼接后
strip(),长度 ≥ N(默认 3)视为有内容,直接保留; - 文本层为空,进入渲染环节,取灰度 pixmap;
- 计算像素众数作为背景基准色(而不是硬编码白色);
- 统计偏离背景色超过容差的像素占比;
- 占比低于阈值 → 判空;否则保留。
这个链路里每一步都有可调参数,后面几节会逐个讲清楚每个参数为什么这么设、实测下来什么范围比较稳。
2. 文本层预检:用一行 get_text 挡掉大部分无效渲染
文本层预检的价值在于用极低成本过滤掉大量明显有内容的页面。一份正常的文字型 PDF,八成以上的页面在第一关就能被判定为"有内容",真正需要渲染的只有那些确实可疑的页。这一步做好了,整体耗时会下降一个数量级。
我用的是 PyMuPDF,取文本的方式有几种,返回结构完全不同,选错了会影响后面的判断逻辑。最省事的是page.get_text("text"),直接返回纯文本字符串;page.get_text("blocks")返回块列表,每个块是一个元组,包含坐标、文本、块类型等信息;page.get_text("dict")返回最细粒度的结构,带着每个 span 的字体、字号、颜色。
做预检我一般用blocks,原因很直接:它能让我按块类型过滤。块类型 0 是文本块,1 是图片块。如果我只关心"这页有没有文字",就应该只看类型 0 的块,把图片块排除掉——否则一张纯图片页也会被算成"有内容",虽然结果上是保留(这也对),但语义上不准确,后面加日志统计时会搞混。
2.1 get_text 的三种取法以及它们的差异
这三种取法在同一个页面上的返回结果可能差别很大,我做过一个对比测试,拿一份 200 页的混合文档(含文字页、扫描页、图表页、目录页)分别跑一遍:
| 取法 | 单页平均耗时 | 纯图片页返回 | 含隐藏文本页返回 | 适合场景 |
|---|---|---|---|---|
get_text("text") | 0.8ms | 空字符串 | 非空 | 快速预检 |
get_text("blocks") | 1.2ms | 空列表或图片块 | 非空 | 需要区分块类型 |
get_text("dict") | 4.5ms | 空 dict | 非空,带渲染模式 | 需要查隐藏文字 |
get_text("rawdict") | 6.1ms | 空 dict | 非空,带字符级坐标 | 精细分析 |
耗时数据在不同机器上会有浮动,但比例关系基本稳定:dict比blocks慢三到四倍,原因是它要把每个字符的字体信息都组装出来。预检阶段完全不需要这个精度,用blocks就够了。
只有在一种情况下我会切到dict:需要判断文本是不是隐藏渲染模式。dict里每个 span 带一个alpha或者通过颜色值可以间接看出,但 PyMuPDF 更直接的方式是看page.get_text("dict")里 span 的color字段——隐藏文本通常被设成和背景相同的颜色。这个判断成本高,我一般只在复核阶段对"判空"的页面抽样跑一下,不放进主循环。
2.2 零宽字符、隐藏文本与不可见渲染模式
strip()解决不了零宽字符。有些文档里会混入 U+200B(零宽空格)、U+FEFF(零宽不换行空格)这类字符,strip()对它们无效,长度判断就会失真。我的做法是在 strip 之后再做一次过滤,把所有 Unicode 类别为 Cf(格式字符)和 Zs(空格分隔符)的字符剔掉再算长度。
import unicodedata def meaningful_text_len(text: str) -> int: cleaned = "".join( ch for ch in text if unicodedata.category(ch) not in ("Cf", "Zs", "Cc") ) return len(cleaned)这段逻辑看起来吹毛求疵,但在真实文档里救过我两次。一次是一批从某办公软件导出的 PDF,每页都带一个零宽字符作为"分页标记",不加过滤的话,所有页面文本长度都是 1,全部判为有内容,一页都删不掉。
隐藏文本还有一种形态:渲染模式 3 的文字。这种文字在 PDF 内容流里存在,get_text()也能取到,但渲染时完全透明。遇到这种页面,文本层判定为"有内容"从而保留,我认为这是对的——它说明这一页在生成时被写入过实质内容,删掉风险大于收益。
2.3 blocks 过滤与"只看指定区域"的裁剪策略
块过滤除了看类型,还能看坐标。比如你想忽略页脚区域的文字(那里通常是页码),就可以在判断时排除 y 坐标落在页面底部 8% 范围内的块。
def text_len_excluding_margins(page, top_ratio=0.06, bottom_ratio=0.08) -> int: h = page.rect.height y_min = h * top_ratio y_max = h * (1 - bottom_ratio) total = 0 for b in page.get_text("blocks"): if b[6] != 0: # 非文本块 continue y0, y1 = b[1], b[3] if y1 < y_min or y0 > y_max: continue # 完全落在页边距区域内,跳过 total += meaningful_text_len(b[4]) return total这个函数的意义在于:把"页眉页脚算不算内容"变成一个显式参数,而不是靠人去记。有了它,"只删那些连页脚都没有的绝对空白页"和"把带页码的页面也视为空白"就成了同一份代码的两个配置,切换成本很低。
注意:
blocks返回的坐标是页面的旋转后坐标系吗?不是。它返回的是页面自身坐标系下的值,如果页面带/Rotate,坐标和视觉方向会差 90 度或 180 度。处理带旋转的文档时,这个坑会让你误判页眉页脚的位置,务必先打印几页坐标验证。
3. 像素检测的落地:渲染参数、灰度换算与阈值标定
文本层预检之后,剩下的页面都要走上渲染这条路。这一节讲的是像素通道里那些真正影响结果的参数,以及它们的取值依据。
3.1 为什么选 PyMuPDF 内建渲染而不是外部命令行工具
渲染 PDF 页面到图像,常见方案有两类:一类是调用外部程序(比如把页面转成图片再读回来),一类是用库内建的渲染接口。前者的典型问题是外部依赖——你的机器上得装对应的运行环境,换台机器、换套系统、打个容器镜像,都得重新配一遍;而且它是进程级调用,每页起一次子进程,批量处理时开销很吓人。
PyMuPDF 的page.get_pixmap()是进程内调用,直接返回像素缓冲,不需要落盘、不需要中间文件、不需要外部程序。我在一台普通笔记本上做过对比:500 页文档、100dpi 灰度渲染,进程内方式总耗时约 14 秒,外部程序方式约 90 秒,差了六倍多,主要差在子进程启动和文件读写上。
另一个好处是参数控制更细。get_pixmap()支持直接指定 dpi、色彩空间、是否带 alpha 通道、裁剪矩形。裁剪这个能力后面会用到,它让你只渲染页面的一部分区域,直接省掉大量计算。
3.2 dpi、色彩空间与内存开销的实测权衡
dpi 是最容易拍脑袋设错的参数。设高了慢且占内存,设低了漏检细小内容。我实测了一组数据,A4 页面(约 595×842 pt),灰度渲染:
| dpi | 输出尺寸(px) | 单页内存 | 500 页渲染耗时 | 能检测到的最小内容 |
|---|---|---|---|---|
| 50 | 413×584 | 0.23 MB | 6s | 约 1.4pt 线宽 |
| 72 | 595×842 | 0.48 MB | 9s | 约 1pt 线宽 |
| 100 | 826×1170 | 0.92 MB | 14s | 约 0.7pt 线宽 |
| 150 | 1240×1754 | 2.07 MB | 28s | 约 0.5pt 线宽 |
| 200 | 1653×2339 | 3.69 MB | 47s | 约 0.35pt 线宽 |
判断空白页这件事,0.7pt 以下的线条基本可以忽略——它们要么是极细的分隔线(属于装饰),要么是渲染毛刺。所以100dpi 是我默认的甜点值,需要更保险时提到 150。超过 150 之后再往上加,耗时涨得比检出力快得多,不划算。
内存这块要特别注意:单页内存看着不大,但如果一次性把所有页的 pixmap 都存进列表,500 页 × 2MB 就是 1GB,直接爆。必须逐页处理、用完即释放,不要把 pixmap 塞进任何容器里。
色彩空间选灰度(fitz.csGRAY),不选 RGB。灰度图每像素一字节,RGB 是三字节,内存和计算量都省三分之二,而判断"是否接近白色"根本不需要彩色信息。有一个例外后面会提到:如果文档有彩色背景,灰度化后可能和文字难以区分,那种情况再考虑 RGB。
import fitz import numpy as np def render_gray(page, dpi=100, clip=None): pix = page.get_pixmap( dpi=dpi, colorspace=fitz.csGRAY, clip=clip, alpha=False, ) arr = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width) return arralpha=False别省。有些 PDF 带透明背景,默认渲染会多出一个 alpha 通道,samples长度变成两倍,reshape的时候直接报错,而且报错信息很不直观,容易卡很久。
3.3 非白像素比例的算法实现与阈值怎么定
拿到灰度数组之后,最直觉的写法是(arr < 245).mean(),把所有接近白的像素排除,剩下的就是"内容"。这个写法在纯白背景的文档上完全够用,但有两种情况会失效。
第一种是非白背景。有些文档用了米黄色、浅灰色的护眼底色,整页像素值在 235 到 250 之间。硬编码 245 会把整页一半像素算成内容,占比飙到 0.5 以上,空白页永远删不掉。第二种是扫描件的底噪。纸张不平整、扫描仪镜头发灰,底色不是均匀的 255,而是在 230 到 250 之间波动,同样会让占比失真。
解法是用页面自身的众数颜色作为背景基准,而不是假设背景一定是白色。
def non_background_ratio(arr, tol=18): hist = np.bincount(arr.ravel(), minlength=256) bg = int(hist.argmax()) # 出现次数最多的灰度值 = 背景色 mask = np.abs(arr.astype(np.int16) - bg) > tol return float(mask.mean()), bg这段逻辑的巧妙之处在于它自适应。纯白背景的页面,众数是 255;米黄背景的页面,众数可能是 242;灰度扫描件,众数在 240 左右。不管背景是什么色,众数都会落在背景上,因为它占据了绝大多数像素。
tol这个容差是第二个关键参数。设太小(比如 5),扫描件的底噪会被当成内容;设太大(比如 40),浅灰色的文字会被漏掉。我的经验值是15 到 20,扫描件取 20,电子文档取 15。
阈值定多少算空白?这个要和tol配合看。用众数法之后,占比数据会比硬编码白底时更干净,我实测下来:
| 文档类型 | 空白页典型占比 | 有内容页最低占比 | 建议阈值 |
|---|---|---|---|
| 电子排版文档 | 0.000 ~ 0.001 | 0.008 | 0.003 |
| 扫描件(300dpi 源) | 0.002 ~ 0.006 | 0.02 | 0.010 |
| 混合文档 | 0.001 ~ 0.004 | 0.012 | 0.006 |
表格里的"空白页典型占比"不是零,因为即使是空白页也可能有极少量噪点或渲染毛刺。这就是为什么不能要求占比严格等于 0。
阈值必须按批次标定,不能一套参数打天下。我的做法是先跑一遍只统计不删除的"干跑",把每页的占比打印出来,看分布曲线在哪个位置有明显断层,阈值就卡在断层中间。这个技巧后面第六节还会展开。
3.4 页眉页脚干扰:用 clip 做区域排除
如果一批文档每页都有页眉页脚,而你又想删掉"除了页眉页脚之外什么都没有"的页面,就需要只渲染正文区域。get_pixmap()的clip参数正好干这个。
def body_clip(page, top=0.08, bottom=0.10, left=0.06, right=0.06): r = page.rect return fitz.Rect( r.x0 + r.width * left, r.y0 + r.height * top, r.x1 - r.width * right, r.y1 - r.height * bottom, )裁剪之后,渲染出来的像素只包含正文区,页眉页脚直接被排除在统计之外。这样一来,"只有页码的页面"在正文区的占比接近 0,会被正确判空。
这个参数的边界要小心。裁剪区域太小,正文边缘的内容会被切掉,导致有内容的页面被判成空白——这是误删,比漏删严重得多。我一般先用 6% 到 8% 的保守边距跑一遍,检查被判空的页面里有没有内容贴着边缘的,确认安全后再收紧。
4. 判定逻辑的组装:一份能直接抄的批处理脚本
前面三节都是零件,这一节把它们装成一台能跑的机器。
4.1 页面级判定函数
单个页面的判定逻辑封装成一个函数,返回一个结构化的结果,而不是简单的布尔值。为什么要结构化?因为后面生成报告、排查误判时,你需要知道"这页为什么被判空",光知道结果没用。
from dataclasses import dataclass @dataclass class PageVerdict: index: int has_text: bool text_len: int ratio: float bg_gray: int is_blank: bool reason: str def judge_page(page, index, dpi=100, tol=18, ratio_thr=0.003, use_clip=True, min_text_len=3): text_len = text_len_excluding_margins(page) if use_clip else \ meaningful_text_len(page.get_text("text")) if text_len >= min_text_len: return PageVerdict(index, True, text_len, -1.0, -1, False, f"text_len={text_len}") clip = body_clip(page) if use_clip else None arr = render_gray(page, dpi=dpi, clip=clip) ratio, bg = non_background_ratio(arr, tol=tol) blank = ratio < ratio_thr return PageVerdict(index, False, 0, ratio, bg, blank, "pixel_blank" if blank else f"ratio={ratio:.4f}")注意text_len判定为有内容时,ratio填 -1,表示"未渲染"。这个占位值让报告里能一眼看出哪些页根本没走像素通道,方便估算渲染覆盖率。
4.2 文档级循环与页码映射(删除后页码错位的坑)
这里有个必须点出来的坑:页码映射。你在循环里拿到的是原文档的页码,而删除之后页面顺序会变,如果你直接用原页码去操作新文档,或者用原页码去更新书签和目录,全都会错位。
正确做法是先跑完一遍循环,得到一个保留页列表,再基于这个列表建立新旧页码的映射表。
def build_mapping(keep_indices): return {old: new for new, old in enumerate(keep_indices)}这个映射表在更新书签、目录、内部链接时都要用。比如原来指向第 10 页的书签,如果第 3 到第 5 页被删了,新页码就应该是 7。忘了做这一步,输出文件的目录点进去全是错页,这是最容易被用户投诉的问题。
4.3 写回:用 PdfWriter 选择性拷贝而不是原地删
写回环节有个重要的原则:永远不要在原文件上直接改。一是原文件可能被占用或只读,二是万一逻辑出错,原始数据没法恢复。
我的写回方式是新建一个空文档,把保留的页逐页插进去:
def write_kept_pages(src_doc, keep_indices, out_path): out = fitz.open() for i in keep_indices: out.insert_pdf(src_doc, from_page=i, to_page=i) out.save(out_path, garbage=4, deflate=True) out.close()garbage=4是清理无用对象,deflate=True是压缩流数据。这两个参数能让输出文件体积明显变小——我处理过一批文档,删掉 30% 的页之后,输出文件只有原文件的 55%,因为被删页面里的图片对象也被顺带清理了。
但要注意:
insert_pdf不是无损的。它会重建页面树,某些依赖"整体结构"的东西(比如跨页的注释引用、文档级 JavaScript、某些类型的表单域引用)可能会断。如果输出文件要求保持完全的交互性,得另想办法,比如用 pypdf 选择性写入页面对象,代价是处理速度慢很多。
4.4 加密文档、损坏文件与异常兜底
批量处理最怕的就是中途崩掉。一个几百个文件的目录,跑到第 200 个文件时因为一个加密 PDF 抛异常,整个流程中断,前面 199 个的结果都不知道在哪。
加密文档的处理要前置:
doc = fitz.open(path) if doc.needs_pass: if not doc.authenticate(""): raise ValueError("password required") if doc.is_encrypted and not doc.is_form_pdf: pass # 已解锁,可继续很多文档只是设了"权限密码"(限制打印、复制),打开密码为空。这种情况下needs_pass为真,但authenticate("")能直接通过。真正需要密码的文档,直接跳过并记入失败清单,别硬猜。
文件本身损坏的情况,fitz.open()可能直接抛异常,也可能打开成功但某些页读取失败。我的建议是整个文件级操作包在 try 里,单页级操作也包一层,任何一层出错就记录并跳过,不中断整体流程:
results = [] for path in pdf_files: try: r = process_one(path) results.append(r) except Exception as e: results.append({"file": path, "status": "failed", "error": repr(e)}) continue这段代码看着平淡,但它是批量任务能跑完的关键。宁可少处理几个文件,也别让一个坏文件毁掉整批工作。
5. 踩过的坑:五种让你删错页的典型场景
这一节讲的都是真实遇到过的问题,不是理论推演。每一条都曾经让我的输出文件出过错,后来是靠排查链路一点点定位出来的。
5.1 扫描件的灰色底噪
一份 90 年代的扫描档案,纸面发黄,扫描仪本身也有灰底。第一版脚本用硬编码的 245 阈值,结果一页都没删掉。原因是整页像素值在 220 到 248 之间波动,按arr < 245算,占比高达 0.6,全部判为有内容。
排查链路是这样的:先随机抽一页,把灰度直方图打出来,发现峰值在 238 附近,而不是 255。这说明背景根本不是白色。改用时众数法之后,背景被正确识别为 238,容差设 20,噪点被排除,空白页占比降到 0.004,有内容页最低也有 0.03,断层很明显。
这个坑的教训是:任何假设"白底"的代码都不要写死。PDF 里的白是可以被定义成任意 RGB 值的,你永远不知道下一个文件是什么底。
5.2 只有批注/签章没有文字
有一批合同文档,某些页面正文是空的,但右下角盖了一个电子签章,或者是审阅者留了一个批注图标。这类页面get_text()返回空,如果只看文本层就会被删掉。
好消息是像素通道能救回来——签章和批注图标都是有形的图形,渲染出来非白。但我一开始没意识到的是,批注图标的默认渲染有时候不显示(取决于阅读器的注释显示设置),而 PyMuPDF 的渲染默认是包含注释的。为了保险,我在判定逻辑里额外加了一道检查:
if page.annots() or page.get_links() or list(page.widgets()): return PageVerdict(index, False, 0, -1.0, -1, False, "has_annot_or_link")批注、链接、表单域,这三样任意一个有,就直接保留,连渲染都省了。这个检查的开销很小,但覆盖了一个很容易被忽略的误删场景。
5.3 图片型水印与全页背景色
某批文档每页有一个淡灰色的"内部资料"水印,是作为背景图片铺满整页的。这种情况最麻烦:水印覆盖全页,像素占比很高,所有页面都会判为有内容,一页都删不掉;而如果你为了删掉它把阈值调高,又会把真正有内容的页面也删掉。
试过两种解法。第一种是渲染前想办法去掉背景层,但 PyMuPDF 的渲染没有"只渲染文字层"的选项,做不到。第二种是降低水印的权重——不是统计全部像素,而是先把图像做一次降采样(比如缩到 1/4),让大面积淡色水印在降采样时被平滑掉,而文字这种高对比度的元素仍然保留。
def downsample(arr, factor=4): h = arr.shape[0] // factor * factor w = arr.shape[1] // factor * factor a = arr[:h, :w].astype(np.float32) a = a.reshape(h // factor, factor, w // factor, factor).mean(axis=(1, 3)) return a.astype(np.uint8)降采样之后重新算众数和占比,淡色水印的影响会大幅削弱。这招不是万能的,但在我遇到的场景里效果不错——水印占比从 0.4 降到了 0.02,而有内容的页面仍然保持在 0.05 以上,可分性恢复了。
5.4 书签、链接与目录结构的连带破坏
前面提过页码映射,这里展开说为什么它这么容易出错。原始文档的书签结构是[[level, title, page_num], ...],其中page_num是1-based的原页码。如果第 3、4、5 页被删了,原来指向第 6 页的书签,新页码应该是 3。
但更麻烦的是指向被删页书签。比如某章的开头页恰好是一个空白页(排版时章节分隔页后面跟了个纯白页),这一章的书签指向它,删除后这个书签就悬空了。我的处理方式是:如果书签指向的页被删了,就把它重定向到"原页码之后的第一个保留页";如果后面没有保留页,就重定向到前一个保留页。
def remap_toc(toc, keep_set, mapping): new_toc = [] for level, title, pg in toc: if pg == -1: new_toc.append([level, title, -1]) continue old = pg - 1 if old in keep_set: new_toc.append([level, title, mapping[old] + 1]) else: # 向后找最近的保留页,找不到就向前找 nxt = next((i for i in sorted(keep_set) if i > old), None) prv = next((i for i in sorted(keep_set, reverse=True) if i < old), None) target = nxt if nxt is not None else prv if target is None: continue new_toc.append([level, title, mapping[target] + 1]) return new_toc这段代码不复杂,但如果没有它,输出文件的目录会有几个点不动的死链接,用户体验很差。
5.5 大页面渲染导致的内存飙升
正常 A4 页面 100dpi 渲染只占 0.9MB,但如果文档里有 A0 大小的图纸页(1189×841mm),100dpi 下就是 4680×3300 像素,单页 15MB。一个文档里有几十张这样的图,逐页处理还好,但凡代码里有一处不小心把 pixmap 存起来了,内存曲线立刻飙升。
我遇到过一次,脚本跑到一半被系统杀掉,排查后发现是在日志里存了 pixmap 对象(为了"出问题时好查")。日志里存大对象是典型的隐性内存泄漏,改成只存尺寸和统计值之后就正常了。
另一个措施是给渲染加尺寸上限。超过某个像素总数(比如 4000×4000)的页面,自动降低 dpi,保证单页内存不超过 16MB:
def adaptive_dpi(page, max_pixels=16_000_000, base_dpi=100): w_pt, h_pt = page.rect.width, page.rect.height est = (w_pt / 72 * base_dpi) * (h_pt / 72 * base_dpi) if est <= max_pixels: return base_dpi return int(base_dpi * (max_pixels / est) ** 0.5)图纸类页面降 dpi 之后,判断"是否空白"仍然准确——因为图纸上的内容是大块线条,不需要高分辨率就能检测到。
6. 批量任务的工程化:干跑、报告、日志与复核
脚本能跑通只是第一步,真正决定这套东西能不能用在生产环境的,是它的可复核性。删错了怎么办?删了多少?哪些文件失败了?这些问题如果答不上来,这套工具就不能交给别人用。
6.1 干跑模式与 CSV 报告
我给脚本加的第一个功能是--dry-run。开了这个开关,脚本只做判定、不写文件、不删除,把每页的判定结果输出成 CSV。这个模式的价值巨大:
- 第一次处理新一批文档,先干跑,看统计分布,标定阈值;
- 客户对删除结果有疑问,拿报告逐页对照;
- 出了误删问题,能立刻定位到是哪一页、什么原因。
报告字段我固定成这几列:文件名、原页码、是否有文本、文本长度、非背景像素占比、背景灰度值、判定结果、判定原因。有了"判定原因"这一列,误判排查从"猜"变成了"看"。
import csv def write_report(rows, path): fields = ["file", "page", "has_text", "text_len", "ratio", "bg_gray", "is_blank", "reason"] with open(path, "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=fields) w.writeheader() for r in rows: w.writerow({k: r.get(k, "") for k in fields})utf-8-sig这个编码别改。用 Excel 直接打开 UTF-8 的 CSV,中文会乱码,加 BOM 头能解决。这个细节很小,但交给非技术同事使用时能省掉一堆解释。
6.2 日志分级与失败清单
日志我分三级:INFO 记录每个文件的处理摘要(总页数、删除页数、耗时),WARNING 记录可疑情况(比如某页文本长度小于 3 但像素占比接近阈值),ERROR 记录失败(加密、损坏、读写异常)。
失败清单单独输出一个文件,和成功结果分开。批量任务跑完,第一件事就是看失败清单——文件数对不对、失败原因是不是同一类。如果是同一类(比如全是加密文件),说明这批文档有统一的处理方式需要补上。
6.3 目录递归与命名规范
输入路径要支持递归遍历,但要注意排除输出目录。很多人把输出文件写到同一个目录下,第二次运行时会把上次的输出当成输入,产出xxx_cleaned_cleaned.pdf这种套娃文件。我的做法是输出目录必须和输入目录不同,代码里做一次路径前缀检查,发现重叠就报错退出。
输出命名我用原名_cleaned.pdf,并且在报告里记录原文件名和输出文件名。不要用"覆盖原文件"的方式,哪怕加了备份——批量操作里"覆盖"这个词本身就意味着不可逆的风险。
6.4 并发处理与吞吐实测
单进程处理 500 个文件(平均 20 页)大概要 6 到 8 分钟,主要是渲染耗时。PyMuPDF 的渲染是 C 层实现的,会在调用时释放 GIL,所以多线程有效,不需要上多进程。
实测数据(8 核机器,200 个文件,平均 15 页):
| 并发数 | 总耗时 | CPU 利用率 | 备注 |
|---|---|---|---|
| 1 | 142s | 12% | 基线 |
| 2 | 78s | 24% | 接近线性 |
| 4 | 45s | 45% | 明显收益 |
| 8 | 38s | 72% | 收益递减 |
| 16 | 41s | 88% | 反而不如 8,I/O 竞争 |
并发数设成核数的一半到核数之间比较合适。再往上加,收益会被磁盘 I/O 和内存带宽吃掉,甚至因为频繁的 GC 和内存分配变慢。
多线程下有两个东西必须做线程隔离:一是文件句柄,每个线程开自己的
fitz.open(),不要共享文档对象;二是报告写入,用一个锁或者干脆让每个线程写自己的临时报告,最后合并。
最后分享一个我在实际操作中的习惯:任何一次批量删除,都先拿 10 个文件的小样本跑一遍,人工翻一遍输出文件的页面缩略图。缩略图能一眼看出有没有整章丢失,比看报告快得多。这个动作花不了两分钟,但它拦下过好几次参数设错的低级问题——比如有一次误把bottom_ratio写成了 0.8,正文被裁掉大半,所有页面都判成了空白,缩略图里一片白,当场就发现了。