图片水印怎么加才有用:ImageMagick 批量脚本、GD 内存坑与原件留存的三个决定

一、先分清:哪一类水印是防不住的

给图片加水印这个需求,几乎每个做站的都会遇到。原创配图被人整站搬走、商品图被同行直接拿去挂到自己的页面上、摄影作品被人裁掉署名角标,这些情况让人很自然地想加一道水印。但在动手写脚本之前,有一件事必须先说清楚:水印本质上是「提高盗用成本」,而不是「阻止盗用」。它能让直接把图下下来就用的人多一道手工处理的麻烦,但挡不住真正想用的人,也挡不住任何一个会用图像编辑软件的人。

所以真正决定水印价值的不是它有多难擦,而是三件事:一是它会不会影响自己站点的观感与转化,二是它能不能被批量、稳定、可重复地加上,三是加完之后能不能追溯。第三点常被忽略,但它才是水印最实在的一个用途——当你在别人的站上看到一张带自己站点域名的图,你至少有了一个明确的、可沟通的证据点,而不是空口说「这是我从你站上看到的」。

按这个思路,水印可以分成三类,处理方式完全不同。

第一类是硬防护型,比如把水印铺满整个画面、降低透明度做成平铺纹理,或者干脆把关键区域裁掉。这类防护最有效,但最伤自己站点的观感,一般只用在需要严格保护的素材上,普通内容站不建议。

第二类是标识型,也就是角落一个半透明的小 logo 加域名。它几乎不影响观感,防不住认真的人,但能让随手转发的人带上你的出处,是绝大多数内容站最合适的选择。本文主要讲这一类。

第三类是追溯型,把不可见的标识或哈希嵌进文件里,肉眼看不见,但能从文件里读出来。它对防盗用没帮助,对「事后证明这是你的图」有帮助,适合有原创摄影、设计素材的站点。

把这三类分清之后,加不加水印、加哪一类、加之前要不要留一张无水印的原始件,这些问题就都有了答案。下面进入具体做法。

二、选工具:ImageMagick 还是 GD,先看批量规模

PHP 站点上加水印,最常见的是两条路:用 PHP 自带的 GD 扩展在脚本里画,或者调系统的 ImageMagick(命令名 convert,新版是 magick)。两条路都能干活,但在批量场景下差别很大。

GD 的优点是零外部依赖、几乎所有 PHP 环境都自带,代码也好写,几行 imagecopy 就能贴一个 PNG 上去。缺点是内存占用随图片尺寸线性增长,处理一张四千万像素的原图时可能直接撞到 memory_limit;而且它每次都要在 PHP 进程里完成解码、绘制、重新编码,一百张图跑下来相当吃 CPU。

ImageMagick 的优点是能力全、功能稳、支持批量通配与目录递归,还能和 find、xargs 并行配合;缺点是它历史上出过若干解析漏洞,处理用户上传的文件时必须在 policy.xml 里限制可用的格式。这一点后面会单独讲。

对个人站来说,我的建议是:上传时按需加(每上传一张处理一张)用 GD 就够了,因为并发低、单张处理;历史存量批量补用 ImageMagick,因为要跑几千张、要可中断可重跑、要不占 PHP 进程。

先确认工具是否就绪:

magick -version
php -r 'var_dump(extension_loaded("gd"));'

如果 magick 不存在但 convert 存在,说明是 ImageMagick 6,命令里把 magick 换成 convert 即可,参数基本通用。Debian/Ubuntu 上装 ImageMagick 7 需要单独引入源,装 6 一般够用。

另外要确认字体:加水印如果带文字(比如域名),ImageMagick 需要能读到字体,否则会报 unable to read font 然后把文字画成空白。查一下:

magick -list font | head -20

输出为空说明字体库没配,装一个中文字体包(如 fonts-wqy-zenhei)再跑 fc-cache -fv。这个问题在精简过的容器镜像里非常常见。

三、水印素材怎么准备,比脚本本身重要

水印素材的准备很容易被糊弄过去,但它决定了最终成品的观感上限。几个具体要求。

用 PNG 而不是 JPG。水印要半透明,只有带 alpha 通道的 PNG 能做到。用 JPG 做水印,贴上去就是一个带白底的方块,难看且不可用。

尺寸要按最大展示宽度准备。如果站点大图最宽 1600 像素,你准备一个 200 像素宽的水印图片,贴到 1600 宽的图上就只占一小角;反过来,准备一个 2000 像素宽的水印,贴到 400 宽的缩略图上就要缩到很小,缩完之后细笔画会糊成一团。正确的做法是准备一张「大图用」和一张「小图用」两档,或者在水印生成时按目标图宽度等比缩放。

不透明度不要写死在水印图片里。很多人直接在图像编辑软件里把水印的透明度调到 40% 再导出,这样这张图就只能用在浅色背景上,撞到深色照片直接消失。正确做法是水印 PNG 保持完全不透明的白色或黑色,在贴图时用 ImageMagick 的 -compose Dissolve 或者 GD 的 imagecopymerge 传入不透明度参数,这样同一份素材能适配不同背景。

位置不要紧贴边缘。留出相当于图片宽度 2%~3% 的内边距。贴着边加的水印,在瀑布流布局里容易被容器的圆角或遮罩切掉一半。

准备一份不到 50 行的脚本就能试出效果:

magick input.jpg \
  ( watermark.png -resize 18% ) \
  -gravity southeast -geometry +24+24 \
  -compose Dissolve -define compose:args=55 \
  -composite output.jpg

这条命令的语义是:读原图,把水印缩到原图宽度的 18%,放在右下角、距右下各 24 像素,以 55% 的不透明度叠加。注意 -geometry +24+24 在 -gravity southeast 下的含义是「从右下角往左上偏移」,不是左上角往右下。

试出来之后,把参数记进配置,后面所有批量处理都用同一组数字,保证全站水印位置一致。水印位置在不同图片上跳来跳去,比没有水印更显廉价。

四、批量处理:可中断、可重跑、不重做已完成的部分

存量批量处理最怕的是「跑到第 1800 张出错,前面白跑」或者「重跑一遍把已经加过的又加了一遍」。这两个问题都能通过文件命名和存在性检查解决。

第一,输出不要覆盖原图。原图放在 origin/,加水印的结果写到 watermarked/,目录结构保持一致。这样任何一次失误都有原始件可回溯,也符合「派生图永远从原件重新生成」这条通用原则。

第二,用输出文件是否存在来判断是否处理过。这比维护一个进度文件简单得多,也不会因为进程被杀而状态错乱。脚本的逻辑是:遍历原图,算出对应的输出路径,如果输出已存在就 skip,否则处理。断点续跑天然成立。

第三,把单个文件的失败隔离开。set -e 在批量循环里会让第一个坏文件终止整个任务,正确做法是在循环内判断退出码、记日志、然后继续。中断之后看一眼日志里有哪些失败项,单独处理。

#!/bin/bash
# watermark-all.sh —— 可中断重跑的批量水印
SRC=/data/images/origin
DST=/data/images/watermarked
LOG=/var/log/watermark.log
find "$SRC" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) -print0 \
| while IFS= read -r -d '' f; do
    rel="${f#$SRC/}"
    out="$DST/$rel"
    if [ -f "$out" ]; then continue; fi
    mkdir -p "$(dirname "$out")"
    if magick "$f" \( /data/wm/wm.png -resize 18% \) \
        -gravity southeast -geometry +24+24 \
        -compose Dissolve -define compose:args=55 \
        -composite -quality 82 "$out" 2>>"$LOG"; then
      echo "OK   $rel" >> "$LOG"
    else
      echo "FAIL $rel" >> "$LOG"
    fi
done

几点说明。-print0 配合 read -d '' 是为了处理文件名里带空格和中文的情况,用普通的 for f in $(find ...) 会在遇到空格时把一个路径拆成两个,这是中文站点上最常见的批量脚本事故。-quality 82 是 JPEG 的甜点值,再往上体积涨得比画质快。2>> 把错误信息收进日志,屏幕上只看到进度。

跑之前先用 find ... | head -5 确认文件集合对不对,再用一个小样本目录跑一遍看输出效果,最后才放开全量。这个顺序别倒过来。

五、上传时实时生成:GD 的几个坑

新上传的图要在上传流程里加水印,通常是 PHP 里用 GD 处理。这里有几个坑,踩过就知道疼。

第一,内存。GD 把 JPEG 解码成位图后,占用大约是「宽 × 高 × 4 字节」。一张 6000×4000 的手机原图就是 96MB,PHP 默认 memory_limit 常常只有 128M,加上复制的水印位图和输出缓冲,很容易超。有两种处理方式:在上传时就限制原图尺寸(超过 2560 宽的先等比缩到 2560),或者在该处理函数里临时提高 memory_limit。前者更好,因为大原图对你的站本来就没用。

第二,EXIF 方向。手机拍的图常常是「像素横向存储 + EXIF 里标注旋转 90 度」。GD 默认会丢掉 EXIF,于是加完水印的图在浏览器里会歪掉,水印也跟着歪。处理办法是先读 EXIF 的 Orientation 字段,按值对图像做一次 imagerotate/imageflip,然后再贴水印。ImageMagick 的 -auto-orient 会自动处理这一层,手动调用时记得加上。

第三,透明 PNG 的背景。PNG 转 JPEG 时,透明区域会变成纯黑,这是 GD 和 ImageMagick 共同的行为。需要先把 PNG 铺到一个白色底图层上再转 JPEG:

magick in.png -background white -alpha remove -alpha off -quality 85 out.jpg

第四,缩略图不能从水印图上再缩。如果站点同时生成多档尺寸,正确做法是「从原图缩到各档尺寸 → 再在每档上按该档尺寸贴水印」,而不是「在原图上贴完水印再缩出各档」。后者的结果是缩略图上那枚水印小得几乎看不见,还浪费了一次重编码。

这四点里最容易漏的是 EXIF 方向,因为本地测试时随手拿的图多半是电脑截图,没有方向标记;等上线后用户上传手机照片,问题才成片出现。

六、ImageMagick 的 policy.xml:不限制就是给自己开后门

如果水印处理是串在用户上传流程里的,那 ImageMagick 的安全配置就不是可选项。ImageMagick 支持上百种图像格式,其中一些格式(比如 MVG、MSL、PS、PDF)在解析时能引用外部文件甚至执行命令,历史上出过多次利用这些解析器拿到服务器的漏洞。

限制办法是编辑 policy.xml,通常在 /etc/ImageMagick-6/policy.xml 或 /etc/ImageMagick-7/policy.xml,用一条 coders 策略把不需要的格式全部禁掉:

<policy domain="coder" rights="none" pattern="{MSL,MVG,PS,PS2,PS3,EPS,PDF,XPS,TEXT,LABEL}" />

另外建议加两行资源上限,防止有人上传一张「一个像素、但声称是十亿像素」的畸形图片把内存吃穿:

<policy domain="resource" name="memory" value="256MiB" />
<policy domain="resource" name="width" value="16KP" />
<policy domain="resource" name="height" value="16KP" />

改完用 magick -list policy 确认生效。这条配置投入不到五分钟,但它挡掉的是「上传一张特制图片就能读服务器文件」这一类事故。

更稳的一种架构是把加水印这一步从 Web 进程里挪出去:上传时只把文件落到待处理目录,由后台的常驻进程或定时任务处理,Web 端只负责判断「处理完了没有」。这样即使处理进程被畸形图片搞挂,也只是队列停一下,不会拖垮站点。

七、原始件要不要留:一个必须现在就决定的问题

水印一旦加上去就是不可逆的。所以「原始件留不留、留在哪、留多久」这件事必须在上线前定下来,不能等以后再说。

我的做法是分三种情况。

如果图片是自己原创、有版权价值的素材,原始件必须单独留一份,放在不对外暴露的目录(不在 Web 根目录下,或者用 Nginx 的 internal 指令保护)。这份原件将来要用于授权、取证、重制,丢了就没法补。

如果是产品图、截图这类可以重拍的素材,留不留取决于重新制作的成本。重新截一次图的,不必留;重新设计的,留着。

如果是用户上传的内容,一般不留原件——但要在服务条款里写清楚图片会被添加站点水印,避免后续争议。这一点很多站忘了写,等到用户投诉才发现条款里没有依据。

另外,水印位置、不透明度、缩放比例这三个参数一定要记在运维文档里,并且和脚本里的值保持一致。将来要改水印样式,得能让全站重新生成一遍,而不是新老混杂。这份「参数 + 脚本 + 重跑命令」的组合,本质上就是一条可重复执行的派生管线,它的价值远高于某一次具体的成品。

八、验证清单:处理完之后看这五件事

批量处理跑完不等于做对了。用一个抽样脚本把结果过一遍,五项检查。

# 1. 数量对得上:原图数与输出数是否一致
find origin -type f | wc -l
find watermarked -type f | wc -l

# 2. 有没有零字节的坏文件
find watermarked -type f -size 0 | head

# 3. 体积变化是否在合理区间(水印后应略大,不该反而大很多)
du -sh origin watermarked

# 4. 抽查五张,确认尺寸没被意外改变
identify -format '%f %wx%h\n' $(find watermarked -type f | head -5)

# 5. 抽查一张确认水印真的贴上去了(看图,不要只看日志)

第 1 项数量不一致,最常见的原因是某些格式没被 find 的 -iname 覆盖到,比如大写扩展名或者 .jfif。第 2 项的零字节文件几乎都是磁盘满或者权限问题留下的,删掉重跑对应项即可。

第 5 项看起来最笨但最不能省。我见过一次批量任务,日志全是 OK,图片也正常生成,但水印图片路径写错了一位,脚本读到的是一张 1×1 的透明占位图,于是「成功」处理了三千张没有任何水印的图。日志和文件大小都正常,只有眼睛能发现。

水印这件事,说到底不是一个技术难题,而是一个「重复劳动要不要自动化」的判断。写得再漂亮的单次手工处理,第二年就没人记得参数了;一份可重跑、可验证、参数写在文档里的脚本,五年后接手的人也能照着改。对一个人维护的站来说,后者的价值要大得多。

Last modification:November 14th, 2026 at 10:25 pm

Leave a Comment