你手头是不是也有一堆截图、扫描件、手机拍的合同照片需要合成一个图片合并PDF我接手这个工具的时候它处于一个“能用但难受”的状态小批量图片没问题一旦丢进去几十张高分辨率照片内存蹭蹭涨最后要么卡死要么生成一个几百MB的巨型PDF文件名排序是乱的手机拍照的方向是歪的部分图片直接崩掉进程。这个项目的“修复优化”不是改改参数那么简单它牵扯到内存模型、图像处理管线、文件编码、并发策略这几个层面。这篇文章就把我实际排查和重构的过程完整记录下来包括每一步的根因、改法、验证手段以及踩过的一些让人印象深刻的坑给同样维护这类小工具的读者一些参考。1. 能跑和好用之间差了几个坑1.1 项目背景这个图片合并PDF工具原本是什么样事情要从一个内部批量工具说起。它的核心功能很纯粹把一个目录下的所有图片按文件名顺序合并输出一个PDF文件。使用场景也很固定——行政同学整理扫描件、设计师导出多张预览图、运营批量整理活动照片都是整包往里丢。最初的实现用的是Python加Pillow库核心逻辑大概十几行遍历目录、逐张打开、粘贴到空白画布、最后统一保存为PDF。听起来很合理对吧但实际跑起来就不是那么回事了。我接手时拿到的反馈是35张以上原图就容易卡死生成速度慢就算了中途失败率还高有些图片上传后方向完全不对横的变竖的文件名排序经常出现第10张排到第2张前面PDF文件大小惊人一张手机原图压进去往往一个文件就超过几十MB。这些问题单独看都不严重但凑在一起就会让使用者每次操作都像抽奖。1.2 三个月没人维护后的第一轮回归测试任何修复工作开始之前第一步永远是复现问题。我先把一个包含80张图片、单张平均4MB左右的目录丢进旧版本里跑结果如下场景现象影响程度80张大图合成内存占用峰值超过2GB中途卡顿明显严重40张以内图片勉强能跑但耗时约90秒可接受但低效包含手机竖拍照片生成的PDF中图片方向错误功能性缺陷文件名含中文和数字混排排序顺序混乱1、10、2功能性缺陷目录里混入损坏图片进程直接崩溃没有提示严重单张超长截图聊天记录长图生成后图片严重压缩模糊质量问题这一轮跑下来问题清单已经很明确。但这些表层的症状背后隐藏着好几个层次的技术债内存策略不对、图片解码方式有问题、排序算法选错了、异常处理缺失、压缩参数一刀切。接下来就是按优先级逐层剥开。1.3 问题清单与优先级划分我给这次修复定了一个硬性目标100张、单张5MB以内的图片目录必须稳定生成PDF且内存占用控制在500MB以内生成时间不超过30秒文件体积比旧版减少50%以上。围绕这个目标所有问题被拆成四个优先级P0进程崩溃和内存溢出不解决后面全都没法谈P1排序错乱和图片方向错误直接影响PDF内容正确性P2文件体积过大和生成时间过长影响使用体验P3长图模糊、空白页、异常输入处理等边界情况决定工具是否经得起日常蹂躏分完优先级我才开始动手。这个顺序很重要——很多人在优化时喜欢先调压缩质量或者先改界面但内存和正确性问题不解决其余都是沙滩上盖楼。2. 大目录直接卡死内存模型的重构2.1 原实现为什么会在第37张图时OOM旧代码的核心逻辑是典型的全量加载模式把每张图片当作Pillow的Image对象读进内存然后一张一张粘贴到目标画布上。这个过程本身没什么问题但问题出在Pillow会把每个JPEG完整解码成原始位图——一张4000x3000的手机照片解码后是4000*3000*3字节约36MB。如果是PNG带透明通道就是4000*3000*4约48MB。80张图同时躺在内存里加上粘贴过程中的临时拷贝峰值轻松突破3GB进程不崩才怪。要理解这个问题的本质得先明白JPEG文件大小和内存占用是两回事。JPEG是压缩存储格式磁盘上几个MB但解码后是像素阵列。旧版本把读取路径和处理路径混在了一起所有图片一次性解码、全部驻留内存后面合PDF时还得一直保存所有Image对象内存只能无限增长。2.2 流式处理与临时文件策略我的改法很简单粗暴但很有效放弃全部加载完再合改成边读边合、合完即弃。Pillow保存PDF时有一个特性它允许你用一个图片列表直接生成多页PDF但列表里的Image会一直被引用。为了避免这个限制我没有让所有文件都驻留内存而是把大图先做预处理生成一份尺寸统一、质量可控的中间文件最后只保留一份中间文件列表。核心流程变成遍历输入目录对每个图片做方向校正和尺寸判断将校正后的图片按统一规则压缩暂存到临时目录从临时目录逐张读取并逐张写入最终PDF的页面全部完成后清理临时目录这里用到的关键API是Pillow的Image.save(fp, PDF, save_allTrue, append_images[...])但在我的实现里不是一次性把所有Image传进去而是分批追加。这个方法每追加一页就释放掉上一页的Image引用配合gc.collect()能保证内存平稳。from PIL import Image import os, tempfile def build_pdf(source_images, output_pdf_path): tmp_dir tempfile.mkdtemp(prefixpdf_build_) normalized [] try: for idx, path in enumerate(source_images): # 预处理、压缩生成中间文件 tmp_path os.path.join(tmp_dir, fpage_{idx:05d}.jpg) preprocess_image(path, tmp_path) normalized.append(tmp_path) first True for tmp_path in normalized: im Image.open(tmp_path) if first: im.save(output_pdf_path, PDF, resolution150) first False else: im.save(output_pdf_path, PDF, appendTrue) im.close() finally: # 清理临时目录 import shutil shutil.rmtree(tmp_dir, ignore_errorsTrue)缓存中间文件的方式会引入一点磁盘开销但换来的是内存从O(图片总解码大小)降到O(单张图片解码大小)。实测在80张大图的场景下峰值内存从2GB降到了320MB左右效果非常明显。2.3 用inSampleSize解决超大图问题流式处理解决了图片数量多的问题但单张图片过大时仍然会出问题。比如一张全景拼接图分辨率可能达到20000x5000解码后的位图是20000*5000*3接近300MB一张图就能把内存打满。处理方法是有节制的降采样也就是缩放而不是直接解码。Pillow里可以指定Image.open()后立刻调用thumbnail()或resize()但在解码阶段控制采样更高效。Pillow的Image.open()默认会把完整分辨率读入不过配合Image.draft()可以预先声明使用模式让Pillow尽可能不完整解码。实际操作里我选择先读取图片尺寸和EXIF信息判断最长边是否超过阈值比如PDF页面对应的合理像素宽度超过就按比例缩放。这里有个细节缩放比例应当用整数次幂而不是任意值这样解码效率更高。对于一张100MB的长图直接缩到最长边5000像素解码内存就从300MB降到30MB左右画质损失在屏幕阅读场景下几乎不可感知。对于A4纸的PDF页面来说基准尺寸是595磅宽、842磅高按150DPI打印需求换算目标像素大约在1240x1753左右。把过长图片缩到2000像素级别已经足够清晰再大就是浪费内存和文件体积。3. 图像不是压得越小越好质量与体积的平衡3.1 手机照片的方向问题EXIF orientation的正确修正方式手机拍出来的JPEG照片有一个反直觉的特点照片在文件里的像素排列并不一定是横平竖直的方向信息存在EXIF的Orientation字段里。老版本直接用Pillow打开像素图贴进PDF完全不看EXIF结果就是竖着手机拍的照片在PDF里横过来了或者转了90度。修复方法不复杂但有一个很容易踩的坑。Pillow里有个现成的ImageOps.exif_transpose()函数可以自动根据EXIF调整图片方向。你可能会觉得一行代码解决但实际用下来要注意这个函数会重新生成Image对象生成后Orientation字段会被当作已处理再次保存时如果还保留原EXIF某些阅读器会二次旋转。所以我在校正方向后会把EXIF信息里的Orientation字段清理掉或者干脆只保留必要的元数据字段。还有一个更隐蔽的问题是旋转后的尺寸会变化。比如Orientation6的照片宽高需要互换如果没在缩放前处理好后续的压缩尺寸就会出现留白或拉伸变形。我的做法是先转方向、再取尺寸、再缩放顺序不能反。from PIL import Image, ImageOps def normalize_orientation(src_path, dst_path): img Image.open(src_path) img ImageOps.exif_transpose(img) # 去掉Orientation字段避免阅读器二次旋转 exif img.getexif() exif.pop(274, None) # 274 Orientation img.save(dst_path, exifexif)3.2 压缩参数怎么定从90%到70%的取舍图片方向问题解决后接下来就是体积控制。旧版直接把原图按无损方式塞进PDF所以文件巨大。我的优化方向是存成JPEG中间格式再入PDF但JPEG压缩质量选多少合适这个需要认真考虑。先看一组我在实际目录上测出来的数据压缩质量平均单页体积肉眼观感适用场景95520KB几乎无损打印精样、人工审核85210KB很难察觉差异日常查阅、存档75130KB文字边缘轻微变软一般归档6085KB明显变糊柱状图细线断裂不推荐我的推荐是文档扫描件用85照片类用80纯文字截图用70。为什么要分场景因为图像内容的冗余度完全不同。扫描件通常有大量空白区域边缘和文字是信息的关键太激进会让笔画粘连照片则本身有噪声压缩伪影混在画面里不太显眼。旧版的问题是统一无损输出完全没考虑使用场景。另一个影响体积的参数是色彩空间。很多手机照片是sRGB但也有部分图片带Adobe RGB或Display P3配置。直接把所有图转成标准sRGB对体积有稳定帮助。我在预处理管线里约定了所有图片最终统一为RGB模式灰度图则保留L模式避免不必要的颜色转换。3.3 白边检测与纸张尺寸自适应还有一类图片是截图只占画面中间一小块周围全是白底。如果按满页处理截图被放大后糊得厉害如果按原尺寸嵌入又会在旁边留下巨大白边。这个问题的解法是做内容区域裁剪。最靠谱的方法是先检测四周边界的颜色是否接近纯白或纯黑然后裁掉多余区域。我当时用的是简单但有效的扫描法把图缩小到100px宽的小图上逐行逐列判断是否为纯色找到内容边界再映射回原图坐标。这个策略看起来有点粗暴但实际精度足够而且能把PDF体积再降低30%左右。裁剪之后还有一个问题就是截图尺寸往往不符合A4比例。我给出的决策是单张截图按原图宽高比嵌入页面不做拉伸多张图合并时如果宽高比接近A4如0.7左右则自动铺满整页否则居中加入保持原始比例。这样生成的PDF阅读体验最自然既不会变形也不会浪费过多页面空间。4. 文件顺序、中文名与看起来正常的边界4.1 1、10、2的排序问题必须用自然排序老版本用的文件名排序是系统默认的字典序也就是1.png、10.png、2.png这种排法。字典序比较的是字符的对应编码值1小于2所以10会排在2前面。这个问题在小批量图片时看不出来但扫描件多起来非常致命顺序全乱了。修复这个问题的标准做法是自然排序natural sort原理是把字符串里连续的数字片段当作数值来比较而不是逐字符比较。Python里可以自己实现也可以直接用第三方库natsort但我为了减少依赖手写了一个轻量版本import re def natural_key(filename: str): return [int(part) if part.isdigit() else part.lower() for part in re.split(r(\d), filename)] # 使用 sorted_files sorted(file_list, keynatural_key)这个函数会把第2页.png和第10页.png正确地按数值排序。注意这里我还顺手做了一步非数字部分统一转小写避免IMG_01和img_01被排成两个区间。排序逻辑是整个合并流程的基石必须在预处理阶段就固定下来后面不能改动。4.2 中文文件名不乱的编码处理排序问题修到一半我遇到一个更头疼的编码问题。旧版本在Windows上运行的时候只要目录路径或者文件名里有中文就容易出现文件不存在或者生成出来文件名乱码。这个问题本质上不是Pillow的问题而是Python在Windows控制台环境下的默认编码和操作系统编码不一致导致的。Python3在高版本里默认用UTF-8处理字符串但Windows文件系统API走的是UTF-16。当你把一个普通字符串传给open()时Python会自动转换理论上没问题。但如果你在读取目录列表时忘了指定正确的参数比如使用了已经解码错误的字节串后面全链路都会乱。我的处理方案固定三条规则所有路径在入口处统一用Path(r原始路径)构造不做任何decode/encode操作遍历目录用pathlib.Path.rglob()它内部处理编码最省心生成PDF元数据时标题字段不直接使用文件名而是用ASCII化的安全字符串避免PDF阅读器渲染中文变豆腐块文件扫描阶段用 Path不碰字符串编码所有文件名原样传递 生成临时目录时内部文件名用统一的 page_00001.jpg 格式 PDF 元数据标题统一用 output.pdf 这种纯 ASCII 命名。这一套组合下来中文目录、中文文件名、中文混合路径的兼容性基本没有出过问题。4.3 空目录、坏图和全是jpg之外的意外正常情况下使用者丢进来的目录都是正经图片。但正常情况是一个很脆弱的概念。我在测试过程中往目录里塞过以下几种坏东西损坏的JPEG文件头不完整、零字节文件、改名成.png但实际是zip的文件、以及纯文本伪装成图片的文件。老版本在这些输入面前毫无抵抗力直接崩溃。修复策略是做三层防护第一层扩展名检查。只接受.jpg/.jpeg/.png/.bmp/.webp这五类且大小写不敏感。第二层Magic Number检查。用文件头判断真实类型。JPEG文件头以FF D8 FF开头PNG以89 50 4E 47开头。这个检查比扩展名可靠得多能挡住大部分伪装文件。第三层解码异常捕获。Image.open()之后立刻调用img.load()触发真实解码如果抛出异常就跳过这个文件并在日志里记录警告而不是让整个进程崩溃。这三层防护互相兜底虽然不能修复损坏文件但至少不会让一个坏的拖垮一整批。遇到坏图时我的策略是默认跳过并统计数量而不是中止整个任务——因为对用户来说一个能出结果、少了几张图但明确告诉你的工具远比一个直接崩溃的工具强。5. 单线程改并行并发度不是越大越好5.1 瓶颈定位CPU密集还是IO密集基础正确性修完之后接下来面对的就是性能优化。旧版的生成速度慢在哪些环节我分析了整个管线读文件是IO操作解压JPEG是CPU密集操作缩小图片涉及重采样是CPU密集操作写临时文件又是IO操作最后合PDF主要是顺序写入。所以这是一个混合型负载。理解了负载类型就知道不该无脑上大线程数。我的实测结论是在8核机器上用4-6个进程处理图像的预处理阶段收益最明显超过8个进程之后CPU上下文切换成本增加磁盘IO也开始抢带宽收益反而不升。这里推荐concurrent.futures.ProcessPoolExecutor做进程级并行因为Pillow图像解码会释放GIL但不同图片之间的处理过程互相独立用进程可以规避内存碎片问题和第三方库的线程安全问题。我用一个基础实验对比不同并发度下的表现并发进程数80张图的预处理耗时CPU占用峰值备注138秒25%基线412秒90%最优均衡89秒100%速度最快但磁盘IO吃紧1610秒85%收益下降产生调度开销考虑到目标机器不一定是高配我最终把默认并发数定为min(os.cpu_count(), 4)同时提供一个参数让使用者可以按需调整。在用户那台普通的8核机器上整批80张从旧版的90秒压缩到了20秒左右体验提升非常明显。5.2 线程池与批量合并的分片策略进程池并行处理的是预处理阶段也就是从原始图片生成中间JPEG文件的阶段。但真正合成PDF的阶段并不适合并行——因为PDF的写入是严格按照页面顺序进行的乱序写入会产生文件碎片甚至需要额外排序。所以我的架构是预处理并行、合成串行中间产物用磁盘上的临时文件衔接。这个设计把最耗时的图像解码阶段并行化了又避免了PDF合成阶段的复杂度膨胀。还有一个值得提的分片策略临时目录里的中间文件不应该无节制地堆积。我在缓冲区达到一定数量比如2倍并发数时就暂停新增任务等合成阶段消费掉一批再继续。这本质上是一个生产者-消费者模型控制了两端速度差避免因为磁盘空间被打满而失败。from concurrent.futures import ProcessPoolExecutor def preprocess_all(src_images, tmp_dir, max_workersNone): if max_workers is None: max_workers min(os.cpu_count() or 2, 4) tasks [(path, tmp_dir) for path in src_images] with ProcessPoolExecutor(max_workersmax_workers) as pool: results list(pool.map(preprocess_image_task, tasks, chunksize4)) return results5.3 进度回调和失败重试并行预处理之后一个新的体验问题浮出水面旧版本在卡死时没有反馈用户不知道是还在跑还是已经挂了。我加了一个进度条不是那种花哨的终端装饰而是简单明了的已完成X/Y当前处理文件Z。进度信息通过回调函数从工作进程传回主进程每处理完一张图就更新一次。失败重试方面我设置了一个简单的策略如果预处理某个文件失败会尝试重新读取一次第二次仍失败则判定该文件为损坏记录到result的failed列表。这样做的原因在于某些网络驱动器上的文件第一次读取可能是瞬时错误直接放弃太可惜。重试只做一次既不会卡太久又能避免偶发失败。进度和重试这两个功能在文档里看起来都是辅助功能但在实际使用中它们的价值不亚于核心修复。因为使用者能持续看到进度就不会误判为死机也不会因为等待焦虑而反复强杀进程。6. 回归验证清单与最终参数选择6.1 测试矩阵不只是能出PDF主体功能重构完后我搞了一份回归测试清单。这份清单不是用来证明原来能跑的现在还能跑而是要覆盖边界情况因为修复优化的价值往往体现在边界上。我的测试矩阵分成四层第一层是正常场景10张、50张、100张、300张图片混合不同尺寸和格式。第二层是异常场景空目录、目录下全部是坏文件、部分文件损坏、文件名超长超过255字符、路径带空格和中文。第三层是特殊图片类型全白图片、全黑图片、长截图宽度撑满但高度是宽的20倍、带透明度通道的PNG、灰度JPEG。第四层是输出验证打开生成的PDF核对页数、页面尺寸、图片清晰度、文件大小并抽查几个关键页的像素数据做对比。这里要特别强调一点PDF的抽查不能只看缩略图要在100%缩放模式下翻阅。有些压缩伪影在缩略图中完全看不出来放大后却非常明显。我当时用不同的阅读器各打开一遍排除掉某个阅读器渲染特殊的偶然因素。6.2 与旧版输出对比这次优化到底值多少修复完成后我做了一次新老版本的横向对比用同一份80张图片的目录指标旧版修复版提升幅度生成耗时92秒21秒77%峰值内存2.1GB330MB84%输出文件大小486MB157MB68%图片方向错误3处0处100%文件排序正确否是100%中途崩溃概率高几乎为0-最夸张的是文件体积从486MB降到157MB原因在于中间JPEG压缩和内容裁剪两个策略的叠加效果。这个体积的PDF可以直接放进压缩包里发给客户而不需要像以前那样拆成两个文件。有一个参数是我想再聊聊的PDF的分辨率字段。最初我设的是150DPI后来发现打印场景需要300DPI就把它做成了可配置项。不同场景下PDF的清晰度和体积之间有一个明显梯度150DPI适合屏幕浏览300DPI适合打印存档两者体积相差约4倍。按你的主要用途去选不要追求每张图都无损因为无损大体积PDF在真实办公场景中反而更难传递。6.3 后续维护体会这些坑以后大概率还会再碰到修复迭代到这一版工具已经能稳稳处理日常目录了。但我不觉得这是终点。事实上我在这轮修复里遇到的大部分问题在其它类似项目里应该也普遍存在。有几条体会我想明确写在这里一内存优化的核心永远是减少同时驻留的数据量而不是优化数据结构的效率。流式处理和临时文件虽然看起来笨但他们在大数据量下就是比任何花哨的缓存策略可靠。二文件系统的真实世界比你想的脏得多。中文名、空格、超长路径、伪装扩展名、损坏文件这些不是测试人员故意刁难而是用户日常操作的自然产物。越早做防御性编程后面越省心。三性能优化的前提是先量化再动手。我不止一次看到有人先改算法然后发现瓶颈根本不在那里。用time和memory_profiler量出每一阶段的消耗再决定动哪里这才是高效的优化路径。四最终选择参数时把使用者需要什么放在理论上最好是什么前面。150DPI还是300DPI压缩质量85还是80这些没有绝对答案我最后都是根据实际场景调出来的而不是照搬某一篇教程。如果你也维护着一个类似的图片合并PDF工具建议先按这个清单自查一遍排序是不是自然排序、图片方向是否处理了EXIF、内存是不是一次性加载所有图、有没有坏图防御、有没有进度反馈。这五项就是新旧工具的分水岭。修好这些你的工具才算真正从能用走向好用。