☰
Python批量给图片加水印:Pillow脚本实现全图平铺与透明度控制
2026/10/15 23:53:39 网站建设 项目流程

如果你手上有几百张图片要统一加上水印,第一反应是打开修图软件一张张贴,还是找个小工具一把梭?我以前总是选前者,直到有一次被一大批商品图压得喘不过气,才老老实实把批量添加全图水印这件事拆成固定流程。这篇文章就把那套流程完整写出来,包括直接用得上的Python脚本、底层原理,以及那些只有真正跑过一批图才会踩到的坑。

1. 为什么"批量加全图水印"会变成大麻烦

1.1 水印不只是一句版权声明,还是一次被动曝光

很多人一提水印就想到防止盗图,实际上它还有另一层作用:品牌曝光。比如独立摄影师发布样片、设计师交付预览图、电商团队处理白底商品图,水印通常是统一尺寸、统一位置的logo或ID,甚至做成半透明斜向平铺,铺满整张图。这种"全图水印"既能防止截图裁剪,又能在社交平台被转发的时保持品牌露出。

不过麻烦也随之而来。一张一张贴图,时间全耗在重复动作上;更不说不同图片的尺寸可能不一样,每次还要手动调整水印大小和边距。真正让人崩溃的不是贴一张,而是800张都要贴,每张都要对齐、大小一致、透明度统一。手动操作半小时后,眼睛已经分不清哪张贴过、哪张漏了。

1.2 "全图水印"和普通角落水印不是一回事

角落水印通常只放在右下角或左上角,逻辑很单纯:盖住某一小块区域。全图水印一般是平铺模式,也就是把同一个logo或文字按固定间距重复铺满整张图,像瓷砖一样。这个操作看起来只是重复次数变多,实际上它引出了几个新问题:

  • 水印之间的间距要一致,否则视觉上会歪歪扭扭。
  • 水印密度太高会压住主体内容,密度太低又被轻易裁掉。
  • 不同尺寸图片如果按固定坐标平铺,铺出来效果会忽密忽疏。

这些细节在单图处理时根本不值得想,但一旦进入批量流水线,就必须定出统一规则。否则同一个文件夹下,有的图水印铺成两张,有的铺成五张,交付出去会很业余。

1.3 批量需求里的潜规则:统一之外还要"不抢内容"

批量加水印的核心诉求有两个字:统一。统一透明度、统一位置、统一尺寸比例,甚至统一角落留白。但另一条隐藏原则是"不抢内容"。特别是电商图和设计预览图,主体是商品或设计稿本身,水印只是辅助标识。半透明、低饱和度、斜向排列,都是为了让水印存在感低一点,但攻击盗图者时又能明确宣示所有权。

这就意味着,水印逻辑不能是"把logo直接怼上去",而要把透明度、旋转、间距都参数化。手动一张张处理完全没法保证这些参数手工保持一致,脚本批量处理反而是最稳定的方案。

2. 批量水印方案选型:脚本、桌面软件和在线工具怎么选

2.1 三类方案的真实差距

动手写代码前,我其实把桌面端、在线工具和脚本都试了一圈。它们各有各的适用场景,放在一张表里看得更清楚:

方案优点缺点适合场景
桌面批量工具上手快,图形界面直观参数不够细,平铺间距和透明度可调范围有限;不同机器装不同软件,行为不一致偶尔一次、几十张的小批量
在线加水印网站零安装,上传即用原图要上传到第三方服务器,隐私敏感的不敢用;多张高清大图上传慢,单文件大小常受限不涉及版权敏感内容、数量少的临时处理
本地脚本(Python+Pillow)参数全可控,可重复执行,不消耗网络流量需要一点代码基础大批量、重复执行、对透明度和位置有自定义要求

我常常见到有人把上百张带客户信息的样片传到在线工具里处理,传上去之后心里就开始打鼓。其实这个场景用本地脚本是最稳妥的:不离开自己电脑,跑出问题改代码再跑一次就行。

2.2 为什么最终选了Python + Pillow

选型时我对比过重型的图像框架,最后还是决定用Pillow。原因很直接:批量加水印这件事本质上是像素级合成,不需要目标检测、不需要模型推理,只需要把一张图按规则贴到另一张图上。Pillow正好覆盖这块能力,安装简单,依赖干净,跨平台表现也一致。

Pillow里核心的操作是paste和alpha_composite,配合ImageDraw画文字、配合ImageFont加载字体,做平铺水印、单点水印、文字水印基本是十分钟的事。更关键的是,它是纯Python库,脚本跑完换台机器也能执行,几乎不会出现环境水土不服。

2.3 为什么不推荐用OpenCV做这件事

OpenCV确实强大,但用它做批量加水印有点高射炮打蚊子。一是OpenCV的默认通道顺序是BGR,跟日常处理图片的习惯不一致,新手特别容易在颜色上翻车;二是它对中文文字的支持很麻烦,需要自己找字体库、配置复杂;三是输出图片时编码参数跟Pillow有差异,色彩偏差问题更明显。

不是说OpenCV不行,而是在这个特定任务里,Pillow的抽象层级更合适,代码量更少,可读性也更好。工具不在多,顺手最重要。

3. 从零写出第一个可用的批量水印脚本

3.1 水印叠加不是"覆盖",而是Alpha合成

这是整个脚本里最值得理解的一个点。很多人第一次写水印代码时,会直接把水印图paste到背景图上,结果发现透明部分要么变成黑色块,要么把下面的图完全挡住。原因在于:普通paste是把上层像素直接覆盖下去,完全不理会上层本身的Alpha通道;而真正的水印叠加是一个**Alpha合成(Alpha Blending)**过程。

Alpha合成本质上就是一套加权平均公式:

结果颜色 = 背景颜色 × (1 - 水印透明度) + 水印颜色 × 水印透明度

如果水印像素点的Alpha是100%,那这个位置就是纯水印色;如果Alpha是0%,就完全透出背景。日常见到的"半透明水印",对每个像素来说,都是背景和水印颜色按透明度做加权混合的结果。用生活经验来类比就是透明贴纸:你贴上去之后,底下的图案还能隐约透出来,因为贴纸膜本身有透光性。

Pillow里做Alpha合成最稳的是Image.alpha_composite(),它处理两张RGBA模式的图片,严格按照Alpha通道计算混合结果,不会出现把透明区域盖成黑块的问题。

3.2 单点水印:把logo贴到右下角并保留边距

先写一个最基础的函数,实现把带透明底的logo贴到图片右下角,同时做整体透明度和边距控制。

from PIL import Image, ImageOps def add_single_watermark(base_path, logo_path, output_path, position="right_bottom", margin=20, opacity=0.85): base = Image.open(base_path).convert("RGBA") logo = Image.open(logo_path).convert("RGBA") # 控制水印整体透明度 alpha = logo.split()[3].point(lambda v: int(v * opacity)) logo.putalpha(alpha) w, h = base.size lw, lh = logo.size if position == "right_bottom": x, y = w - lw - margin, h - lh - margin elif position == "left_bottom": x, y = margin, h - lh - margin elif position == "center": x, y = (w - lw) // 2, (h - lh) // 2 else: x, y = margin, margin layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) layer.paste(logo, (x, y), logo) result = Image.alpha_composite(base, layer) result.convert("RGB").save(output_path, quality=95)

这段代码里有几个细节值得说明:

  • 所有图像先统一转成RGBA,保证Alpha通道存在,避免后续合成报错或不透明区域变成黑块。
  • split()[3]取出的是Alpha通道,通过point()方法对每个Alpha值乘以透明度系数,就能控制整个logo的淡出程度。
  • 合成时先建一个透明layer,把logo贴到layer上,再通过alpha_composite叠到底图上,这样可保留水印的圆角、阴影、半透明渐变这些细节。

3.3 平铺全图水印:循环铺满整张图

单点水印变全图水印,其实只是把"贴一次"改成"贴一个规律网格"。以平铺间距为步长,在整张图上循环写入水印:

def add_tiled_watermark(base_path, logo_path, output_path, spacing=200, opacity=0.12, rotate=30): base = Image.open(base_path).convert("RGBA") logo = Image.open(logo_path).convert("RGBA") logo = logo.rotate(rotate, expand=True) alpha = logo.split()[3].point(lambda v: int(v * opacity)) logo.putalpha(alpha) w, h = base.size lw, lh = logo.size layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) y = 0 while y < h: x = 0 while x < w: layer.paste(logo, (x, y), logo) x += lw + spacing y += lh + spacing result = Image.alpha_composite(base, layer) result.convert("RGB").save(output_path, quality=95)

平铺参数有三个最关键:spacing控制水印间距,opacity控制整体透明度,rotate控制水印旋转角度。实际平铺时,透明度不要超过20%,不然整张图看起来像蒙了一层纱,主体内容直接被干扰;旋转30度左右会让水印剪裁起来更麻烦,对防裁图有点用。

这段代码还可以优化成按行偏移错落排列,比如奇数行水印右移半个周期,视觉上更像马赛克瓷砖,防裁切效果也更好。不过基础版已经够日常使用。

3.4 批量循环与参数化入口

上面两个函数只解决单张图,接上批量逻辑才是"分分钟"的关键。核心做法是用glob扫描某个文件夹下的所有图片,再逐个处理:

import glob import os import argparse def batch_watermark(input_dir, output_dir, mode="tile", logo_path=None, text=None, spacing=200, opacity=0.15, rotate=30): os.makedirs(output_dir, exist_ok=True) image_paths = glob.glob(os.path.join(input_dir, "*")) image_paths = [p for p in image_paths if p.lower().endswith((".jpg", ".jpeg", ".png", ".bmp", ".webp"))] for i, path in enumerate(image_paths, 1): if mode == "tile": add_tiled_watermark( path, logo_path, os.path.join(output_dir, os.path.basename(path)), spacing=spacing, opacity=opacity, rotate=rotate) else: add_single_watermark( path, logo_path, os.path.join(output_dir, os.path.basename(path)), opacity=opacity) print(f"处理进度: {i}/{len(image_paths)} -> {path}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="批量添加水印") parser.add_argument("--input", required=True, help="输入目录") parser.add_argument("--output", default="output", help="输出目录") parser.add_argument("--mode", default="tile", choices=["tile", "single"]) parser.add_argument("--logo", default="logo.png", help="水印图片路径") parser.add_argument("--spacing", type=int, default=200, help="平铺间距") parser.add_argument("--opacity", type=float, default=0.15, help="水印透明度") parser.add_argument("--rotate", type=float, default=30, help="水印旋转角度") args = parser.parse_args() batch_watermark(args.input, args.output, args.mode, args.logo, spacing=args.spacing, opacity=args.opacity, rotate=args.rotate)

这样在命令行执行一次就能搞定整个文件夹:

python watermark.py --input ./raw_images --output ./watermarked --mode tile --logo mylogo.png --spacing 180 --opacity 0.12 --rotate 30

处理过程中打印进度是很有必要的,不然批量跑几百张图,中间卡住了都很难定位是哪一张出问题。

3.5 没有png logo时:文字水印怎么做

很多人其实没有现成的透明底logo,只有一段文字,比如个人ID、工作室名、网站域名。这种情况下不需要渲染图片,直接用Pillow的ImageDraw画文字就行。

from PIL import ImageDraw, ImageFont def add_text_watermark(base_path, text, output_path, font_path="simhei.ttf", font_size=48, opacity=0.2, rotate=25, spacing=260): base = Image.open(base_path).convert("RGBA") layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) font = ImageFont.truetype(font_path, font_size) draw = ImageDraw.Draw(layer) w, h = base.size # 先测出文字的宽高,再按网格平铺 bbox = draw.textbbox((0, 0), text, font=font) text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1] y = 40 while y < h: x = 40 while x < w: draw.text((x, y), text, font=font, fill=(255, 255, 255, int(255 * opacity))) x += text_w + spacing y += text_h + spacing layer = layer.rotate(rotate, resample=Image.BICUBIC, expand=False) result = Image.alpha_composite(base, layer) result.convert("RGB").save(output_path, quality=95)

文字水印有两点容易疏忽:

  • 字体文件必须真实存在,系统自带字体路径各平台不一样,写死路径很容易换台机器就报错。建议脚本里做一次字体路径检测。
  • fill的RGBA里Alpha值乘的是opacity,也就是文字的透明度。白色低透明度在大部分图片上观感比较好,不会像纯黑水印那样显得脏。

4. 实测跑批:效率数值、踩坑和修正

4.1 一批400张大图的真实耗时段

我用一批403张图做过测试,每张尺寸大概4000×3000像素,水印是一张1500×1500的半透明PNG,平铺间距200像素。具体环境是中端笔记本,Python 3.10,Pillow 10.x。结果如下:

处理模式总耗时单张平均耗时
平铺水印约7分30秒约1.1秒
单点水印约2分40秒约0.4秒
文字水印约5分10秒约0.8秒

平铺模式耗时长主要是图层旋转和多次paste导致的计算开销。如果只是赶时间,可以去掉rotate参数或降低平铺密度,速度会快很多。这个速度对日常交付完全够用,毕竟全自动跑批,不需要人盯着。

4.2 踩坑记录一:透明PNG水印贴上去变成黑色块

第一次跑的时候,我直接把logo用paste贴到底图上,结果所有透明区域全都变成了黑色块,整整跑完400张之后才发现整批图全废了。

问题出在paste的第一种用法:paste(image, position)没有把水印图自己的Alpha通道作为mask传进去,透明部分被当成纯色覆盖了。正确的做法是传第三个参数mask:

layer.paste(logo, (x, y), logo) # 第三个参数logo就是mask

或者干脆用alpha_composite。这也是为什么前面所有合成逻辑都使用alpha_composite的原因——它可以避免绝大多数透明通道引发的奇怪问题。

4.3 踩坑记录二:混合目录里JPEG和PNG的文件格式冲突

批量处理的文件夹里,往往同时有JPG和PNG。JPG本身没有Alpha通道,转成RGBA再处理后转回RGB保存反而正常;但如果原图是PNG,保存时用默认的PNG参数,文件体积可能会膨胀。

更隐蔽的问题是:如果输出目录和输入目录是同一个文件夹,脚本会扫描到上一轮已经处理过的图片,无限循环处理“水印套水印”。我在脚本里强制输出到独立目录,以后再也没有碰见这问题。另外,处理完PNG文件时,可以加上PNG压缩优化参数:

result.convert("P", palette=Image.ADAPTIVE).save(output_path, optimize=True)

当然,转成调色板模式会损失颜色精度,除非你的流程对文件体积特别敏感,否则不建议用。保守做法是保持RGB保存,设置合适的quality,JPG一般用85到95之间。

4.4 踩坑记录三:手机拍摄的图片竖竖横横,水印方向不对

用Pillow直接打开的JPG,很多是带了EXIF旋转信息的,预览器能自动转正,但脚本处理时读到的像素还是拍摄时的原始方向。结果就是水印按横向图的位置贴,出来的图却是竖着的,位置完全错位。

修复方法很简单,利用Pillow自带的ImageOps.exif_transpose:

from PIL import ImageOps base = Image.open(base_path) base = ImageOps.exif_transpose(base).convert("RGBA")

这步处理尤其重要,只要是手机图、相机原图,几乎都带EXIF。加一行代码就能避免水印位置整体偏移。

4.5 踩坑记录四:中文字体在部分环境里变成方框

文字水印在Windows下用默认字体路径一般没事,但脚本迁到Linux服务器上之后,中文全部变成方框字。原因是Linux环境没有预装中文TrueType字体,而Pillow不会自动扫描系统中的中文字体。

解决方式是在脚本里做一个字体路径探测,按顺序检查几个常见路径:

import os FONT_CANDIDATES = [ "C:/Windows/Fonts/msyh.ttc", # Windows 微软雅黑 "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # Linux 文泉驿微米黑 "/System/Library/Fonts/PingFang.ttc", # macOS 苹方 ] def find_font(): for path in FONT_CANDIDATES: if os.path.exists(path): return path raise FileNotFoundError("找不到中文字体文件,请手动指定路径")

字体文件最好直接放到脚本目录并相对引用,这样换机器时行为完全一致。

4.6 踩坑记录五:透明模式与缩略图预览观感不一致

还有个小坑,就是平铺水印在100%查看时很淡,但在某些缩略图渲染下会显得特别深,因为缩略图缩小后水印和背景的像素被混合成更扎眼的色块。这个很难完全规避,只能在水印透明度上留点余量。我通常把最终交付图的透明度控制在12%到18%之间,既保证水印清晰可辨认,又不会在缩略图下喧宾夺主。

5. 让批量水印脚本更好用的四个扩展点子

5.1 递归处理子目录里的所有图片

很多素材库是按日期或主题分子目录的,只处理一层目录就得反复跑脚本。给批处理函数加上递归扫描,改动很小但非常实用:

def collect_images(root_dir): result = [] for current, _, files in os.walk(root_dir): for f in files: if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp", ".webp")): result.append(os.path.join(current, f)) return result

输出时也要保留目录结构,否则几百张图全部平铺到一个文件夹里,后期找图又会疯掉。

5.2 根据原图尺寸动态调整水印大小

不同来源的图片尺寸差异可能很大,水印固定像素大小在手机上看的很清楚,到了4K大图上就变成一个小点。动态缩放水印的规则并不复杂:让水印宽度占原图宽度的15%到25%之间,再按比例算出高度。

target_ratio = 0.2 new_width = int(w * target_ratio) new_height = int(lh * new_width / lw) logo = logo.resize((new_width, new_height), Image.LANCZOS)

这样就保证了不同尺寸的图,水印视觉占比是统一的,批量交付时观感更整齐。

5.3 按目录或文件名规则套用不同水印

比如对外发样片用"A版本"水印,对外发精修预览图用"B版本"水印。可以在扫描目录后,通过文件名前缀来动态决定水印方案。博客里给个简化思路:

for path in image_paths: if "preview" in path: use_logo = "preview_logo.png" else: use_logo = "final_logo.png"

这种规则化处理的好处是后期不用人工区分,只要命名规范,脚本可以完全自主决定每一张图用什么水印、什么透明度。真实项目里遇到过的需求还有按客户维度区分水印、按产品线区分水印,本质上都是同一套逻辑。

5.4 增加干跑预览模式,避免整批跑完才发现参数错了

这是我最推荐加的一个功能。在脚本里加一个--dry-run参数,只处理前3张图,输出到preview目录并打印参数,不处理全集。这样每次调整透明度、间距、旋转角度后,先看几张效果,满意后再全量跑。别小看这个小功能,它能救回好几次"整套图跑完发现水印太大"的窘境。

if args.dry_run: sample_paths = image_paths[:3] else: sample_paths = image_paths

跑批之前先预览,是批量图像处理里成本最低、收益最高的习惯。

6. 最后说两个我花了很久才养成的习惯

第一个习惯是所有输出目录都和输入目录分开。一开始我图省事直接覆盖原文件,结果某次参数配错,把一批原图的清晰度永久损掉了,后来所有图像批处理都强制写新目录,回滚几乎零成本。

第二个习惯是保持脚本幂等——同一个输入、同一套参数,无论跑多少次,输出的图都应该完全一致。这意味着不要依赖随机数、不要用系统当前时间做文件名,所有变量都从参数显式传入。坚持这个原则后,客户说"上次的水印位置往左上挪一点",我只需要改参数重新跑一遍,出来的结果可预测、可比对。

批量加水印这个需求看起来不大,但它背后其实是图像合成、文件格式、字体渲染、目录管理等一堆小细节的组合。把这一套流程跑通之后,以后再遇到"这批图全部要加水印"的需求,我基本不慌,因为脚本几分钟就能完成。希望这份经验对你也有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询