让一个做图片站的人彻底睡不着的事不多,"用户的原图被搬走却拿不出证据"算一件。那天下午,站里一个做摄影的作者跑来找我,语气很冲:"我的图被人搬走了,搬到隔壁那个站,连我的署名都裁了。我要投诉,你得给我出个证据。"
我打开他给的链接,确实,图一模一样,连色调都没变。我说行,我给你开个投诉流程,你把你电脑里的原图导出发我。
他反问:"原图?人家也有原图啊,你怎么证明我是先发的?"
我愣住了。
他说的对。上传时间?我这边有记录,可对方平台不认。EXIF?修一下就有了。原图?谁手里都有。我翻了半天,发现自己居然拿不出一样东西能斩钉截铁地说:这图就是我这儿的用户传的。最后这事只能不了了之。那天晚上我躺在床上越想越不是滋味:一个做图片站的,连自己的图都护不住,像话吗?
后来我把"隐水印"接进了上传链路,这个问题才算有了答案。现在每张图从上传那一刻起,体内就藏着一行字——"@上传者的用户名 | yuemutuku.com"。肉眼看不见,但任何时候把图拿回来做一次提取,这行字会原样浮出来。搬运?搬得越远越好,搬完我照样顺着这行字找到你是谁。
这篇文章就是这套东西从原理到落地的全过程。不想写成说明书,就按我当初趟过来的顺序讲。
最开始我哪懂什么盲水印,第一个想到的就是最土的办法——把半透明 logo 直接糊在图上。

一开始觉得挺好,图上有我的域名,谁搬走都带着我的广告。结果呢?搬图的第二天,我在别的平台看到那张图,水印没了——人家截图裁掉了角落,或者用修复工具涂了两笔,干干净净。我试过把水印调大调居中,图是保住了,但创作者先炸了:"这图还能看吗?"你说得对,我也觉得不能看。
那阵子我悟出一个道理:可见水印卡在一个尴尬的位置——小了被裁,大了毁图,两头不讨好。真正的问题是,水印和图像内容是"两层皮",是贴在表面上的东西,所以人家想揭就能揭。
那能不能把信息直接写进图本身?让水印和像素融为一体,想删就得把图毁掉?方向对了,这就是隐水印。但怎么"写进像素",当时我完全没概念,于是开始啃资料,越啃越发现这行水挺深。
网上讲隐写术的文章,十篇有九篇开场就是 LSB——最低有效位。
原理一句话:像素颜色是 8 位二进制,把最后一位替换成你想藏的信息。
比如红色通道值是 152,二进制是 10011000,把最后那个 0 换成 1,变成 10011001,也就是 153。152 和 153 差 1/255,人眼根本分不出来。一张 1000×1000 的图三百万像素、每像素仨通道,理论上能藏几百万比特,容量大得离谱。
当时我差点就打算自己写个 LSB 库开工了。幸好多看了一眼那篇文章的后半段,才知道这玩意儿有多脆:
任何有损压缩都会杀了它。 JPEG/WebP 压缩会重算像素值,最低位直接被抹平,水印蒸发得无影无踪。几何变换直接错位。 裁剪、缩放、旋转之后,像素全不在原来的位置上了,提取时对不上号。抗噪几乎为零。 加一点噪点、调一点亮度,最低位就翻车。
还有更阴的:很多图片处理流程会做伽马校正、色彩空间转换,这一步会把 8-bit 值重新映射,最低位的意思就没了。你在自己软件里嵌入提取一切正常,图一经过第三方平台的处理管道,出来就废了。
看完这些我算明白了:LSB 只适合写进教程,不适合写进生产代码。真正能扛事的水印,得换个地方藏。
方向转向"频域"之后,我花了好几天才转过这个弯。讲人话就是:
任何一张图,都可以被拆成"低频"和"高频"的叠加。低频是那些大面积的东西——天空、墙面、皮肤的颜色过渡;高频是细碎的纹理——发丝、砖缝、噪点。JPEG 压缩干的事,就是把高频砍掉一部分,反正你看不出来。
那反过来说:既然砍高频你都不心疼,我往高频里塞点信息,你是不是也看不出来?
流程是这样的:把图切成 8×8 的小块,做 DCT(离散余弦变换),得到频域系数矩阵,左上低频右下高频;然后在中高频系数上按约定位置把水印比特调进去——比如"系数为正代表 1,为负代表 0";最后做 IDCT 还原成图。因为改动集中在中高频,肉眼完全无感。
但骨架好搭,肉难长。真正的坑在于:JPEG 压缩会主动砍高频,你水印藏得浅,压缩一过就没了;藏得深,又容易被看出来。两头都是悬崖。
工业界怎么解?两个招。
第一招叫扩频。不把 1 个比特塞进 1 个系数,而是用一串伪随机序列把这一比特的能量铺开到成百上千个系数上。压缩砍掉一部分?没关系,剩下的能量照样能解出这个比特。这招 1993 年就被用在数字水印里了,跟通信里的扩频是同一个祖宗。
第二招叫 HVS 掩蔽,说人话就是"人眼在纹理多的地方对噪声不敏感,在平整的地方极敏感"。所以水印能量要多往纹理区放、少往平坦区放——同样的嵌入强度,视觉上几乎不可察觉,抗压缩能力还更强。
这两招合起来,就是水印界的"不可能三角"——鲁棒性、不可见性、容量,三者只能取其二——被硬生生压到平衡点的过程。商用方案(包括后面要讲的腾讯云数据万象)都是这么干的。
道理懂了,但让我自己从零写 DCT 加扩频加掩蔽模型?我掂量了一下自己的头发,决定先看看有没有现成的轮子。
那时候我还真翻过一阵子开源库——OpenCV 里有现成的 DCT 水印示例,GitHub 上也有几个号称"生产可用"的项目。但看了一圈代码,心里越来越虚:要么只支持灰度图,要么没做扩频只做了裸 DCT,要么 README 写得天花乱坠代码里全是 TODO。最要命的是,这些库的抗压缩能力全都没有经过真实图片平台(反复转码、压缩、裁剪)的检验,我总不能拿站里几万张用户图去赌。
那时候我想明白一件事:水印这种对抗性的东西,最怕的就是"看起来能用"。自己写个小 demo 嵌入提取没问题,一上真实链路就露馅。要么找个被大规模验证过的商业服务,要么自己啃论文做好长期维护的准备。权衡下来,商业服务是当下性价比最高的选择——正好图片就存在腾讯云 COS 上,数据万象的盲水印是现成的。
项目图片本来就存在腾讯云 COS 上,一查,数据万象(CI)服务自带盲水印,直接 REST API 调,不用自己写 DCT。我们要用的文字盲水印 v3.0 长这样:
watermark/3/type/3/text/<Base64>/version/3.0
type/3 是文字盲水印,text 后面是 URL-safe Base64 编码(无 padding)的文本,version/3.0 是算法版本——比老版本抗压缩、抗裁剪、抗缩放、抗旋转的能力强不少。提取则是 watermark/4/type/3/version/3.0。
接入分三步走:上传即嵌入、存量批量回补、盗图提取溯源。原以为三天搞定,结果坑一个接一个。
上传的链路是用 Java + COS SDK 写的。核心思路是用 COS 的 PicOperations,在一次上传里同时完成压缩转码和盲水印嵌入。
关键代码长这样(简化过):
// CosManager.putPictureObject(key, file, watermarkText)
PicOperations picOperations = new PicOperations();
picOperations.setIsPicInfo(1);
List<PicOperations.Rule> rules = new ArrayList<>();
// 1. 压缩转 WebP + 盲水印,管道符串联成一条规则
PicOperations.Rule compressRule = new PicOperations.Rule();
compressRule.setFileId(webpKey);
String ruleStr = String.format("imageMogr2/format/webp/quality/%d", quality);
ruleStr += buildBlindWatermarkRule(watermarkText);
compressRule.setRule(ruleStr);
rules.add(compressRule);
// 2. 缩略图(无水印,仅缩放)
PicOperations.Rule thumbRule = new PicOperations.Rule();
thumbRule.setRule(String.format(
"imageMogr2/thumbnail/%sx%s>/format/webp/quality/%d", 1024, 1024, quality));
rules.add(thumbRule);
picOperations.setRules(rules);
putObjectRequest.setPicOperations(picOperations);
cosClient.putObject(putObjectRequest);
水印文字是哪来的?上传时把当前登录用户拼进去:
String watermarkText = "@" + user.getUserName() + " | yuemutuku.com";
到这儿有个很爽的设计:每张图藏的是它上传者的身份。张三传的图,里面藏着"@张三 | yuemutuku.com";李四传的藏"@李四"。谁传的图被搬走了,提取出来一看就知道是哪个账号——溯源粒度是用户级,不是站点级。等于每个用户都有了一套看不见的专属签名。
几个当时反复权衡的细节:
缩略图特意不带水印。 列表页、详情页天天用的缩略图,要的是加载速度和干净观感,而且缩略图被裁剪后水印也没意义。水印只进原图(original.webp)。
质量是按文件大小分级的。 链路里有个 getQualityForFileSize——小图用高质量(细节经不起压缩折腾),大图用略低的质量换体积。容易忽略的一点是:质量越低压缩越狠,水印信号就需要越强——质量分级和盲水印鲁棒性是绑在一起调参的,不是随便填个 80 就完事。
盲水印发生在 COS 服务端。 数据万象在处理管道里干这活,后端 Java 只是把规则拼进上传请求,嵌入过程对上传耗时几乎没有影响——这也是为什么敢放心地给每个上传都加上。
第一步顺利,正得意呢,第二步就给我当头一棒。
哦对,中间还插了一档子事,差点把第一步也推翻。正式全量开启上传即嵌入之前,我先在测试环境跑了一周灰度:每天随机抽几张带水印的图,拿去提取,看成功率稳不稳定。结果头两天就发现一个诡异现象——同一张图,上午提取成功,下午再提就失败了。排查到最后,居然是测试环境里那台机器时钟慢了两分钟,COS 签名一过期,提取请求全被 403 弹回来。谁能想到水印提取还跟服务器时间同步有关系?所以后来我把生产环境的 NTP 时钟同步也顺手加固了,这类坑不踩一次根本意识不到。
上线前的老图怎么办?十几万张,不可能手动处理。写了个后台定时任务分批补。
思路:扫库找"url 含 .webp 且还没标记已处理"的图,批量查用户名(避免 N+1),20 线程并发调嵌入接口,成功后把 is_blind_watermark 置 1。
// 扫库:待处理 = url 含 .webp 且未标记已处理
List<Picture> pics = this.lambdaQuery()
.likeRight(Picture::getUrl, "http")
.like(Picture::getUrl, ".webp")
.ne(Picture::getIsBlindWatermark, 1) // 断点续跑的关键字段
.last("LIMIT " + offset + "," + batchSize)
.list();
// 20 线程并发嵌入
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
CountDownLatch latch = new CountDownLatch(pics.size());
for (Picture pic : pics) {
executor.submit(() -> {
cosManager.embedBlindWatermark(key, watermarkText, quality);
// 成功后更新 is_blind_watermark=1
});
}
latch.await();
第一个坑来得又快又狠:COS Java SDK 对盲水印 v3.0 支持不完整。SDK 的 processImage 方法调 v3.0 不是报错,是静默失败——参数被吞掉,返回成功但水印根本没进去。排查的时候人都是懵的,怎么测都是"成功",一提取啥都没有。
最后绕开 SDK,直接用 HTTP POST + COSSigner 手工签名直调 REST API,代码啰嗦了一截,但完全可控。这个教训我记了很久:SDK 的"支持"两个字,有时候要打个问号,尤其是新特性。
第二个坑藏在参数里:fileId 只能传文件名,不能带目录前缀。对象存储的 key 基本都带目录(/photos/2026/xxx.webp),但嵌入/提取的 fileid 只认裸文件名(xxx.webp),带前缀直接 4xx。文档里没写清楚,全靠报错信息一点点试出来。
第三个坑跟规则拼接有关:批量回补时,水印规则不能和压缩规则管道符串联。上传新图时一条规则管道串联没问题(第一步就是那么干的),但存量回补时串联出现过不可靠的情况。最后改成:回补时水印和格式转换作为两个独立规则,先保证水印嵌入成功,再谈格式。
第四个坑跟签名有关:签名有效期只有 15 分钟。REST 直调得自己算 COS 签名(COSSigner.buildAuthorizationStr),有效期 900 秒。批量任务跑得久没关系——签名是每张图请求时现算的,不共享。但服务器时钟必须准,时钟一漂移,签名立刻失效,一堆 403。
还有个坑是跑完才发现的:任务要有进度可观测性,不能黑盒跑。第一版只打了起止日志,跑的时候心里完全没底——不知道跑到哪、成功多少、失败多少。后来加了三个观测点:每批打印"本批成功/失败/累计成功";失败单独计数并记录前几条失败原因(大多是超时和限流);任务结束把汇总写进日志。有了这些,回补任务才从"碰运气"变成"可复盘"。任何批量任务,没有进度和失败统计,等于在裸奔。
顺便说个设计上的小心思:is_blind_watermark 字段不只是个标记,它是断点续跑的命根子。每张处理成功就置 1,下次扫描自动跳过。跑崩了、超时了,重跑只会处理剩下的,不会重复嵌——重复嵌会叠加噪声,影响提取质量,这个后面细说。
有图被搬走了?拿回来提取。管理端接口:
@GetMapping("/admin/extractWatermark")
public BaseResponse<Map<String, Object>> extractWatermark(@RequestParam long pictureId) {
// 构造提取规则:watermark/4/type/3/version/3.0
String rule = "{\"is_pic_info\":1,\"rules\":[{\"fileid\":\"xxx.webp\","
+ "\"rule\":\"watermark/4/type/3/version/3.0\"}]}";
String xml = cosManager.extractBlindWatermarkXml(key, watermarkText);
// 解析 XML 得到 Text / WatermarkStatusCode
// → "提取成功,水印内容:@张三 | yuemutuku.com"
}
第一次真正跑通提取的时候,我在工位上差点喊出来。那张被搬走的图,从隔壁平台下载下来,原封不动扔进提取接口,几秒钟后返回一行字:"@某某 | yuemutuku.com"。就是当初上传那个用户的名字。
不过别高兴太早,提取这事也有翻车的时候。有一回运营拿着一张被转载了好几手的图来提取,返回的 WatermarkStatusCode 直接给我标了个"提取失败"。查了半天,那张图在搬运途中被反复转码加滤镜,水印信号已经退化到检测不出来了。后来我们摸出个规律:搬运链越短、越接近原图,提取成功率越高;真到了"截图 + 滤镜 + 裁切重排"那种深度加工,谁都救不了。所以后来运营那边也学乖了,遇到纠纷先找清晰度最高的版本,而不是拿压缩得最狠的那张来试。
那一刻我忽然理解了维基百科那张经典的生命周期图——嵌入、攻击、检测,三步闭环,水印这东西天生就是为"对抗"设计的:
(图片来自 Wikimedia Commons,CC BY-SA 4.0)
重复嵌入会损坏水印。 同一张图嵌两次,第二次的调制会干扰第一次的信号,提取时可能出现乱码。所以 is_blind_watermark 字段必须准,靠它防任务重跑时重复处理——前面说这是命根子,就是这个原因。
提取需要尽量原图。 数据万象的盲水印抗 JPEG/WebP 压缩、抗一定程度的裁剪缩放,但重度二次加工(反复转码、马赛克、重绘、超大滤镜)会让水印退化到提取不出来。所以真遇到纠纷,先想尽办法找清晰度高的搬运版本,成功率会高很多。
几个能拿出手的数据:上传链路零感知,嵌入发生在 COS 内部,用户那边一点感觉没有;十几万存量图,20 线程并发,几轮任务跑完;真有纠纷时,提取出的"@用户名 | 域名"配合上传时间、IP 记录,是以前可见水印给不了的完整证据链。
还有个没想到的隐性收益:威慑。搬运者不知道图里有水印,但平台知道。发生过一次"搬运者被准确点名"的事之后,社区里的搬运风气肉眼可见地收敛了。很多时候,让盗图的人知道"能查到你",比真正查到他更有用。
说起来,那次"点名"还挺戏剧性。有个用户连着搬了站里好几个摄影作者的图,作者们忍无可忍,把截图、原图、时间线全整理好扔到我面前。我一张一张提取,每张图都清清楚楚印着那个搬运账号自己的用户名——他搬图的时候是登录状态上传的,结果把自己的水印也带过去了。证据链完整到他自己都没法狡辩。那之后,那个账号注销了,站里搬运的帖子肉眼可见地少了一截。这事的后劲儿比我预期的大——水印这东西,威慑力往往来自"它可能在那里"。
也得说几句大实话,免得你把它当银弹:
它不是防盗链的银弹。 它解决的是"事后溯源",不是"事前阻止"。真想防搬运,防盗链、压缩图、限制下载这些还得配合。抗二次创作有限。 截图加滤镜加裁切重排后,水印可能提取失败。好在搬运者通常没耐心做这么重的手脚,普通搬运(直接存图、简单裁剪)水印都活得下来。合规要提前想。 给用户图片嵌身份标识,涉及用户知情——我们在用户协议里写明了"为保护版权,平台可能对上传内容添加数字水印"。
最后扯句行业闲话。谷歌的 SynthID、OpenAI 那套 AI 内容水印,本质也是往内容里嵌不可见水印,只是藏的信息从"谁传的"变成了"这是 AI 生成的"。技术路线跟我们做图片溯源同源——都在频域/变换域里做鲁棒调制。隐水印已经从"版权取证的小工具"变成了"内容可信度基础设施"的一部分。你现在不接,等监管要求"AI 内容必须可标记"那天,还是得接。早接早积累。
如果你也在做图片或视频产品,我的建议就一句:把盲水印当基础设施,别当事后补救。上传那一刻就是嵌入的最佳时机——等图流出去再想补,只能靠回补任务追存量,成本高不说,中间那段真空期的图全都裸奔。
这篇的代码都来自悦目图库(yuemutuku.com)的真实生产环境。如果你也在趟盲水印的浑水,欢迎来评论区聊聊——特别是提取成功率优化和回补任务设计,这两块我踩的坑最多。也欢迎去图库随便传一张图,体验一下"每张图都带着身份"的感觉——你的照片从此有了不容易被抹掉的签名。