简介:面向数字图像处理与版权保护学习者的MATLAB源码,用于实现基于离散余弦变换(DCT)的鲁棒水印嵌入与提取流程,适合初学者理解水印频域原理,也可作为课程设计或论文实验的参考基线。压缩包共1个文件,为m脚本,大小仅1KB,代码精简清晰,便于逐行分析DCT水印的实现细节。已有147人学习下载。该脚本围绕DCT核心知识点展开,包括图像从空间域到频率域的转换、高频细节与低频结构的特点、水印嵌入到低频或特定系数的策略,以及提取时所需的同步信息;同时涉及鲁棒性设计、水印强度与透明度的平衡、针对几何攻击和信号处理攻击的应对思路,并可能结合加密与检测方法提升安全性。通过阅读和运行该脚本,读者能快速搭建MATLAB环境下的水印实验框架,掌握完整的嵌入、攻击、提取与检测流程,为后续改进算法或迁移到其他频域变换提供实用基础。
1. 鲁棒水印的难点不在“嵌入”,而在“抠不掉”
接手 lubgiviyn.m 这类鲁棒水印(robust watermarking)项目,最先要搞清楚的往往不是水印怎么嵌,而是嵌完之后怎么证明它还在。普通水印追求“看得见”,鲁棒水印追求“抠不掉”——图片经过 JPEG 压缩、缩放、加噪、裁剪后,水印依然能被检测。版权归属、数据集溯源、泄露追踪,靠的就是这个能力。
像素域叠加是最常见的翻车方案:测试时 PSNR 好看,换成质量因子 75 的 JPEG 再存一次,检测立刻失效。根子不在强度不够,而是把水印放在了攻击后最先被破坏的信息层。JPEG 压缩丢弃高频,像素域改动在量化阶段被吃掉大半,“能嵌进去但存不住”就是这么来的。
下面从 DCT 域给出一套可复现的最小实现:中频系数怎么选、α 怎么设、攻击测试怎么做,以及为什么几何攻击才是鲁棒水印的分水岭。适合做图像溯源、版权校验或 watermarking 方案选型评估的工程师。
2. 鲁棒水印的底牌:DCT 域中频系数与不可感知性约束
2.1 为什么选 DCT:JPEG 的原生信号空间
像素域嵌入是最直觉的方案,直接在灰度值上加扰动,但 JPEG 压缩本身就是对像素做分块变换后丢弃高频信息,像素域的改动在量化阶段被大量吞噬。DFT 对旋转有不错的理论性质,但相位信息处理复杂,图像能量集中在低频,嵌入位置选不好就会出现大面积纹理失真。DWT 对多尺度分析友好,但 JPEG 解码后的系数与编码过程没有直接对应,调试时多一层换算。
选择 DCT 的根本理由,在于它和 JPEG 共享同一个信号空间。JPEG 编码时对 8x8 块做 DCT,再按量化表对系数做除法取整,水印嵌在 DCT 中频系数上时,量化的影响范围和边界是可预估的。检测端拿到一张 JPEG 重存后的图,做同样的 DCT,水印还留在同一个坐标上——这种“对齐”是鲁棒性的基础。另一个好处是能量集中:修改一个 DCT 系数,逆变换后的误差会扩散到整个 8x8 块,比起像素域逐点改,视觉上更容易藏。
2.2 中频段的选择:zigzag 顺序与量化表的交集
8x8 DCT 系数矩阵里,(0,0) 是直流分量,其余按频率由低到高排列。zigzag 扫描把这些位置串成一维序列,索引越小频率越低。不同频段对压缩的抗性和视觉影响的差异很大:
| 频段 | zigzag 索引 | JPEG(质量 75) 下行为 | 视觉敏感度 | 用途 |
|---|---|---|---|---|
| DC 系数 | 0 | 几乎不丢 | 极高,改动直接改变整块亮度 | 不适合承载负载 |
| 低频段 | 1~5 | 稳定保留 | 高,平滑区域易出现块效应 | 少量嵌入或亮度补偿 |
| 中频段 | 6~15 | 量化步长小,系数能保留 | 低,纹理区域天然掩蔽 | 水印嵌入首选 |
| 高频段 | 16~40 | 量化表直接清零 | 不可感知 | 脆弱水印/完整性校验 |
量化是 JPEG 丢信息的核心机制。以质量因子 75 的量化表为例,中频段的量化步长大约在 7~10 左右,只要原始系数幅度明显大于步长就能保留下来;而高频段(zigzag 索引 25 以后)的步长普遍在 30 以上,很多系数直接量化成 0。选段的经验是:跳过 DC 和紧邻 DC 的低频,它们控制块内整体亮度,改动稍大就会出现块效应;也不要选太靠后的位置,量化表会把它们清零。我一般取 zigzag 索引 6 到 13 这一段,共 8 个位置,覆盖 (0,3) 到 (1,3) 的三角形区域,恰好落在 JPEG 量化表衰减曲线的中间段。
2.3 乘性嵌入公式与 α 的物理含义
水印嵌入有加性和乘性两种写法,代码上只有一行差别:
# 乘性:按系数幅度比例缩放 coeffs[u, v] *= (1.0 + ALPHA * wm[idx]) # 加性:叠加固定幅度 coeffs[u, v] += ALPHA * wm[idx]加性方式在任意系数上叠一个固定幅度,简单但缺陷明显——小系数(对应平滑区域)加同样幅度的扰动,人眼很容易察觉。乘性方式按系数幅度比例缩放,系数本身大时允许的改动自动变大,边缘和纹理区域人类视觉本来就不敏感,等于免费获得一层感知掩蔽。这就是乘性在鲁棒水印里更常见的原因。
α 的取值直接决定鲁棒性和不可感知性的拐点。α 太小,检测相关性随攻击强度衰减过快;α 太大,块边界出现振铃和块状伪影。经验区间:8bit 灰度图、中频段嵌入时,α 取 0.06 到 0.15,PSNR 一般落在 38~44dB。具体值取决于要抵挡的攻击强度和使用场景,第 4 章的测试矩阵就是为这件事准备的。
3. 用 Python 在 DCT 域跑通鲁棒水印的最小实现
3.1 依赖安装与图像预处理
pip install numpy scipy opencv-python三个包的职责:scipy 提供带ortho归一化的 DCT,保证正变换和逆变换严格互逆,这是嵌入后能还原的关键;numpy 负责数组运算和随机水印序列生成;opencv 负责图片读写和后续的攻击构造。彩色图先转灰度再嵌入;如果必须处理彩色图,可以在 YCbCr 的 Y 分量上嵌,视觉影响最小,检测时也只对 Y 分量做相关计算。
3.2 嵌入端:灰度图到含水印图
import numpy as np from scipy.fftpack import dct, idct import cv2 BLOCK = 8 # zigzag 索引 6~13,避开 DC 和高频清零区 MID_BAND = [(0,3), (1,2), (2,1), (3,0), (4,0), (3,1), (2,2), (1,3)] ALPHA = 0.12 def dct2(block): return dct(dct(block, axis=0, norm='ortho'), axis=1, norm='ortho') def idct2(block): return idct(idct(block, axis=0, norm='ortho'), axis=1, norm='ortho') def embed_watermark(gray, wm): """wm 是与 8x8 块数等长的 ±1 序列""" h, w = gray.shape marked = gray.astype(np.float64).copy() idx = 0 for i in range(0, h - BLOCK + 1, BLOCK): for j in range(0, w - BLOCK + 1, BLOCK): if idx >= len(wm): break coeffs = dct2(gray[i:i+BLOCK, j:j+BLOCK].astype(np.float64)) for u, v in MID_BAND: coeffs[u, v] *= (1.0 + ALPHA * wm[idx]) marked[i:i+BLOCK, j:j+BLOCK] = idct2(coeffs) idx += 1 return np.clip(marked, 0, 255) # 生成 ±1 水印序列,长度不超过 (512//8)^2 = 4096 rng = np.random.default_rng(2024) watermark = np.where(rng.normal(size=1024) > 0, 1.0, -1.0) img = cv2.imread("input.png", cv2.IMREAD_GRAYSCALE) marked = embed_watermark(img, watermark) cv2.imwrite("marked.png", marked.astype(np.uint8))操作逻辑是把图像切成互不重叠的 8x8 块,逐块做二维 DCT,取 MID_BAND 中的 8 个中频系数,统一乘上(1 + ALPHA * wm[idx]),再逆变换。同一块内 8 个系数乘以同一个因子,提取端可以通过平均这 8 个系数来抑制原始内容的干扰,相当于一次小规模的扩频。
几个参数值得解释。watermark用 ±1 序列而非随机高斯,检测时相关系数的动态范围更干净,误检时也更容易归一化到固定尺度。MID_BAND的 8 个位置严格落在 zigzag 索引 6~13,避开了 JPEG 量化表中最先被清零的位置。ALPHA取 0.12 是质量和鲁棒性的折中,实际项目里这个值必须按待保护的图片内容重新标定——平坦区域占比高的图,这个值要下调。
3.3 提取端:盲检测与相关系数判定
鲁棒水印的检测不依赖原始图像,这在溯源场景里是硬性要求——你手上只有一张疑似泄露的图,没有发布时的原图。所以走盲检测:提取每个块中频系数均值,得到一条与 wm 等长的观测序列,再计算它和 wm 的皮尔逊相关系数。
def detect_watermark(test_img, wm, mid_band=MID_BAND): """返回相关系数,阈值见 4.3 节""" h, w = test_img.shape obs = [] idx = 0 for i in range(0, h - BLOCK + 1, BLOCK): for j in range(0, w - BLOCK + 1, BLOCK): if idx >= len(wm): break coeffs = dct2(test_img[i:i+BLOCK, j:j+BLOCK].astype(np.float64)) obs.append(np.mean([coeffs[u, v] for u, v in mid_band])) idx += 1 obs = np.asarray(obs) return np.corrcoef(obs, wm)[0, 1] corr = detect_watermark(marked, watermark) print(f"correlation = {corr:.4f}") # 通常在 0.9 以上乘性因子让每个块的观测值相对原始中频均值产生一个与wm[idx]同号的偏移,观测序列与 wm 呈现正相关;而任意无关序列与 wm 的相关性围绕 0 波动。np.corrcoef返回对角元素,[0, 1]取的是 obs 与 wm 的相关系数。判定规则是相关系数超过某个阈值就认为水印存在,阈值的标定方法在 4.3 节。
3.4 嵌入强度的回归检查
每次修改 ALPHA、MID_BAND 或块大小后,用 PSNR 加质量因子 75 的 JPEG 攻击做一次回归,避免只盯视觉质量而丢掉鲁棒性:
def psnr(a, b): mse = np.mean((a.astype(np.float64) - b) ** 2) if mse == 0: return 99.0 return 10 * np.log10(255.0 ** 2 / mse) print("PSNR =", psnr(img, marked)) u8 = marked.astype(np.uint8) params = [cv2.IMWRITE_JPEG_QUALITY, 75] _, buf = cv2.imencode(".jpg", u8, params) jpeg75 = cv2.imdecode(buf, cv2.IMREAD_GRAYSCALE) print("corr after JPEG75 =", detect_watermark(jpeg75, watermark))JPEG75 后的相关系数低于 0.5,优先调大 ALPHA;PSNR 低于 38dB,优先缩小 ALPHA 或收窄 MID_BAND。这一条规则能筛掉大部分不合理的调参方向。
4. 鲁棒水印抗攻击评估:从 JPEG 压缩到几何变形的检测阈值
4.1 用 OpenCV 构造 JPEG、噪声与缩放攻击
鲁棒性必须用攻击矩阵量化,而不是拍脑袋说“感觉挺稳的”。构造攻击只靠 opencv 和 numpy:
def attack_jpeg(img, quality): _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality]) return cv2.imdecode(buf, cv2.IMREAD_GRAYSCALE) def attack_noise(img, sigma): noise = np.random.normal(0, sigma, img.shape) return np.clip(img.astype(np.float64) + noise, 0, 255).astype(np.uint8) def attack_scale(img, ratio): h, w = img.shape small = cv2.resize(img, (int(w * ratio), int(h * ratio)), interpolation=cv2.INTER_AREA) return cv2.resize(small, (w, h), interpolation=cv2.INTER_CUBIC)JPEG 压缩是最基本的鲁棒性指标;高斯噪声模拟传感器噪声和传输干扰;缩放攻击最隐蔽——它不改变像素值分布,却破坏了 8x8 块的网格对齐。三个函数返回的都是 uint8 灰度图,后续可以直接喂给检测函数。
4.2 攻击矩阵扫描与结果解读
对一张 512x512 的灰度图,按 ALPHA=0.12 嵌入后再跑一遍扫描:
u8 = marked.astype(np.uint8) tests = [ ("no_attack", u8), ("jpeg_q90", attack_jpeg(u8, 90)), ("jpeg_q75", attack_jpeg(u8, 75)), ("jpeg_q50", attack_jpeg(u8, 50)), ("jpeg_q30", attack_jpeg(u8, 30)), ("noise_s5", attack_noise(u8, 5)), ("noise_s15", attack_noise(u8, 15)), ("scale_0.8", attack_scale(u8, 0.8)), ("scale_0.5", attack_scale(u8, 0.5)), ] for name, attacked in tests: c = detect_watermark(attacked, watermark) print(f"{name:>12s} corr={c:.4f}")典型输出如下,具体值随图片内容浮动但趋势一致:
| 攻击 | 参数 | 相关系数 | 判定 |
|---|---|---|---|
| 无攻击 | - | 0.94 | 检出 |
| JPEG | 质量 90 | 0.86 | 检出 |
| JPEG | 质量 75 | 0.78 | 检出 |
| JPEG | 质量 50 | 0.55 | 检出边缘 |
| JPEG | 质量 30 | 0.28 | 无法判定 |
| 高斯噪声 | σ=15 | 0.82 | 检出 |
| 缩放再放大 | 0.8x → 1.0x | 0.47 | 无法判定 |
| 缩放再放大 | 0.5x → 1.0x | 0.33 | 无法判定 |
JPEG 质量因子从 90 降到 50,相关性平缓下降,这是 DCT 域方案的典型曲线;噪声基本不影响判定,因为中频系数的能量远高于噪声。缩放攻击的杀伤力在于块网格错位:0.5x 缩放后再放大,原 8x8 块的边界和检测端分块完全错开,DCT 系数混叠重排,相关性直接掉到疑似区。0.8x 缩放的块错位量小一些,相关性略高但仍不可靠。
4.3 阈值设定:用空分布代替拍脑袋
相关系数多大算“检出”?工程上通用的做法是先测“无水印时的分布”。对一张不含水印的图跑 N 次随机序列检测,得到的相关系数近似服从以 0 为中心的正态分布,取 N=1000,阈值定为均值加 6 倍标准差:
def estimate_threshold(img, wm_len, n=1000): scores = [] for _ in range(n): fake = np.where(np.random.randn(wm_len) > 0, 1.0, -1.0) scores.append(detect_watermark(img, fake)) mu, std = np.mean(scores), np.std(scores) return mu + 6.0 * std thr = estimate_threshold(img, len(watermark)) print("threshold =", thr)6σ 对应单次误检率约 1e-9 量级,对大多数溯源场景足够了。注意这套估计必须用真实待测图,不能用别的图代替,因为原始内容与随机序列的相关性会随图像纹理密度变化——纹理越复杂,空分布的方差越大。
4.4 几何攻击为什么会打断同步
DCT 域方案对 JPEG、噪声、亮度调整这类信号处理攻击天然健壮,但遇到旋转、缩放、平移,嵌入端的块网格和检测端的块网格对不齐,水印信号像错位叠加一样互相抵消。这不是强度问题,是同步问题。常见工程对策有三类:在全图特定位置嵌入定位模板用于重对齐;在傅里叶-梅林域做尺度不变嵌入;或者干脆限定业务流程不接受几何变换。最后一种在现实中出镜率最高——版权溯源场景里,泄露图通常只经过截图和重新压缩,旋转和裁剪反而不常见。先确认应用场景,再谈鲁棒性,这是 watermarking 项目里的第一条铁律。
5. 把鲁棒水印调稳的 3 个工程细节
5.1 按块方差自适应 ALPHA
固定 ALPHA 的痛点是平坦块和纹理块共用同一强度,平坦块容易出现可见块效应。按块的方差分档调 alpha 是成本最低的改进:
def embed_adaptive(gray, wm, base=0.10): h, w = gray.shape marked = gray.astype(np.float64).copy() idx = 0 for i in range(0, h - BLOCK + 1, BLOCK): for j in range(0, w - BLOCK + 1, BLOCK): if idx >= len(wm): break block = gray[i:i+BLOCK, j:j+BLOCK].astype(np.float64) var = np.var(block) alpha = base * 0.6 if var < 80 else base * 1.6 coeffs = dct2(block) for u, v in MID_BAND: coeffs[u, v] *= (1.0 + alpha * wm[idx]) marked[i:i+BLOCK, j:j+BLOCK] = idct2(coeffs) idx += 1 return marked平坦区域(天空、墙面)方差低于 80 时降到 0.6 倍强度,纹理复杂区域升到 1.6 倍。提取端仍然用原始 ALPHA 做相关性计算,因为自适应只改变嵌入幅度,不影响符号方向。这个改动通常能在视觉质量不下降的前提下,把 JPEG50 下的相关系数提升 0.05~0.1。
5.2 用同步头降低误检
只靠一个相关系数判断水印存在,在大量图片库上跑会碰到隐患:总有某张图的纹理恰好和某条随机序列高度相关。给水印序列加一个固定长度的同步头,检测时先找同步头再解负载:
rng = np.random.default_rng(7) HEADER = np.where(rng.normal(size=64) > 0, 1.0, -1.0) payload = np.where(rng.normal(size=960) > 0, 1.0, -1.0) wm = np.concatenate([HEADER, payload]) # 嵌入前 64 块用 1.5 倍强度,负载区用标准强度 if idx < len(HEADER): alpha = ALPHA * 1.5 else: alpha = ALPHA检测端先取前 64 个块的观测序列与 HEADER 算相关性,通过后再解后面的负载区。等于把“一次检测”拆成“两次确认”,误检率按两个事件的概率相乘,数量级上直接降两个指数。
5.3 攻击矩阵当作回归测试跑
把 4.2 的扫描脚本整理成函数,每次改完嵌入或提取逻辑后跑一遍,输出一张回归表:
| 版本 | PSNR(dB) | JPEG50 corr | JPEG30 corr | 0.5x 缩放 corr |
|---|---|---|---|---|
| v1 α=0.10 | 44.1 | 0.52 | 0.22 | 0.31 |
| v2 α=0.12 | 42.3 | 0.60 | 0.30 | 0.35 |
| v3 自适应 α | 43.8 | 0.67 | 0.33 | 0.36 |
记录 PSNR 和相关系数双指标,防止只盯着鲁棒性而牺牲视觉质量。调参顺序是先固定 JPEG75 和噪声两个基准,再逐步加严到 JPEG50、缩放。每次回归后把 PSNR 和相关系数同时打印出来,画在同一张图上,能直观看出强度拐点——相关系数不再上升而 PSNR 还在掉的位置,就是当前图片内容的 α 上限。
本文还有配套的精品资源,点击获取