PDF 扫描件压缩(整页重建)
铁律:分辨率与 dpi 一律不动
默认且默认情况下唯一正确的做法是保留源图像的原始像素尺寸。 本 skill 的
layout_fun 从源页面物理尺寸反推 dpi,所以「换编码」本身不会改变分辨率——
输出 PDF 的页面尺寸和图像分辨率都与原件一致。
- 压缩体积只能靠换编码 / 降位深 / 灰度域降噪,不能靠砍像素。
--max-edge是破坏性降采样,会真实降低 dpi(301→221 等)。只有在用户明确
说「可以降分辨率」时才允许使用;用户只给体积上限(如「压到 100MB 以下」)时,
绝不能自作主张加--max-edge。- 体积上限达不成时,正确顺序是:① 换更省的编码档位(png2→png1→g4→best1);
② 报告实测数字与各档位代价,把选择权交回用户;③ 不要用降采样偷偷兜底。
体积指标达标 ≠ 可以牺牲清晰度。
用户在本 skill 上的明确偏好(2026-10-14):禁止调整 dpi / 分辨率。
用途
把「每页就是一张大图」的 PDF(扫描书、影印件、无文字层的图片型 PDF)压到极致。
核心不是调 JPEG 质量,而是换编码:1-bit CCITT Group4 传真压缩。
实测基准(341 页灰度扫描书 238.46MB → 山大小见 references/benchmark.md):
| 方案 | 折算全书(60 页均匀抽样) |
|---|---|
| JPEG 降采样 1600px q82 | 83.59 MB (-64.9%,实测全量) |
| CCITT G4(本 skill 默认) | 23.47 MB (-90.2%,实测全量) |
| PNG 1-bit(Flate) | 24.2 MB(与 G4 互有胜负,14/60 页更小) |
| PNG 2-bit(4 级灰) | 97.3 MB —— 全书统一用是 G4 的 3.8 倍、0/60 页胜出;但连续调页保真度是 G4 的 4 倍(MAE 12.75 vs 51.01),所以 mix 策略专门用它处理插图页 |
| PNG 4-bit(16 级灰) | 277 MB —— 比源还大,不要用 |
> 8-bit/256 级 PNG 已移除(实测 277MB,无实用价值)。--levels 只作用于 png4,
> 位深按级数自动取 1/2/4/8。
G4 还额外保留了原始分辨率(本例 2058×2778 / 284dpi),文字比降采样方案更清晰。
第二组基准:272 页 RGB 扫描教材 248.47MB(16 页抽样折算)
这本几乎全是纯文字页(二值化代价中位 7.9,16 页里仅 2 页 >25),用来标定
「目标体积有宽裕预算」时该怎么选:
| 方案 | 全书 | 相对源 | 量化 MAE | 达标(<100MB) |
|---|---|---|---|---|
| JPEG q82(不降采样) | 190.5 MB | 77% | — | ❌ |
| png4 16 级(原分辨率) | 174.1 MB | 70% | 1.37 | ❌ 超标 |
png4 16 级 + --max-edge 2400 |
87.7 MB | 35% | — | ✅(有效 221dpi) |
| png2 4 级(原分辨率) | 64.5 MB | 26% | 6.30 | ✅ 画质最优的达标档 |
| png1 2 级 | 24.0 MB | 9.7% | 13.39 | ✅ |
| g4 二值 | 20.2 MB | 8.1% | 21.17 | ✅ |
| best1(g4/png1 取小) | 17.7 MB | 7.1% | 21.17 | ✅ |
g4 + --denoise median3 |
13.1 MB | 5.3% | 21.52 | ✅ |
结论:目标只是「压到某个上限」时,先算一下最保真的档位是否本来就达标。
本例 4 级灰(64.5MB)就已经远低于 100MB,压根不需要二值化——白白丢掉灰阶去换
20MB 是亏的。反过来,若目标是「压到 20MB 以内」,那就必须二值化。
决策流程
- 先算预算,而不是先想模式。 体检后用 10-20 页抽样把各档折算成全书体积,
再对照用户给的上限。经常出现「最保真的档位本来就达标」——那就别二值化。
任何情况下都不要为了凑体积上限而调低 dpi(见「铁律」)。 - 先体检:页数、是否纯图片型(
page.get_text()为空)、每页内嵌图尺寸。
体检走scripts/pdf_scan_compress.py以外的轻量检查,或直接跑一次小样本。 - 选模式:
- 纯文字/线条 →--mode g4(最小);想再省一点 →--mode best1
- 文字为主、夹杂少量插图/照片,且不想让插图被二值化毁掉 →
--mode auto --strategy mix(推荐,见下节)
- 只求体积最小、不在意插图变花 →--mode g4或g4 --denoise median3
- 必须保留灰阶层次 →--mode png2(4 级,连续调页首选);
追求近无损且体积预算够 →png4(必须配--max-edge,否则普遍超标)
- 要保照片 →--mode jpeg - 先跑 10-20 页样本(
--pages 3-17),肉眼确认后再全量。G4 与--denoise
都是不可逆操作,不要在没抽样验证的情况下直接全量替换原件。
用法
$PY = "<venv>\Scripts\python.exe"
& $PY "<skill>\scripts\pdf_scan_compress.py" "输入.pdf" -o "输出.pdf" --mode auto
必备参数只有输入路径。-o 省略时输出 <原名>_<mode>.pdf。
| 参数 | 默认 | 说明 |
|---|---|---|
--mode |
g4 |
auto / g4 / best1 / png1(2 级)/ png2(4 级)/ png4(16 级)/ jpeg |
--denoise |
none |
二值化前的灰度域降噪:none / median3(3x3 中值,实测再省 20-35%)/ median5 / bgflat(背景归一化)/ median3_bg。只对 g4 / best1 / auto 有意义,且不可逆 |
--levels |
16 | png4 的灰阶级数 2-256,位深自动取 1/2/4/8 |
--strategy |
compete |
auto 的判据:mix / compete / hist,见下 |
--mix-threshold |
40.0 | mix 的二值化误差阈值,越小越保真越大 |
--mix-prefer |
png2 |
mix 下连续调页的编码:png2 / jpeg |
--dither |
none |
none / floyd(仅 g4) / ordered(8x8 Bayer,仅 png,缓解低位深色带)。文字页必须 none*,抖动引入噪点且显著增大 |
--threshold |
无 | g4 二值化阈值 0-255,用于去纸张噪点(140 左右常见) |
--jpeg-quality |
82 | jpeg 模式质量 |
--max-edge |
0 | 默认 0 = 保持原始分辨率,不要动。>0 会重采样到最长边 N 像素,真实降低 dpi,属破坏性操作,需用户明确授权才可使用 |
--pages |
全部 | 如 3-17 或 0,5,9 |
-q |
关 | 静默,只输出结果行 |
auto 模式的三套判据
mix(混合策略,推荐用于「文字为主 + 少量插图」的扫描书):按
二值化代价binary_error()判页型 —— 误差 ≤ 阈值判为文字页走 G4,
否则判为连续调页走--mix-prefer(默认png2)。
判据用灰度直方图算(O(256),与逐像素 MAE 偏差实测 0.000)。
阈值 ↔ 体积对照(341 页灰度扫描书实测,连续调页用 png2):
--mix-threshold |
全书 | 平均 MAE | 走 G4 的页数 |
|---|---|---|---|
| 25 | 74.5 MB | 11.07 | 193 |
| 35 | 46.5 MB | 15.47 | 267 |
| 40(默认) | 30.8 MB | 18.67 | 312 |
| 45 | 27.3 MB | 19.97 | 329 |
| 纯 g4(对照) | 25.6 MB | 21.17 | 341 |
连续调页改用 --mix-prefer jpeg 质量更好(MAE 12.97 vs 15.47 @阈值35)
但体积约翻倍(86.0 MB vs 46.5 MB)。
注意:不能用体积竞赛来判页型。 实测 60 页抽样里 G4 字节数无一例外
小于 png2(二值化后行程编码太高效),min(g4, png2) 会选中全部 G4
(=25.6MB),插图页照样被毁。必须用质量判据。
compete(默认):同页实编 G4 与 JPEG,取字节数小者。自校准、无需调参,
代价是每页多跑一次 JPEG 编码。注意它只在 G4/JPEG 之间比,JPEG 体积大,
所以实际几乎总是选 G4 —— 想保插图请用mix。hist:只算直方图中间调占比,与--mid-threshold(默认 0.30)比较,速度更快。
0.30 是实测出来的;不要用 0.08 之类的低阈值,会把大量纯文字页误判成连续调
而走 JPEG,体积直接失控。中间调占比本身不可靠(纯文字页因抗锯齿边缘也大量
命中),判页型优先用mix的二值化误差。
关键坑(必读,直接决定成败)
1. 低位深量化必须用「均匀量化」,绝不能用 quantize() / palette=ADAPTIVE
这两个 API 按像素数量均分色槽。扫描件 93% 像素是近白,colors=2 时两个槽会全部
落进亮部(实测调色板 [254,190]),整页被压成纯白、内容 100% 丢失,而且文件还
"更小" —— 体积数据是假优化。正确做法是均匀量化(值域等分 0/85/170/255...)后
手工构造调色板写 P 模式,见 scripts/pdf_scan_compress.py 的 uniform_index()。
同理 2-bit/4-bit 的实测结论:低位深 PNG 只在 1-bit 时能与 G4 掰手腕,2-bit 起
完败(Flate 对索引数据的压缩远不如 CCITT G4 对二值图像的行程编码)。
1b. quantize 之后千万不能 convert("L")
Pillow 只对 P(调色板)模式 写 bits<=4。一旦 convert("L") 再存,写出来的
必然是 8-bit 灰度 PNG(colortype=0),4-bit 的优势全部丢失。保持 P 模式直接 save。
1c. 别指望「级联降位深」能降噪或减小体积
实测 gray → png4 → png2 → png1/g4 与直接一步到位的位图逐像素完全相同
(8/8 页 diff 0.0000%,体积差 0 B)。数学原因:均匀量化步长 17/85/255 层层嵌套,
且 127.5 恰好既是 2 级的边界、也是 4 级与 16 级的舍入边界,所以逐级降阶 ≡ 一步到位。
每一级加抖动则是净损失(位图差 1.74%、体积 1.52 倍、噪点涨 50%)。
降噪必须做在灰度域(--denoise),而不是靠多级量化。
1d. png1 对连续调页不只是「掉灰阶」,是整页报废
《母婴护理学》封面(二值化代价 52.3,典型图像页)实测:png1 把整页压成接近纯白,
只余零星噪点,原图完全不可辨;png2 保留大形但出现明显阶梯块;png4 与原图几乎一致。
正文页 png1 也会让抗锯齿笔画粘连发粗。所以 png1 / g4 / best1 都不能用在
含照片的整本书上——用 mix 或 png2。
1e. png4 原分辨率普遍超标——但不要用降采样去凑
实测两本书:341 页书 277MB(比源还大)、272 页书 174.1MB,都远超常见体积上限。
位深和体积是乘法关系(每升一级 ×2.7),不要以为「多两级灰阶」代价很小。
默认保分辨率时,png4 直接判定为不可用,选 png2(4 级灰、原分辨率)——
实测 272 页书 png2 = 64.5MB 已达标,比 png4 降采样到 2400(87.7MB)体积更小、
dpi 还高出一截。只有当用户明确授权降分辨率时,--max-edge 2200~2400
才把 png4 变成可选项(实测 73.7MB / 87.7MB,有效 202 / 221 dpi)。
1f. --denoise 会连低对比的浅色真实内容一起抹掉
中值滤波抹的是孤立暗点,透印/淡字/浅色批注也是孤立暗点。压缩与存档场景是赚,
需要忠实还原或送 OCR 时要谨慎。先 --pages 抽样目检。
2. img2pdf 的 layout_fun 必须返回 4 元组
(pagewidth, pageheight, imgwidthpdf, imgheightpdf),返回 2 元组会抛
ValueError: not enough values to unpack。不传 layout 时 img2pdf 按 96dpi 排版,
A4 会被撑到 21 英寸。正确做法是从源页面物理尺寸反推 dpi:
dpi_x = px_width / (page.rect.width / 72.0)
由于 layout_fun 拿不到页号,用闭包计数器按处理顺序取当页 dpi —— 但必须按页
序遍历且每图只调用一次,不要打乱顺序。
3. img2pdf.Engine.get("pikepdf") 不存在
用 img2pdf.Engine.pikepdf。internal 引擎同样能正确保留 /BitsPerComponent 4
与 /CCITTFaxDecode,引擎差异对本流程影响很小。
4. 取源图用 doc.extract_image(xref) 而不是 page.get_pixmap()
前者拿到的是文件里存的原始 JPEG 字节,get_pixmap() 会把页面重新栅格化,多一次
质量损失也更慢。多图页按面积取主图:max(imgs, key=lambda t: t[2]*t[3])[0]。
5. 别对已压缩的灰度 JPEG 做二次有损
这类图再编一次大概率变大。走 jpeg 分支时务必配 --max-edge 降分辨率才有收益。
6. 损失 PNG 量化不敌 JPEG 是常态
参见 references/benchmark.md。对连续调内容,4-bit 无损 PNG 通常比源 JPEG 还大 40%
以上 —— PNG 量化只在二值化之后才有意义。
环境
依赖:pymupdf pillow img2pdf[pikepdf]
& "<skill>\scripts\setup_env.py" # 在 scripts/venv 建隔离环境
已有可用环境时直接指定其 python 即可。PyMuPDF ≥1.28 请 import pymupdf,
继续用 fitz 会报弃用告警(脚本已做双分支兼容)。
Windows PowerShell 5.1 下中文输出会乱码,先执行:
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONIOENCODING = "utf-8"
边界
- 不可避免地丢失灰阶:G4 是纯黑白。原件的铅笔字、浅色底纹、灰度照片会被抖动
噪点取代或整块变黑。这是不可逆操作,务必先抽样--pages验证。 - 不支持 JBIG2。JBIG2 对黑白文本通常比 G4 再小 2-3 倍,但需要 Ghostscript 或
jbig2enc,本 skill 不引入。能接受外部依赖时优先上 JBIG2。 - 路线 A 是整页重建,原 PDF 的文字层、书签、注释、链接全部丢失。混排 PDF(文字
- 穿插图片)不要用本 skill,需要走 XObject 原位替换(路线 B,本 skill 未实现)。
- 输出为全新 PDF,原件请保留。