做宣传图最烦的其实不是设计是重复劳动。一张图要缩尺寸、压体积、加Logo、调清晰度偶尔还要换位置重新导出。三五张手工处理没问题一旦到100张整个人基本就废在编辑器里了而且越到后面越容易出错——要么忘了加水印要么压出来的尺寸不统一宣传物料发出去才发现问题那才叫尴尬。这篇文章记录的是我自己处理宣传物料时写的一个Python批量工具核心就两件事批量压缩图片到指定尺寸和体积以及批量加文字/图片水印。整套流程跑下来100张原图从导入到导出脚本实际运行时间在几分钟左右剩下的时间都花在看图和检查上。如果你也经常被这类重复的图片处理工作缠住这篇文章应该能直接帮你省下大半天。我会把思路、代码、参数选择的理由、踩过的坑全部铺开讲代码可以直接复制改改就用不搞花活。1. 这个需求到底要解决什么问题1.1 手动处理宣传图的真实痛点我接到过一个模拟项目X的宣传物料任务渠道方给了100张实拍图要求统一处理成适合社交媒体发布的样子。具体要求是长边不超过1920像素单张体积控制在300KB以内右下角加品牌Logo不得拉伸变形还要保证肉眼看起来画质没有明显损伤。听起来不难对吧但在编辑器里一张一张来每张至少要经历导入、缩放、导出、加水印、再导出、检查体积6个步骤。熟练的话一张两三分钟100张就是四五个小时。中间如果被消息打断回来还得回忆这张处理到哪一步了。更要命的是人不是机器处理到第80张时很容易忘记加Logo或者用的压缩参数和第10张对不上导致整批图片风格不统一。我当时的第一反应是这种标准化、重复化的流程本来就不该靠手。真正的问题不是怎么处理图片而是怎么保证100张图片用同一套标准稳定处理完。这个标准一旦定下来用脚本执行是必然的选择。1.2 我圈定的自动处理范围做工具之前我先列出了处理规则把需求收敛成几条明确的输入输出输入一个文件夹里任意数量的图片支持常见的jpg、png、webp、bmp格式输出另一个文件夹里同样数量、统一为jpeg格式的成品图尺寸按比例缩小长边最大1920像素不裁剪、不拉伸体积通过质量参数控制目标300KB以内水印右下角加品牌LogoLogo宽度约为图片宽度的12%半透明附加处理自动修正手机照片的方向信息透明底图片转jpeg时铺白底规则定清楚之后再写代码思路就非常顺。很多人上来就写脚本边写边想需求最后代码改来改去反而浪费时间。顺序应该是先定标准再谈实现。2. 技术选型为什么是Python Pillow2.1 我不是没考虑过其他方案市面上其实有不少现成工具能达到差不多的效果比如批量处理软件、命令行工具、甚至编辑器的动作录制功能。我评估了一圈先说结论它们的核心短板在于规则写死在软件里不好定制。编辑器的动作录制只能解决固定尺寸、固定位置的压缩和水印一旦渠道方说这次Logo放左下角这批图不要压太狠你就得重新录一遍动作或者手动微调。命令行工具功能很强大但语法偏冷门我身边不少做运营和设计的朋友看参数说明就跟看天书一样。而用Python写脚本规则全部变成变量改一行代码就能调整所有输出这是最核心的优势。另一个现实原因是环境基本零成本。只要电脑装了Python一条pip命令装完Pillow库就能跑不像某些图像处理软件要装一堆运行库。Pillow虽然是老牌库但到现在仍然是Python生态里最主流的图像处理方案文档全、坑少、出问题随便一搜就有答案。对批量压缩水印这种需求来说它属于典型的重剑无锋好用不花哨。2.2 Pillow的安装与基础用法环境配置很简单我直接给出步骤python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install pillow新建虚拟环境是习惯问题能避免Pillow和系统里的其他包互相污染。如果不想建虚拟环境直接pip install pillow也没问题这类项目依赖非常单一。Pillow的核心操作对象是Image类。打开图片用Image.open()处理完保存用img.save()中间所有缩放、滤镜、合成都是对Image对象做变换。理解了这个模型后面的代码就没什么神秘的了from PIL import Image img Image.open(photo.jpg) # 打开 img.thumbnail((1920, 1920)) # 等比缩小 img.save(output.jpg, JPEG) # 保存三行代码就是一次基础压缩。但实际工程里要处理的边界情况远不止这些这也是为什么我们后面要写完整函数而不是三行脚本完事。3. 批量压缩把图片缩小还不损画质的写法3.1 压缩函数的完整代码先看核心的压缩函数我把它拆成打开、修正方向、转色彩模式、缩图、保存五步from PIL import Image, ImageOps def compress_image(src_path, dst_path, max_size(1920, 1920), quality85): img Image.open(src_path) # 1. 修正照片方向 img ImageOps.exif_transpose(img) # 2. 透明图铺白底其他模式统一转RGB if img.mode RGBA: background Image.new(RGB, img.size, (255, 255, 255)) background.paste(img, maskimg.split()[3]) img background elif img.mode ! RGB: img img.convert(RGB) # 3. 等比缩图只缩不放 img.thumbnail(max_size, Image.Resampling.LANCZOS) # 4. 保存为优化过的渐进式JPEG img.save(dst_path, JPEG, qualityquality, optimizeTrue, progressiveTrue)这段代码单独拿出来已经能满足大部分批量压缩需求。第2步的两种分支各有讲究我单独展开讲。3.2 三个影响压缩效果的参数max_size元组(1920, 1920)表示长边最多1920像素。注意thumbnail()的特点是等比缩放且只缩小不放大图片是横向还是竖向它都会正确处理不会拉伸变形。这是处理宣传图最保险的方式因为你不知道原图是手机拍的竖图还是相机拍的横图硬性resize到固定宽高必定变形。quality参数JPEG压缩质量范围0到100。我实测下来用于网络传播的宣传图quality85是甜点位。低于75天空、渐变背景这类区域容易出现肉眼可见的色块断层高于90体积下降不明显性价比很低。如果你的图片内容以文字截图、纯色海报为主quality80也能接受体积会更小。LANCZOS重采样算法这是Pillow里质量最高的缩放算法适合缩小图片时保留细节。如果用默认的NEAREST图片边缘会出现明显锯齿用BILINEAR虽然快但清晰度差一截。宣传图是要给客户和用户看的清晰度优先级最高所以老老实实用LANCZOS。在Pillow 9.1之后的版本里推荐写法是Image.Resampling.LANCZOS旧的Image.LANCZOS写法在最新版本已经标记为废弃。3.3 处理透明PNG的坑压缩流程里最容易翻车的是透明PNG。PNG支持透明通道而JPEG不支持直接convert(RGB)的话透明区域在转码过程中会变成黑色。想象一下一张白底宣传素材加上透明Logo处理后整个背景变黑这图基本就废了。解决思路是在转码前先把透明图合成到一张白色背景上background Image.new(RGB, img.size, (255, 255, 255)) background.paste(img, maskimg.split()[3]) img backgroundimg.split()[3]取的是RGBA图的Alpha通道也就是透明度信息用它作为mask可以让透明区域正确透出白色背景不透明区域保持原样。如果你的图片素材统一是白底这段代码不跑也没关系但只要素材里可能出现透明PNG这段就是保命代码。手机照片还有个隐藏坑很多手机拍出来的照片在Exif信息里记录了方向编辑器里看着是正的但程序直接读取像素数据时可能是横着的。所以第1步用ImageOps.exif_transpose(img)把方向信息应用回像素这一步不做后面出来的图全是旋转过的。4. 加水印Logo和文字两种方案都给你4.1 图片水印Logo的实现压缩只是第一步宣传图还要加品牌Logo。Logo水印的核心思路是把Logo图片缩放到目标尺寸的某个比例调节透明度然后粘贴到指定位置。代码如下def add_logo_watermark(img, logo_path, positionbottom-right, logo_scale0.12, opacity0.7): base img.convert(RGBA) logo Image.open(logo_path).convert(RGBA) base_w, base_h base.size logo_w, logo_h logo.size # 按图片宽度等比缩放Logo默认占12% new_w max(1, int(base_w * logo_scale)) new_h max(1, int(logo_h * new_w / logo_w)) logo logo.resize((new_w, new_h), Image.Resampling.LANCZOS) # 调整透明度 r, g, b, a logo.split() a a.point(lambda x: int(x * opacity)) logo.putalpha(a) # 计算位置 margin int(base_w * 0.03) x, y _get_position(position, base_w, base_h, new_w, new_h, margin) base.paste(logo, (x, y), logo) return base.convert(RGB)两个细节我想重点说明。第一logo缩放用base_w * logo_scale而不是固定像素是为了适配不同尺寸的图片。100张图有大有小如果Logo固定100像素宽在小图上显得巨大在大图上又小到看不清。按图片宽度比例走所有成品的视觉一致性会好很多。默认12%是我试过比较舒服的比例不会喧宾夺主又能清晰辨认品牌信息。第二调节透明度拆成了三个通道操作先把RGBA拆成R、G、B、A四个通道对Alpha通道做等比变换后再合并回去。a.point(lambda x: int(x * opacity))的意思是每个像素的透明度值都乘以0.7。这样Logo在视觉上是半透明的能透出底图内容看起来更专业不会像贴了一块膏药。0.7是我常用的值如果你的Logo本身颜色很深可以调到0.5避免压住主体内容。paste的第三个参数传了logo本身这是关键。它表示粘贴时用Logo自己的Alpha通道作为掩膜透明部分不会覆盖底图边缘过渡自然。新手最容易漏掉这个参数结果就是一整块矩形色块盖在图上。4.2 文字水印的实现有些场景不适合贴Logo比如临时活动物料、没有品牌图标的个人作品这时候文字水印更灵活。用Pillow画文字水印同样不复杂def add_text_watermark(img, text, positionbottom-right, font_size28, opacity0.55): base img.convert(RGBA) overlay Image.new(RGBA, base.size, (0, 0, 0, 0)) draw ImageDraw.Draw(overlay) font _load_font(font_size) # 获取文字实际占用区域 bbox draw.textbbox((0, 0), text, fontfont) text_w bbox[2] - bbox[0] text_h bbox[3] - bbox[1] margin 20 x, y _get_position(position, base.width, base.height, text_w, text_h, margin) draw.text((x - bbox[0], y - bbox[1]), text, fontfont, fill(255, 255, 255, int(255 * opacity))) return Image.alpha_composite(base, overlay).convert(RGB)文字水印的实现逻辑和Logo水印不太一样。Logo是直接粘贴图片文字则是先在透明图层上画好再用alpha_composite把两个图层合成。这么做的好处是文字边缘能带抗锯齿效果比直接往原图上画更平滑。文字宽度的计算容易踩坑。draw.textsize()是老API在新版Pillow里已经废弃了而且它算出来的宽度不含字间距容易导致文字位置偏右。正确做法是用textbbox()拿到文字实际边界框计算时还要把bbox[0]和bbox[1]减掉否则文字会整体偏移几个像素。这个细节让我当时对着屏幕纳闷了好一阵。字体方面Windows、macOS、Linux的字体路径完全不同我在_load_font里写了一段自动探测逻辑按系统存在的字体文件逐个尝试。这里有个小经验中文字体必须用中文字体文件如果用默认字体渲染中文出来的是一堆方框。4.3 水印位置的计算逻辑不管是Logo还是文字位置计算都是同一套逻辑。_get_position函数接收图片宽高、水印宽高和边距返回粘贴的坐标def _get_position(position, base_w, base_h, obj_w, obj_h, margin): positions { top-left: (margin, margin), top-right: (base_w - obj_w - margin, margin), bottom-left: (margin, base_h - obj_h - margin), bottom-right: (base_w - obj_w - margin, base_h - obj_h - margin), center: ((base_w - obj_w) // 2, (base_h - obj_h) // 2), } return positions.get(position, positions[bottom-right])原理就是拿图片尺寸减去水印尺寸再减去边距得到水印左上角的坐标。比如右下角x坐标就是图片宽 - 水印宽 - 边距y坐标就是图片高 - 水印高 - 边距。center位置特意用了整除避免出现半像素导致水印抖动。我习惯把边距设成图片宽度的3%而不是固定20像素。原因和Logo缩放逻辑一样图片大时边距跟着变大视觉上更协调。传参时直接把margin int(base_w * 0.03)算好传进来这个数值在函数内部也能算但保持函数职责单一更清晰。5. 把压缩和水印串成一条流水线5.1 完整批量脚本前面的函数都准备好后组合成完整脚本就很简单了。我用pathlib的Path类做文件遍历因为它的路径处理比os.path字符串拼接优雅得多import os from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageOps SUPPORTED_EXTS {.jpg, .jpeg, .png, .bmp, .webp, .tiff, .tif} def process_one_image(src_path, output_path, max_size(1920, 1920), quality85, logo_pathNone, textNone, positionbottom-right): img Image.open(src_path) img ImageOps.exif_transpose(img) if img.mode RGBA: background Image.new(RGB, img.size, (255, 255, 255)) background.paste(img, maskimg.split()[3]) img background elif img.mode ! RGB: img img.convert(RGB) img.thumbnail(max_size, Image.Resampling.LANCZOS) if logo_path: img add_logo_watermark(img, logo_path, position) if text: img add_text_watermark(img, text, position) img.save(output_path, JPEG, qualityquality, optimizeTrue, progressiveTrue) def batch_process(source_dir, output_dir, max_size(1920, 1920), quality85, logo_pathNone, textNone, positionbottom-right): src Path(source_dir) dst Path(output_dir) dst.mkdir(parentsTrue, exist_okTrue) files [p for p in src.iterdir() if p.suffix.lower() in SUPPORTED_EXTS] total len(files) print(f共发现 {total} 张图片开始处理...) for index, file_path in enumerate(files, 1): output_path dst / f{file_path.stem}_processed.jpg try: process_one_image( str(file_path), str(output_path), max_sizemax_size, qualityquality, logo_pathlogo_path, texttext, positionposition ) print(f[{index}/{total}] {file_path.name} 处理完成) except Exception as e: print(f[{index}/{total}] {file_path.name} 处理失败{e}) if __name__ __main__: batch_process( source_dir./raw_images, output_dir./output_images, max_size(1920, 1920), quality85, logo_path./logo.png, textMyBrand, positionbottom-right )这个脚本的核心设计是处理单张图片的逻辑独立成函数批量循环只是调用它而已。这样有两大好处第一出问题时能精准定位是批量逻辑的问题还是图片本身的问题第二以后想改成并发处理或者只压缩不加印、只加印不压缩改一行就行。注意process_one_image里的顺序先转RGB、缩图最后加印再保存。顺序不能乱。如果先加印再缩图Logo会跟着图片一起缩小比例就失控了如果先保存再加印就等于存了两遍JPEG画质白白损失一截。JPEG是有损格式每次重新编码都会产生画质衰减所以打开原图→处理完→一次保存是最优路径。5.2 文件命名与目录组织输出文件名我用了{原文件名}_processed.jpg保留原始文件名方便和素材对应。如果你处理的是同一批产品图也可以在文件名里加日期后缀比如20250611_photo01_processed.jpg这样后续归档更清晰。输出目录单独放一个文件夹和原始素材隔离开这是必须养成的习惯。所有批处理工具都应该遵守原文件只读输出另存的原则。我在脚本开始时用dst.mkdir(parentsTrue, exist_okTrue)自动创建输出目录不用每次手动建文件夹。就算输出目录已经存在exist_okTrue也不会报错这个参数对反复运行脚本的场景很友好。目录里的文件如果混入了损坏的图片或非图片文件脚本会在这张图上抛异常。我在批量循环里做了try...except单张失败不会让整个批次中断详细的错误信息会打印在终端里处理完统一去找失败列表就行。千万别让脚本因为一张坏图就停摆100张图里坏一张重跑整个批次的时间成本很高。5.3 处理速度与效果评估说回标题里的1小时出100张。纯脚本运行时间其实远不到1小时我实测100张12MP手机照片从读取到压缩到加Logo再到落盘总耗时在1到2分钟之间主要看图片大小和磁盘速度。真正花时间的是处理前的参数确定和检查输出质量。我的个人节奏是第一批先拿5张图测试调整quality和logo_scale到满意效果然后全量跑一遍最后抽查20张看体积、尺寸、水印位置是否符合要求。算上测试和检查全部做完半小时左右比手动拖拽四五个小时效率高太多了。6. 常见问题与排查实录6.1 问题速查表我整理了一张实际使用中最常遇到的问题对照表你可以直接按症状排查症状常见原因解决办法输出图片方向是横的没有处理Exif方向信息用ImageOps.exif_transpose(img)透明区域变成黑色RGBA直接转RGB先贴到白色背景再转水印是整块矩形色块paste没传第三个掩膜参数base.paste(logo, (x, y), logo)文字水印显示为方框字体不支持中文指定中文字体文件路径图片模糊、锯齿明显用了快但质量低的缩放算法改用Image.Resampling.LANCZOS单张坏图导致整个批次中断没有异常处理批量循环加try...except压缩后体积还是很大quality太高或图片本身复杂quality降到80或者继续缩尺寸输出图片颜色和原图不一致色彩模式转换问题检查是否经过RGBA中转再转RGB6.2 几个我踩过最深的坑第一个坑就是Exif方向。有一批合作伙伴发来的活动照片手机拍的居多。我没加exif_transpose之前脚本处理完的图在电脑预览里全是正的传到社交媒体上就横过来了。原因是预览软件自动读了Exif方向信息而社交平台的后端直接读像素。这个问题排查了将近半小时才定位到后来把所有脚本都默认加上这行一劳永逸。第二个坑是字体路径。我最初在测试机写代码用默认字体文字水印显示的MyBrand没问题但换成中文品牌名之后输出图上全是一排排小方框。这是因为Pillow的默认字体是位图字体只支持ASCII字符。后来我改用系统中文字体比如Windows的msyh.ttc微软雅黑或macOS的PingFang.ttc中文才正常渲染。跨平台运行这套脚本时_load_font里的路径探测一定要保留不同系统字体文件位置差异很大。第三个坑和日志有关。第一次跑全量100张时脚本没有任何输出我盯着屏幕以为卡死了。后来发现其实还在正常处理只是没有反馈机制等了十分钟才想起来看文件夹。所以我在脚本里加了每张图的进度打印虽然土但非常实用。处理大批量文件时这种可见的进度反馈能极大降低焦虑感也能第一时间发现是不是真的卡住了。第四个坑比较隐蔽PNG的调色板模式P模式转RGB有时会出现颜色偏差。如果原图是带透明度的调色板PNG直接convert(RGB)可能让半透明边缘出现一圈白边。我的处理方式是先统一转RGBA再走白底合成流程这样透明度信息在转换过程中不会丢失。这个情况比较少见但遇到一次就会让人印象深刻。7. 一些实际操作心得与扩展想法7.1 参数怎么调才合适很多人拿到脚本第一反应是问quality该设多少logo_scale该设多少。我的回答永远是默认参数只能作为起点最终值应该由你的实际素材决定。判断标准很简单——拿一两张最复杂的原图比如风景照、内容密集的页面截图跑一遍放大到100%看画面的平滑过渡区域没有明显色块断层就说明quality够用把Logo放在深色和浅色背景上各试一次透明度不影响品牌识别就是合适的。如果渠道方对体积有硬性要求比如单张不超过200KB优先降max_size而不是降quality。因为缩小尺寸对体积的影响是几何级的而降质量到临界点以下会产生肉眼可见的劣化。我常用的组合是长边1600像素加quality 80绝大多数照片能压在150KB以内和1920像素的观感差距微乎其微。7.2 后续可以扩展的方向这套脚本稍微改改就能扩展出很多能力。比如批量生成多尺寸素材一套原图同时导出1920宽用于网页、1280宽用于社交媒体、800宽用于聊天窗口预览。再比如把Logo水印和文字水印结合右下角放Logo左下角放摄影师署名宣传图的信息完整度更高。如果想更省事还可以加一个简单的交互层用input()让用户在运行脚本时输入位置参数或者把处理逻辑包装成一个带图形界面的小程序拖拽文件夹进去就能跑。我之前还试过把脚本接到文件变化监听上新图片一放进文件夹就自动处理对持续产出素材的团队非常实用。最后再分享一个我个人的习惯所有批处理脚本跑完之后我不会立刻删掉原始素材。哪怕输出目录检查过了原始文件也会保留到项目周期结束。原因很简单——脚本如果有未发现的bug比如某类特殊图片处理异常原始素材还在就能随时重跑。这个习惯帮我避免过一次重大事故那次是因为一批图片混入了损坏文件输出少了几张如果原图已经清理那几组宣传物料就彻底丢了。批量处理的本质是把人的精力从重复劳动里解放出来放到真正需要判断力的事情上。这套脚本虽然技术含量不算高但它解决的是实实在在的痛点。如果你的工作里也有类似的固定流程不妨花半小时把这套思路落地成自己的工具之后每一次用到它都是在替未来的自己省时间。