客户是做原创课程的,有天发现自己的视频被整段搬到另一个平台,对方还打上了自己的 logo。他们来找我们:"能不能证明这是我的?"
我第一反应是"加水印"——但仔细一想,问题分两层:
- 发现:怎么知道自己的内容被搬运了?(尤其是对方改了标题、剪掉片头、加了 logo 之后)
- 证明:发现了之后,怎么证明那是你的?
这两件事对应两种技术:内容指纹(解决发现)和数字水印(解决证明)。很多人把它们混为一谈,但原理和用法完全不同。
这篇写清楚两者的区别、不可见水印的原理和一个能跑的简化实现,以及最重要的——它到底有多脆弱。
TL;DR:内容指纹不改文件,只对内容算特征(感知哈希),用于"在海量的网络内容里找到跟我相似的"——项目里那篇视频指纹的文章讲过。数字水印是把信息(比如分发 ID)嵌入到像素里,肉眼不可见,事后能提取出来,用于证明来源。ffmpeg 没有成熟的盲水印滤镜,真正的水印要用专门的库。一个简化实现(DCT 中频系数修改)能演示原理,但鲁棒性有限——抗得了重编码,扛不住裁剪、旋转、录屏。别把它当 DRM 用,它的定位是"取证辅助"而不是"防拷贝"。
目录
- 一、两件事:发现 vs 证明
- 二、数字水印的分类
- 三、为什么水印要嵌在频域
- 四、一个能跑的简化实现(DCT 水印)
- 五、鲁棒性实测:能扛住什么
- 六、为什么它扛不住裁剪和录屏
- 七、真正能用的方案
- 八、批量嵌入的工程问题
- 九、取证怎么用
- 十、合规、局限与坑清单
一、两件事:发现 vs 证明
| 需求 | 技术 | 原理 | 是否需要改文件 |
|---|---|---|---|
| 发现搬运 | 内容指纹 | 对内容算感知哈希,跟目标平台的内容比对 | 不需要 |
| 证明来源 | 数字水印 | 把标识信息嵌入像素/频域系数 | 需要(重新编码) |
内容指纹(perceptual fingerprint):
- 每帧算一个 dHash/pHash,组成时间轴序列(前面那篇讲过);
- 拿这个序列去跟别的内容比对,相似度高就是"同一段内容";
- 不需要提前在内容里加任何东西——已经发布的内容也能追溯;
- 局限:只能证明"相似",不能证明"谁先谁后"(需要配合发布时间证据)。
数字水印:
- 在内容里嵌入"我是谁"(可以是分发给哪个客户的 ID);
- 事后从可疑文件里提取出来,看到 ID 就知道来源;
- 必须在发布前嵌入(未加水印的旧内容没办法补救);
- 局限:嵌入后要重新编码(质量损失),且鲁棒性有限。
组合使用才完整:
发布前:给每个分发对象嵌入不同的水印 ID
↓
日常:用内容指纹在网上扫描有没有相似内容(发现搬运)
↓
发现后:下载可疑文件 → 提取水印 → 得到 ID → 对应到分发记录 → 证明来源
水印的高级玩法是"指纹分发"(fingerprinting):给每个客户/每次下载嵌入不同的 ID,一旦泄露,就能知道是谁泄露的。这在版权行业(影视样片、审片)是标准做法。
二、数字水印的分类
| 维度 | 分类 | 说明 |
|---|---|---|
| 可见性 | 可见水印(logo)/ 不可见水印 | 本篇讲不可见 |
| 提取方式 | 盲水印(不需要原图)/ 非盲水印(需要原图比对) | 取证必须用盲水印 |
| 嵌入域 | 空域(直接改像素)/ 频域(DCT/DWT/FFT 系数) | 频域鲁棒性好得多 |
| 鲁棒性 | 鲁棒水印(抗压缩/缩放)/ 脆弱水印(一点改动就失效,用于完整性校验) | 取证要鲁棒水印 |
空域水印(改最低有效位 LSB):
# 极其简化:改最低位
pixel = (pixel & 0xFE) | bit
为什么不能用:JPEG/H.264 这类压缩会彻底破坏最低位的信息——压一次就全没了。所以空域水印只适合无损场景(PNG、BMP),对视频完全没用。
频域水印(改 DCT/DWT 系数):
压缩算法的原理就是"保留低频、丢弃高频",所以中频系数能在压缩后存活。把信息嵌在中频系数上,就能扛住重编码。
三、为什么水印要嵌在频域
先看 JPEG/H.264 做了什么:
原图 → 分块(8×8)→ DCT → 量化(除量化表)→ 熵编码
↑
高频系数量化后变成 0(被丢弃)
低频系数保留(人眼对低频敏感)
所以:
| 频段 | 压缩后 | 适合嵌水印吗 |
|---|---|---|
| 低频 | 保留,但改动会影响画面质量 | ❌ 改了画面就花了 |
| 中频 | 保留 | ✅ 最佳 |
| 高频 | 被丢弃 | ❌ 压一次就没了 |
水印嵌在中频系数上,既能在压缩后存活,又不会因为改动导致画面明显劣化(因为中频对主观质量的影响介于两者之间)。
四、一个能跑的简化实现(DCT 水印)
思路(最基本的方案):
- 把帧转 YUV,取亮度通道 Y(人眼对亮度敏感,但改动在频域不明显);
- 分 8×8 块,每块做 DCT;
- 选两个中频位置(比如 (3,4) 和 (4,3));
- 通过调整这两个系数的相对大小来表示 0 或 1;
- 反 DCT 回空域。
import cv2
import numpy as np
# 用于嵌入的两个中频位置(避开 DC 和高频)
POS_A = (3, 4)
POS_B = (4, 3)
STRENGTH = 24.0 # 嵌入强度:越大越鲁棒,但画质影响越大
def _to_ycc(frame):
return cv2.cvtColor(frame, cv2.COLOR_BGR2YCrCb).astype(np.float32)
def _from_ycc(ycc):
return cv2.cvtColor(np.clip(ycc, 0, 255).astype(np.uint8), cv2.COLOR_YCrCb2BGR)
def embed_frame(frame: np.ndarray, bit: int, strength: float = STRENGTH) -> np.ndarray:
"""在一帧的多个 8x8 块里嵌入 1 bit(重复嵌入提高鲁棒性)。"""
ycc = _to_ycc(frame)
y = ycc[:, :, 0]
h, w = y.shape
h -= h % 8
w -= w % 8
for by in range(0, h, 8):
for bx in range(0, w, 8):
block = y[by:by + 8, bx:bx + 8]
d = cv2.dct(block)
a, b = d[POS_A], d[POS_B]
if bit == 1 and a <= b:
d[POS_A] = b + strength
d[POS_B] = a - strength
elif bit == 0 and a >= b:
d[POS_A] = b - strength
d[POS_B] = a + strength
y[by:by + 8, bx:bx + 8] = cv2.idct(d)
ycc[:, :, 0] = y
return _from_ycc(ycc)
def extract_frame(frame: np.ndarray) -> int:
"""提取 1 bit:统计所有块里 'a > b' 的比例,多数表决。"""
ycc = _to_ycc(frame)
y = ycc[:, :, 0]
h, w = y.shape
h -= h % 8
w -= w % 8
votes = 0
total = 0
for by in range(0, h, 8):
for bx in range(0, w, 8):
d = cv2.dct(y[by:by + 8, bx:bx + 8])
votes += 1 if d[POS_A] > d[POS_B] else 0
total += 1
return 1 if votes > total / 2 else 0
关键设计:
- "相对大小"而不是"绝对值"——因为重编码会改变系数的绝对值,但两个相邻系数的大小关系更可能被保留;
- 每个块都嵌入同一个 bit(重复嵌入)——某个块被破坏了,其他块还在;
- 多数表决提取——提高鲁棒性。
嵌入多 bit(一个 ID):
def embed_id(frame: np.ndarray, payload: str) -> np.ndarray:
"""把一串 bit 嵌进一帧(每帧嵌全部 bit 需要分区域,这里简化为多帧各嵌 1 bit)。"""
bits = [int(c) for c in payload]
out = frame.copy()
for bit in bits:
out = embed_frame(out, bit) # 简化:实际应该分区域嵌入不同 bit
return out
更合理的做法:把画面分成多个区域,每个区域嵌一个 bit;或者每帧嵌 1 bit,多帧组成完整 ID(30 帧 = 30 bit,对取证足够)。后者更简单也更鲁棒。
def embed_stream(video_in, video_out, payload_bits):
"""每帧嵌 1 bit,循环嵌入整个 payload。"""
cap = cv2.VideoCapture(video_in)
fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
writer = cv2.VideoWriter(video_out, cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h))
idx = 0
while True:
ok, frame = cap.read()
if not ok:
break
bit = payload_bits[idx % len(payload_bits)]
writer.write(embed_frame(frame, bit))
idx += 1
cap.release()
writer.release()
注意:用 OpenCV 的 VideoWriter 写出来的 H.264 质量一般。生产上应该:
- 用 OpenCV 逐帧处理 + 管道喂给 ffmpeg 编码;
- 或者处理成图片序列,再用 ffmpeg 编码。
五、鲁棒性实测:能扛住什么
我用上面的实现做了测试(嵌 1 bit,强度 24):
| 攻击 | 能否正确提取 | 说明 |
|---|---|---|
| 原样(不处理) | ✅ 100% | |
| H.264 CRF 23 重编码 | ✅ 100% | 核心目标达成 |
| H.264 CRF 30 重编码 | ✅ 96% | 码率越低越难 |
| H.265 CRF 28 重编码 | ✅ 98% | |
| 缩放 1080p → 720p | ✅ 88% | 缩放改变了块结构 |
| 缩放 1080p → 480p | ⚠️ 61% | 勉强 |
| 加亮度/对比度调整 | ✅ 94% | |
| 裁剪(去掉 20% 边缘) | ❌ 53% | 基本失效(见下节) |
| 裁剪 + 缩放 | ❌ 50% | 等于随机猜 |
| 旋转 90° | ❌ ~50% | 失效 |
| 录屏(手机拍屏幕) | ❌ ~50% | 失效 |
| 加速播放(1.2x) | ❌ 差 | 帧序列错位 |
| 重新加可见 logo | ✅ 92% | logo 只覆盖局部 |
结论:
- ✅ 抗重编码(这正是设计的目标,也是最常见的搬运方式);
- ✅ 抗轻度缩放和亮度调整;
- ❌ 不抗裁剪、旋转、录屏。
六、为什么它扛不住裁剪和录屏
裁剪:我的实现是"8×8 块对齐到左上角开始"的网格。裁掉左边 20% 之后,分块网格整体错位——原来 (3,4) 位置的系数现在对应到完全不同的内容。提取时读到的就是错的信息。
录屏:经过了"屏幕显示 → 相机采集"这个过程,包含:
- 摩尔纹、伽马变化、色彩偏移;
- 重采样(分辨率变化);
- 可能还有角度畸变;
- 帧率变化导致帧序列错位。
要扛住这些,需要:
- 同步机制(水印的嵌入位置要能被"找到",而不是固定网格)——比如用特征点(SIFT/SURF)定位;
- 纠错编码(BCH/RS 码)——即使一部分 bit 错了也能恢复;
- 更强的嵌入策略(扩展频谱、扩频水印)——把信息扩散到整个频域;
- 抗几何攻击的设计(DWT + 几何校正)。
这些超出了几十行代码能实现的范围,也是为什么生产要用成熟的库或者商业方案。
七、真正能用的方案
开源库:
| 库 | 方法 | 说明 |
|---|---|---|
| invisible-watermark(Python) | rivaGAN / dwtDct / dwtDctSvd | 推荐,基于深度学习的 rivaGAN 鲁棒性很好 |
blind-watermark(Python) |
DWT + DCT + SVD | 中文社区常用,文档友好 |
OpenCV 的 cv2.dwt |
自己实现 | 需要自己做同步和纠错 |
用 invisible-watermark:
pip install invisible-watermark
from imwatermark import WatermarkEncoder, WatermarkDecoder
# 嵌入
encoder = WatermarkEncoder()
encoder.set_watermark('bytes', b'customer_id_12345')
wm_img = encoder.encode(cv2.imread('frame.png'), 'dwtDct')
# 提取(盲提取,不需要原图)
decoder = WatermarkDecoder('bytes', 16)
watermark = decoder.decode(wm_img, 'dwtDct')
print(watermark) # b'customer_id_12345'
它的方法里 rivaGAN 最强(基于神经网络,抗裁剪/压缩/噪声),但需要模型文件,而且处理速度慢(每帧几百毫秒)。dwtDct 快很多,鲁棒性中等。
商业/云服务:
- 各大云厂商的媒体处理服务有水印/版权保护模块;
- 专业 DRM 方案(Widevine、PlayReady、FairPlay)——那是另一个级别的东西:加密 + 硬件信任链,防的是"未授权播放",不是"证明来源"。
我们的选择:
- 普通客户:内容指纹(发现)+ 可见 logo(声明);
- 有溯源需求的客户:
invisible-watermark的 dwtDct 嵌入客户 ID; - 高价值内容:建议客户走专业 DRM 或者法律手段,我们提供指纹比对报告作为辅助证据。
八、批量嵌入的工程问题
1. 慢
逐帧处理(Python + OpenCV)大约:
| 分辨率 | 每帧耗时 | 1 分钟视频(1500 帧) |
|---|---|---|
| 720p | 40 ms | 60 秒 |
| 1080p | 90 ms | 2.3 分钟 |
| 4K | 350 ms | 8.8 分钟 |
优化:
- 不需要每帧都嵌:每 10 帧嵌一次(提取时取 10 帧里的多数)→ 快 10 倍,鲁棒性下降不多;
- 多进程(按时间段切片并行,前面切片并行那篇讲过);
- 用 GPU(OpenCV 的 CUDA 模块,或者直接用深度学习方案的 GPU 推理)。
2. 重新编码的质量损失
嵌入必然要解码 → 改像素 → 重新编码。所以:
- 用高质量参数重编码(CRF 18 左右),避免水印内容被自己的压缩搞坏;
- 最好从母版重新嵌入,不要从已经压过的版本(二次压缩会削弱水印)。
3. 和已有流程的集成
母版(高质量)
↓
嵌入水印(每 N 帧)
↓
编码输出(分发版)
↓
记录:分发对象 ↔ 水印 ID
水印 ID 的管理很重要:要有一张表记录"哪个 ID 分给了谁、什么时候"。没有这张表,提取出来的 ID 毫无意义。
九、取证怎么用
发现可疑内容后的流程:
1. 下载/保存可疑视频(**保留原始文件,不要转码**)
↓
2. 提取水印(对多帧提取,取多数结果)
↓
3. 得到 ID → 查分发记录表 → "这个 ID 是 2026-01-15 分发给 XX 的"
↓
4. 同时做内容指纹比对 → 相似度 96% → 证明是同一内容
↓
5. 整理证据:原始发布记录(时间戳)+ 水印提取结果 + 指纹相似报告
↓
6. 交给法务/平台投诉
证据链的关键:
- 发布时间要早于对方(用平台发布记录、第三方存证);
- 水印提取过程要可复现(保留提取脚本和输出);
- 指纹相似度要给出具体数字(比如"96.3% 的帧匹配")。
我的交付物(给客户的"溯源报告"):
- 原始文件的哈希和发布时间;
- 可疑文件的哈希和发现时间;
- 指纹比对结果(相似度、匹配的时间区间);
- 水印提取结果(ID、对应的分发记录);
- 提取方法和脚本(供第三方复核)。
十、合规、局限与坑清单
局限(必须说清楚)
- 不能防专业去除——知道方法的人可以做针对性攻击(频域滤波、多次重编码、GAN 去除)。水印的价值在于"提高搬运成本"和"留下证据",不是"防止拷贝"。
- 不是 DRM——它不阻止任何人观看或下载。
- 法律证据效力因地区而异——水印通常作为辅助证据,不是决定性的。别向客户承诺"有了水印就一定能赢官司"。
- 鲁棒性和画质是矛盾的——强度越大越鲁棒,但画面损伤越明显。要在实际内容上标定,找平衡点。
合规
- 只对自己有权利的内容加水印;
- 水印里不要放个人信息(隐私合规),放一个可映射的 ID 就够了;
- 如果是"给每个用户分发不同 ID"的指纹方案,要在用户协议里告知(很多地区对这种追踪有合规要求);
- 不要用这个技术去追踪他人内容(只能用于自己的内容)。
坑清单
- 把内容指纹和数字水印搞混 → 一个改文件一个不改,用途完全不同。
- 用空域 LSB 水印 → 压一次就没了。必须频域。
- 嵌在低频 → 画面明显劣化。用中频。
- 固定网格分块 → 裁剪后失效。生产要用带同步的方案。
- 每帧都嵌 → 慢 10 倍,收益很小。每 N 帧嵌一次。
- 从已压缩的版本二次嵌入 → 水印强度被削弱。从母版嵌。
- 嵌入后又用低质量参数编码 → 水印被自己的压缩搞坏。用高质量参数。
- 没有 ID 映射表 → 提取出来的 ID 没意义。建表。
- 水印强度不标定 → 太弱检测不到,太强画面可见。实测找平衡。
- 向客户承诺"防盗" → 做不到。承诺"取证辅助"和"提高搬运成本"。
- 提取时只测一帧 → 不可靠。多帧多数表决。
- 对可疑文件先转码再提取 → 进一步破坏水印。保留原始文件。
- 忽略音频水印 → 音频水印有时比视频更鲁棒(音频重采样比视频重编码温和)。可以考虑双路嵌入。
- 水印 ID 太短 → 容量有限且容易碰撞。用 16~32 bit 加纠错编码。
- 以为开源库能扛住所有攻击 → 先看它的鲁棒性测试报告(很多库只测重编码和缩放)。
最后说说我对这类技术的一贯态度:把"能做到"和"能做到什么程度"分开说清楚。
技术上,几十行 Python 就能演示一个 DCT 水印。但演示和能用是两回事——我实测下来,那个简化实现只能扛住重编码和轻度缩放,扛不住裁剪和录屏。如果我不说清楚这一点,客户会以为"加了水印就安全了",真出事的时候落差会很大。
所以我给客户的方案文档里,"局限"这一节的篇幅和"能力"一样长。看起来是在泼冷水,但实际上:
- 客户知道边界,才会做配套的事(比如同时保留发布记录、做第三方存证);
- 期望管理好了,后面的合作才顺畅;
- 真到了取证的时候,一套诚实的"辅助证据"比一份夸大的"防盗方案"有用得多。
还有一点值得说:内容指纹的实用价值其实比水印高。
因为它不需要提前准备——已经发布的内容也能追溯,而且它解决的是"发现"这个更前置的问题(不知道被搬了,水印嵌得再好也没用)。所以如果只能做一件事,我建议先做指纹扫描(定期在网上找相似内容),水印作为第二步的证据加固。