提示

返回博客列表

给自己的视频加一道隐形标记:不可见水印、内容指纹与溯源能做什么

客户是做原创课程的,有天发现自己的视频被整段搬到另一个平台,对方还打上了自己的 logo。他们来找我们:"能不能证明这是我的?"

我第一反应是"加水印"——但仔细一想,问题分两层:

  1. 发现:怎么知道自己的内容被搬运了?(尤其是对方改了标题、剪掉片头、加了 logo 之后)
  2. 证明:发现了之后,怎么证明那是你的?

这两件事对应两种技术:内容指纹(解决发现)和数字水印(解决证明)。很多人把它们混为一谈,但原理和用法完全不同。

这篇写清楚两者的区别、不可见水印的原理和一个能跑的简化实现,以及最重要的——它到底有多脆弱。

TL;DR:内容指纹不改文件,只对内容算特征(感知哈希),用于"在海量的网络内容里找到跟我相似的"——项目里那篇视频指纹的文章讲过。数字水印是把信息(比如分发 ID)嵌入到像素里,肉眼不可见,事后能提取出来,用于证明来源。ffmpeg 没有成熟的盲水印滤镜,真正的水印要用专门的库。一个简化实现(DCT 中频系数修改)能演示原理,但鲁棒性有限——抗得了重编码,扛不住裁剪、旋转、录屏。别把它当 DRM 用,它的定位是"取证辅助"而不是"防拷贝"。

目录

一、两件事:发现 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 水印)

思路(最基本的方案):

  1. 把帧转 YUV,取亮度通道 Y(人眼对亮度敏感,但改动在频域不明显);
  2. 分 8×8 块,每块做 DCT;
  3. 选两个中频位置(比如 (3,4) 和 (4,3));
  4. 通过调整这两个系数的相对大小来表示 0 或 1;
  5. 反 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

关键设计:

  1. "相对大小"而不是"绝对值"——因为重编码会改变系数的绝对值,但两个相邻系数的大小关系更可能被保留;
  2. 每个块都嵌入同一个 bit(重复嵌入)——某个块被破坏了,其他块还在;
  3. 多数表决提取——提高鲁棒性。

嵌入多 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) 位置的系数现在对应到完全不同的内容。提取时读到的就是错的信息。

录屏:经过了"屏幕显示 → 相机采集"这个过程,包含:

  • 摩尔纹、伽马变化、色彩偏移;
  • 重采样(分辨率变化);
  • 可能还有角度畸变;
  • 帧率变化导致帧序列错位。

要扛住这些,需要:

  1. 同步机制(水印的嵌入位置要能被"找到",而不是固定网格)——比如用特征点(SIFT/SURF)定位;
  2. 纠错编码(BCH/RS 码)——即使一部分 bit 错了也能恢复;
  3. 更强的嵌入策略(扩展频谱、扩频水印)——把信息扩散到整个频域;
  4. 抗几何攻击的设计(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% 的帧匹配")。

我的交付物(给客户的"溯源报告"):

  1. 原始文件的哈希和发布时间;
  2. 可疑文件的哈希和发现时间;
  3. 指纹比对结果(相似度、匹配的时间区间);
  4. 水印提取结果(ID、对应的分发记录);
  5. 提取方法和脚本(供第三方复核)。

十、合规、局限与坑清单

局限(必须说清楚)

  1. 不能防专业去除——知道方法的人可以做针对性攻击(频域滤波、多次重编码、GAN 去除)。水印的价值在于"提高搬运成本"和"留下证据",不是"防止拷贝"。
  2. 不是 DRM——它不阻止任何人观看或下载。
  3. 法律证据效力因地区而异——水印通常作为辅助证据,不是决定性的。别向客户承诺"有了水印就一定能赢官司"。
  4. 鲁棒性和画质是矛盾的——强度越大越鲁棒,但画面损伤越明显。要在实际内容上标定,找平衡点。

合规

  • 只对自己有权利的内容加水印;
  • 水印里不要放个人信息(隐私合规),放一个可映射的 ID 就够了;
  • 如果是"给每个用户分发不同 ID"的指纹方案,要在用户协议里告知(很多地区对这种追踪有合规要求);
  • 不要用这个技术去追踪他人内容(只能用于自己的内容)。

坑清单

  1. 把内容指纹和数字水印搞混 → 一个改文件一个不改,用途完全不同。
  2. 用空域 LSB 水印 → 压一次就没了。必须频域。
  3. 嵌在低频 → 画面明显劣化。用中频。
  4. 固定网格分块 → 裁剪后失效。生产要用带同步的方案。
  5. 每帧都嵌 → 慢 10 倍,收益很小。每 N 帧嵌一次。
  6. 从已压缩的版本二次嵌入 → 水印强度被削弱。从母版嵌。
  7. 嵌入后又用低质量参数编码 → 水印被自己的压缩搞坏。用高质量参数。
  8. 没有 ID 映射表 → 提取出来的 ID 没意义。建表。
  9. 水印强度不标定 → 太弱检测不到,太强画面可见。实测找平衡。
  10. 向客户承诺"防盗" → 做不到。承诺"取证辅助"和"提高搬运成本"。
  11. 提取时只测一帧 → 不可靠。多帧多数表决。
  12. 对可疑文件先转码再提取 → 进一步破坏水印。保留原始文件。
  13. 忽略音频水印 → 音频水印有时比视频更鲁棒(音频重采样比视频重编码温和)。可以考虑双路嵌入。
  14. 水印 ID 太短 → 容量有限且容易碰撞。用 16~32 bit 加纠错编码。
  15. 以为开源库能扛住所有攻击 → 先看它的鲁棒性测试报告(很多库只测重编码和缩放)。

最后说说我对这类技术的一贯态度:把"能做到"和"能做到什么程度"分开说清楚。

技术上,几十行 Python 就能演示一个 DCT 水印。但演示和能用是两回事——我实测下来,那个简化实现只能扛住重编码和轻度缩放,扛不住裁剪和录屏。如果我不说清楚这一点,客户会以为"加了水印就安全了",真出事的时候落差会很大。

所以我给客户的方案文档里,"局限"这一节的篇幅和"能力"一样长。看起来是在泼冷水,但实际上:

  • 客户知道边界,才会做配套的事(比如同时保留发布记录、做第三方存证);
  • 期望管理好了,后面的合作才顺畅;
  • 真到了取证的时候,一套诚实的"辅助证据"比一份夸大的"防盗方案"有用得多。

还有一点值得说:内容指纹的实用价值其实比水印高。

因为它不需要提前准备——已经发布的内容也能追溯,而且它解决的是"发现"这个更前置的问题(不知道被搬了,水印嵌得再好也没用)。所以如果只能做一件事,我建议先做指纹扫描(定期在网上找相似内容),水印作为第二步的证据加固。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部