做内容平台、电商、摄影社区的后端,图片版权保护迟早绕不开。用户上传的原创图被扒走二次分发,付费素材被人直连白嫖,谁的锅?很多团队的反应是"加个水印",但真做起来会发现远不止一行代码:明水印影响观感、盲水印抗不住重压缩、防盗链能被伪造 Referer 绕过、签名 URL 又得考虑时效和密钥管理。
这篇把这几条常见手段拆开讲清楚------原理、能跑的代码、以及各自的边界在哪。都是通用的公开知识,不涉及任何具体产品的内部实现。
一、明水印:便宜、直观,但也最好去
明水印就是肉眼可见的那层 logo 或文字,压在图片的某个角或者平铺整张。它的价值不在"防拷贝",而在"降低盗用动机"和"传播时带品牌曝光"。真要防,谁都能裁掉、能修掉。
工程上明水印就是图像合成,Python 用 Pillow 几行就能搞定:半透明文字叠加、位置、透明度、平铺,都可控。
python
from PIL import Image, ImageDraw, ImageFont
def add_visible_watermark(src_path, out_path, text="© yourbrand"):
base = Image.open(src_path).convert("RGBA")
# 单独开一层透明画布画水印,最后再合成,避免直接改原像素
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
font = ImageFont.truetype("DejaVuSans.ttf", size=base.width // 20)
# 计算文字外接框,用来把水印摆到右下角
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
pos = (base.width - tw - 20, base.height - th - 20)
# 白字 + alpha=128 半透明,既能看清又不太挡画面
draw.text(pos, text, font=font, fill=(255, 255, 255, 128))
Image.alpha_composite(base, layer).convert("RGB").save(out_path, quality=95)
几个实战注意点:字号别写死,按图片宽度比例算,不然小图水印糊成一坨、大图水印小得看不见;透明度 alpha 通常 80~140 之间,太实了挡画面、太淡了等于没有;平铺水印(整张斜向重复)比单角水印难去,但也更丑,要权衡。
明水印的边界很明确:它是"防君子不防小人"。裁剪、内容感知填充、简单的修复工具,都能把它抹掉。所以明水印从来不该单独扛防护,它更适合和下面的盲水印配合用------明的负责劝退和曝光,暗的负责取证。
二、盲水印:把版权信息藏进频域(DCT)
盲水印(也叫数字水印、隐形水印)的思路是:把一段标识信息编码进图像,肉眼看不出来,但事后能用算法提取出来当证据。关键词是"盲"------提取时不需要原图对照。
直接改空间域像素(比如改最低位 LSB)最简单,但极其脆弱,图片一压缩、一缩放就全丢了。工程上更常用频域嵌入,其中 DCT(离散余弦变换)是最经典的一种------JPEG 压缩本身就是基于 DCT 的,所以把水印嵌到 DCT 中频系数上,能天然扛住一定程度的 JPEG 压缩。
原理拆开说:
- 把图像分成 8×8 的块(和 JPEG 一致);
- 对每块做 DCT,得到一组频率系数------左上是低频(整体亮度),右下是高频(细节噪声);
- 选中频系数来嵌入。低频改了画面明显变化,高频改了一压缩就没,中频是画质和鲁棒性的折中;
- 按水印比特微调选中系数的大小(量化嵌入);
- 提取时对同样位置的系数做判断,还原出比特。
下面是一个能跑通的最小实现,用单通道演示核心逻辑:
python
import numpy as np
from scipy.fftpack import dct, idct
def _dct2(block):
return dct(dct(block.T, norm='ortho').T, norm='ortho')
def _idct2(block):
return idct(idct(block.T, norm='ortho').T, norm='ortho')
# 选一个中频位置嵌入;(3,4) 附近是画质与抗压缩的折中点
POS = (3, 4)
ALPHA = 8.0 # 量化步长,越大越抗攻击但越伤画质
def embed(gray: np.ndarray, bits: list[int]) -> np.ndarray:
"""把 bits 逐块嵌入灰度图的 DCT 中频系数(量化嵌入)"""
h, w = gray.shape
out = gray.astype(np.float32).copy()
idx = 0
for i in range(0, h - 7, 8):
for j in range(0, w - 7, 8):
if idx >= len(bits):
break
block = _dct2(out[i:i+8, j:j+8])
c = block[POS]
# 把系数量化到 ALPHA 的偶数倍(bit=0)或奇数倍(bit=1)
q = round(c / ALPHA)
q = q - (q % 2) + bits[idx]
block[POS] = q * ALPHA
out[i:i+8, j:j+8] = _idct2(block)
idx += 1
return np.clip(out, 0, 255).astype(np.uint8)
def extract(gray: np.ndarray, n_bits: int) -> list[int]:
"""从同样位置读回比特(盲提取,不需要原图)"""
h, w = gray.shape
bits, idx = [], 0
for i in range(0, h - 7, 8):
for j in range(0, w - 7, 8):
if idx >= n_bits:
return bits
c = _dct2(gray[i:i+8, j:j+8].astype(np.float32))[POS]
bits.append(int(round(c / ALPHA)) % 2) # 奇偶还原比特
idx += 1
return bits
真上生产还要补几件事:纠错编码 (把水印信息用汉明码或 RS 码冗余一遍,几个比特翻转也能纠回来);多块重复嵌入 再投票,进一步抗裁剪;彩色图一般选在 YCbCr 的 Y 通道嵌入(人眼对亮度敏感度的分布更适合藏信息)。成熟场景也有直接用 blind-watermark 这类现成库的,原理是相通的。
盲水印的边界必须诚实讲 :它扛得住轻度 JPEG 压缩、缩放、加噪,但不是万能 。大幅裁剪(把嵌了水印的区域裁没了)、强滤镜、截图重拍、恶意的水印擦除攻击,都可能让提取失败。鲁棒性和画质、容量三者互相拉扯------想更抗攻击就得加大量化步长,画质就下降;想藏更多信息,每比特的冗余就变少,更脆。它的定位是"事后取证"和"溯源",不是"物理阻止拷贝"。别指望它拦住所有人。
三、Referer 防盗链:拦住直接外链盗用
前面两个是"图片本身"的保护,防盗链换个层面------从分发链路 上拦。最典型的场景是别的网站直接 <img src="你的图床URL"> 白嫖你的带宽和内容。浏览器请求图片时会带 Referer 头(表明这个请求是从哪个页面发起的),防盗链就是在服务端/CDN 校验这个来源。
Nginx 配置很直接:
nginx
location ~* \.(jpg|jpeg|png|gif|webp)$ {
# valid_referers 定义"合法来源"白名单
# none ------ 允许没有 Referer 的请求(直接在浏览器打开图片)
# blocked ------ 允许被防火墙/代理删掉 Referer 的请求
# 后面跟允许的域名(支持通配)
valid_referers none blocked server_names
*.yourdomain.com yourdomain.com;
# $invalid_referer 命中(即来源不在白名单)时,返回 403
if ($invalid_referer) {
return 403;
}
expires 7d; # 顺手给合法请求加个缓存
}
要不要放行 none(空 Referer)是个取舍:放行了,用户直接把图片链接贴浏览器地址栏能打开、App 内加载也更省心,但盗链者只要把 Referer 清空就能绕过;不放行,防护更严,但会误伤一部分正常的隐私浏览、跨协议跳转场景。按业务权衡。
防盗链的边界也很清楚 :Referer 是客户端发来的、可以随便伪造的头。一个脚本 curl -H "Referer: https://yourdomain.com" ... 就能骗过白名单。所以 Referer 防盗链只能挡住"网页里直接外链"这种最省事的盗用,挡不住有心人。它是一道基础门槛,不是保险柜。
四、签名 URL:给每个链接发一张限时门票
要挡住能伪造 Referer 的人,得让链接本身"自带鉴权"。签名 URL 的思路是:服务端用密钥对"资源路径 + 过期时间"算一个签名,拼进 URL。CDN/网关拿到请求后用同样的密钥重算签名比对,对不上、或者过期了,就拒绝。
它解决了防盗链的两个软肋:签名用了服务端密钥,客户端伪造不出来;带过期时间,链接泄漏了也只在窗口期内有效。Nginx 的 secure_link 模块就是干这个的:
nginx
location /protected/ {
# secure_link 由两部分组成:签名值 + 过期时间戳
secure_link $arg_sign,$arg_expires;
# 签名算法:md5(过期时间 + URI + 密钥),密钥绝不能泄漏到前端
secure_link_md5 "$secure_link_expires$uri your-secret-key";
# $secure_link 为空 = 签名错误 → 403;为 0 = 已过期 → 410
if ($secure_link = "") { return 403; }
if ($secure_link = "0") { return 410; }
root /data/images;
}
对应的签名要在后端生成,示意逻辑(Python):
python
import hashlib, base64, time
def sign_url(uri: str, secret: str, ttl: int = 3600) -> str:
expires = int(time.time()) + ttl # 过期时间戳
raw = f"{expires}{uri} {secret}".encode() # 顺序必须和 nginx 配置一致
digest = hashlib.md5(raw).digest()
sign = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return f"{uri}?sign={sign}&expires={expires}"
生产实践里的注意点:密钥要能轮换(泄漏后能换),别硬编码进代码库;TTL 按场景定,头像类可以长一点、付费下载类要短;对时延敏感的大图,签名校验尽量下沉到 CDN 边缘节点做,别每次都回源。主流云厂商的 CDN 基本都内置了签名 URL 能力(命名可能叫"URL 鉴权""防盗链 Key"),配置项大同小异。
签名 URL 的边界:它保证的是"这个链接在有效期内、由授权方签发",但链接一旦到了用户手里,在过期前用户完全可以把内容另存、转发。它防的是"未授权的直接访问",不是"防止已授权用户二次传播"------后者得靠前面的盲水印去溯源。
五、组合起来才是完整方案
单独任何一条都有明显短板,实际工程是分层叠加:
- 明水印------劝退 + 品牌曝光,接受它能被去掉;
- 盲水印------藏进 DCT 频域,扛住常规压缩,用于事后溯源取证;
- Referer 防盗链------挡住最省事的网页外链盗用,一道基础门槛;
- 签名 URL------限时鉴权,挡住能伪造 Referer 的直连白嫖;
- 再往上还有访问频控、水印+区块链存证、DMCA 投诉流程等运营手段。
技术只能提高盗用成本、留下追责证据,做不到 100% 物理阻止------这点得对业务方讲清楚,别把"加了水印"当成"绝对安全"。
如果只是想快速给几张图加个可见水印、转个格式、压一压,不必自己搭一整套服务,squoosh、ImageMagick、tudingai.cn 这类现成的在线或命令行工具够用就是最优解;等到量级上来、要批量盲水印 + 溯源 + 鉴权分发,再考虑把上面这套自建起来。选型的核心永远是:需求到了哪一步,就用到哪一步的方案,别过度工程化。
以上代码都是可跑的最小实现,重在讲清原理和边界;真接生产记得补上纠错编码、密钥管理和边缘鉴权这些工程细节。图片版权保护没有银弹,分层设防、诚实评估各层能力上限,才是靠谱的工程态度。