简介:本资源围绕基于离散余弦变换(DCT)的数字水印技术展开,面向图像处理、信息安全方向的学习者与研究人员,帮助理解不可见水印的生成、嵌入与检测流程,掌握版权保护与内容认证的典型实现思路。压缩包共3个文件,包含1个m脚本、1个jpg与1个png图像素材,整体约402KB,其中MATLAB脚本用于完成二维DCT变换、水印嵌入与逆变换恢复,图像文件则作为载体与对照样本,便于直接运行与观察效果。资源已有1522人学习下载,说明其在数字水印入门与实验环节具有一定参考价值。通过该资料,读者可结合dct2与idct2函数实践频域水印方案,理解低频与高频系数选择对不可见性和鲁棒性的影响,并借助仿真对比嵌入前后图像差异,为后续研究抗缩放、旋转、滤波等攻击的水印算法打下基础。
1. 从一次截图维权说起:DCT 数字水印到底能干什么
前阵子帮一个做摄影的朋友处理图片被搬运的事,他发出去的作品被人裁掉水印直接商用,截图对质时对方一句“图哪写你名字了”就把他噎住了。这件事让我重新翻出硬盘里那份基于 DCT 的数字水印实现——它解决的就是这类问题:把版权信息嵌进图像频域里,肉眼看不出来,但提取时能拿出证据。DCT(离散余弦变换)是 JPEG 压缩的核心,把水印藏在 DCT 系数里,等于顺着图像本身的压缩逻辑走,抗压缩能力比直接改像素强得多。这份资源适合做图像版权保护、隐蔽通信、课程设计或想搞懂频域水印原理的开发者,下面我按“原理—实现—参数—排错”的顺序拆一遍,能直接抄作业。
2. DCT 水印的数学底子:为什么选频域而不是空域
2.1 空域水印的翻车现场
很多人第一次做水印,直觉是改像素最低位(LSB)。我最早也这么干过,结果图片一存成 JPEG,水印直接没了——因为 JPEG 是有损压缩,量化阶段会把低位信息当噪声扔掉。空域水印的另一个问题是鲁棒性差:裁剪、缩放、加噪,任意一个操作都能让嵌入信息失效。这不是实现问题,是原理层面的天花板。
DCT 水印换了个思路:不跟像素较劲,改在频域下手。图像做 8×8 分块 DCT 后,能量集中在左上角低频区,右下角是高频细节。低频区抗压缩(JPEG 量化表对低频保留多),但改动容易被肉眼察觉;高频区隐蔽性好,但压缩时最先被丢弃。所以水印通常嵌在中频区,这是鲁棒性和不可见性的平衡点。
2.2 8×8 分块 DCT 与 ZigZag 排序
JPEG 标准把图像切成 8×8 小块,每块独立做 DCT。变换后得到 64 个系数,按 ZigZag 顺序从低频到高频排列。水印嵌入就是在这个序列上选位置。常见做法是选中频段的若干系数,用它们的相对大小关系编码 0 和 1。
这里有个关键参数:块大小。8×8 是 JPEG 默认,兼容性最好;也有用 16×16 的,嵌入容量大但计算量翻倍,且和 JPEG 量化表对不上,抗压缩反而变差。我一般固定 8×8,除非有特殊容量需求。
2.3 水印嵌入的两种主流策略
一种是加性嵌入:C' = C + α·W,C 是原系数,W 是水印信号,α 是强度。简单直接,但 α 难调——大了可见,小了提取不出来。另一种是关系嵌入:选一对系数 (C1, C2),通过调整它们的大小关系表示比特。比如 C1 > C2 表示 1,C1 < C2 表示 0。提取时只看关系,不看绝对值,抗幅度攻击更强。这份资源用的是关系嵌入,下面代码会体现。
提示:关系嵌入的代价是容量低,一对系数只能藏 1 bit。要藏文字得先转二进制,再按块分配。
3. 动手实现:从读图到嵌入再到提取的完整链路
3.1 环境准备与依赖
代码用 Python 写,依赖就三个:numpy做矩阵运算,opencv-python读写图像,scipy里的dct和idct。不装 scipy 也行,自己写 DCT 矩阵乘,但没必要。安装命令:
pip install numpy opencv-python scipy版本没硬性要求,numpy 1.20 以上、opencv 4.x 都能跑。我测试环境是 Python 3.9 + numpy 1.24 + opencv 4.8,没遇到兼容问题。
3.2 水印预处理:文字转比特流
水印信息不能直接塞,得先转成比特序列。常见做法是文字转 UTF-8 字节,再转二进制,末尾加结束标记。代码:
def text_to_bits(text): """把字符串转成比特列表,末尾加 8 位结束符 0x00""" data = text.encode('utf-8') + b'\x00' # \x00 作为结束标记 bits = [] for byte in data: for i in range(7, -1, -1): # 高位在前 bits.append((byte >> i) & 1) return bits def bits_to_text(bits): """比特列表还原成字符串,遇到 0x00 停止""" bytes_out = bytearray() for i in range(0, len(bits), 8): byte = 0 for j in range(8): byte = (byte << 1) | bits[i + j] if byte == 0: break bytes_out.append(byte) return bytes_out.decode('utf-8', errors='ignore')逻辑说明:text_to_bits把每个字节拆成 8 个比特,高位先出,末尾补一个0x00字节作为结束标志。提取时读到0x00就停,避免把填充的随机比特当有效数据。参数上,结束符选0x00是因为正常文本不会出现空字节,冲突概率极低。
3.3 嵌入流程:分块 DCT + 中频系数对调整
嵌入是核心,分四步:读图转灰度、8×8 分块、每块 DCT、选中频系数对按比特调整。代码:
import cv2 import numpy as np from scipy.fftpack import dct, idct # 中频系数对位置(ZigZag 排序后的索引),选 (4,5) 和 (5,4) 附近 # 这里用二维坐标表示,避免手动 ZigZag COEF_PAIR = [(3, 2), (2, 3)] # (row, col) 中频区 ALPHA = 30 # 强度阈值,控制抗攻击与不可见性的平衡 def embed_watermark(img_path, bits, out_path): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) h, w = img.shape # 裁剪到 8 的倍数,避免边界块不完整 h8, w8 = h // 8 * 8, w // 8 * 8 img = img[:h8, :w8].astype(np.float32) bit_idx = 0 total_bits = len(bits) for i in range(0, h8, 8): for j in range(0, w8, 8): if bit_idx >= total_bits: break block = img[i:i+8, j:j+8] dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho') # 取系数对 (r1, c1), (r2, c2) = COEF_PAIR v1, v2 = dct_block[r1, c1], dct_block[r2, c2] # 关系嵌入:bit=1 让 v1 > v2,bit=0 让 v1 < v2 bit = bits[bit_idx] if bit == 1: if v1 - v2 < ALPHA: avg = (v1 + v2) / 2 v1 = avg + ALPHA / 2 v2 = avg - ALPHA / 2 else: if v2 - v1 < ALPHA: avg = (v1 + v2) / 2 v1 = avg - ALPHA / 2 v2 = avg + ALPHA / 2 dct_block[r1, c1], dct_block[r2, c2] = v1, v2 # 逆 DCT 还原块 block_out = idct(idct(dct_block.T, norm='ortho').T, norm='ortho') img[i:i+8, j:j+8] = block_out bit_idx += 1 if bit_idx >= total_bits: break # 裁剪回原尺寸并保存 result = np.clip(img, 0, 255).astype(np.uint8) cv2.imwrite(out_path, result) return bit_idx # 返回实际嵌入的比特数逻辑说明:dct(dct(block.T).T)是二维 DCT 的标准写法,先对列变换再对行变换,norm='ortho'保证正交归一化,逆变换能精确还原。COEF_PAIR选 (3,2) 和 (2,3),这两个位置在 8×8 块里属于中频,JPEG 量化时保留较多,同时改动对视觉影响小。ALPHA是强度阈值,控制两个系数的差值下限——差值越大,提取越稳,但图像失真越明显。我一般从 20 开始试,30 是经验值,具体看图像纹理复杂度。
参数怎么调:纹理丰富的图(草地、人群)可以加大 ALPHA 到 40,肉眼看不出来;平滑图(天空、皮肤)ALPHA 超过 25 就容易出现块效应。嵌入容量方面,一张 512×512 图有 4096 个块,每块 1 bit,理论上能藏 512 字节,但实际只用前几百个块,因为后面的块可能被裁剪影响。
3.4 提取流程:盲提取与相关性判断
提取不需要原图,属于盲水印。逻辑是重新分块 DCT,看系数对的大小关系还原比特。代码:
def extract_watermark(img_path, bit_length): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) h, w = img.shape h8, w8 = h // 8 * 8, w // 8 * 8 img = img[:h8, :w8].astype(np.float32) bits = [] (r1, c1), (r2, c2) = COEF_PAIR for i in range(0, h8, 8): for j in range(0, w8, 8): if len(bits) >= bit_length: break block = img[i:i+8, j:j+8] dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho') v1, v2 = dct_block[r1, c1], dct_block[r2, c2] bits.append(1 if v1 > v2 else 0) if len(bits) >= bit_length: break return bits_to_text(bits)逻辑说明:提取时不需要原始图像,直接对含水印图做同样的分块 DCT,比较v1和v2的大小。v1 > v2判为 1,否则为 0。bit_length是预估的比特数,一般设大一点(比如 4096),让bits_to_text自己遇到0x00停。这里有个坑:如果图像被压缩过,系数值会漂移,但大小关系通常能保留,这就是关系嵌入的优势。
3.5 完整调用示例
把上面拼起来跑一遍:
if __name__ == '__main__': secret = "Copyright 2024 A-Studio" bits = text_to_bits(secret) print(f"水印比特数: {len(bits)}") embedded = embed_watermark('input.jpg', bits, 'watermarked.jpg') print(f"实际嵌入: {embedded} bits") extracted = extract_watermark('watermarked.jpg', 4096) print(f"提取结果: {extracted}")跑完看输出,如果提取结果和原文一致,说明链路通了。不一致先别急着改代码,往下看排错部分。
4. 参数调优与攻击测试:怎么判断水印扛不扛造
4.1 不可见性评估:PSNR 和 SSIM
嵌完水印得量化失真。PSNR 高于 40dB 基本肉眼无感,35dB 以上可接受。SSIM 更贴近人眼感知,0.98 以上算优秀。计算代码:
from skimage.metrics import peak_signal_noise_ratio, structural_similarity def evaluate(orig_path, wm_path): orig = cv2.imread(orig_path, cv2.IMREAD_GRAYSCALE) wm = cv2.imread(wm_path, cv2.IMREAD_GRAYSCALE) psnr = peak_signal_noise_ratio(orig, wm) ssim = structural_similarity(orig, wm) print(f"PSNR: {psnr:.2f} dB, SSIM: {ssim:.4f}")我实测 ALPHA=30 时,纹理图 PSNR 约 42dB,平滑图约 38dB。如果 PSNR 低于 35,把 ALPHA 降到 20 再试。
4.2 抗攻击测试:JPEG 压缩、加噪、裁剪
水印的价值在抗攻击。常见测试项和预期:
| 攻击类型 | 参数 | 关系嵌入预期 | 加性嵌入预期 |
|---|---|---|---|
| JPEG 压缩 | 质量 70 | 可提取 | 可能失败 |
| 高斯噪声 | σ=5 | 可提取 | 可提取 |
| 裁剪 | 去掉 10% 边缘 | 部分丢失 | 部分丢失 |
| 缩放 | 0.5 倍再放大 | 可能失败 | 失败 |
JPEG 质量 70 是分水岭,低于 60 关系嵌入也开始丢比特。裁剪影响的是被裁区域的块,如果水印只嵌在前半部分块,裁右下角不影响。所以嵌入时最好把比特分散到全图,而不是顺序填。
4.3 ALPHA 与鲁棒性的权衡曲线
ALPHA 不是越大越好。我做过一组测试:ALPHA 从 10 到 50,JPEG 质量 70 下提取正确率从 60% 升到 98%,但 PSNR 从 45dB 降到 33dB。拐点在 ALPHA=30 附近,正确率 95%,PSNR 40dB。所以 30 是个甜点值,除非图像特别平滑或纹理特别复杂,否则不用大改。
注意:ALPHA 是绝对差值阈值,图像 DCT 系数幅值差异大时,固定阈值可能不够。进阶做法是按块能量自适应调整,但代码复杂度上升,新手先用固定值跑通。
5. 避坑与排查:那些让我熬夜的翻车现场
5.1 提取结果乱码,全是问号或空
现象:嵌入成功,提取出来是乱码或空字符串。原因通常是比特对齐错了。text_to_bits高位在前,bits_to_text也得高位在前,两边不一致就全乱。解决:打印前 32 个比特,手动核对第一个字符的 ASCII 码。比如C是 0x43,二进制01000011,看提取的比特是不是这个开头。
5.2 图像出现明显块效应,肉眼可见方格
现象:嵌完水印图上有 8×8 的方格纹。原因是 ALPHA 太大,或者选的低频系数。低频系数改动直接影响块的平均亮度,人眼对块边界敏感。解决:确认COEF_PAIR在中频区(行号+列号在 4 到 10 之间),ALPHA 降到 25 以下。如果还有,检查是不是对同一块重复嵌入多次。
5.3 JPEG 保存后水印丢失
现象:PNG 格式提取正常,转 JPEG 后提取失败。原因是 JPEG 量化把中频系数也压了,如果 ALPHA 不够大,大小关系被翻转。解决:ALPHA 提到 35 以上,或者换更靠低频的系数对(比如 (2,1) 和 (1,2)),但要注意不可见性下降。另一个办法是嵌入时模拟 JPEG 压缩,用压缩后的系数做嵌入,这叫“压缩感知嵌入”,代码复杂但效果好。
5.4 彩色图直接处理导致颜色偏移
现象:对彩色图做 DCT 水印,结果颜色发灰或偏色。原因是直接对 RGB 三个通道分别嵌入,逆变换后通道间相关性被破坏。解决:转 YCbCr 空间,只在 Y(亮度)通道嵌入,CbCr 不动。OpenCV 转换:
img_ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) y_channel = img_ycrcb[:, :, 0] # 对 y_channel 做嵌入,再合并回去 img_ycrcb[:, :, 0] = y_watermarked result = cv2.cvtColor(img_ycrcb, cv2.COLOR_YCrCb2BGR)5.5 提取时比特数设错,多出随机字符
现象:提取文本末尾多出乱码。原因是bit_length设太大,0x00结束符之后还在读。解决:bits_to_text里遇到0x00必须break,不能继续。另外嵌入时确保0x00真的嵌进去了——如果图像块不够,结束符可能没嵌完,提取就找不到终止。嵌入前算一下:len(bits)必须小于总块数,否则截断水印文本。
6. 进阶技巧:把水印藏得更深、提得更稳
跑通基础版后,可以试两个方向。一是自适应 ALPHA:按块方差调整强度,纹理块用大 ALPHA,平滑块用小 ALPHA,兼顾鲁棒性和不可见性。代码片段:
def adaptive_alpha(block, base=30): variance = np.var(block) # 方差大说明纹理丰富,可以加大强度 return base * (1 + min(variance / 1000, 0.5))二是重复嵌入:同一比特嵌到多个块,提取时投票决定。比如每个比特嵌 3 次,提取时取多数。代价是容量降到 1/3,但抗攻击能力显著提升。我一般对关键版权信息用 3 次重复,普通标注用 1 次。
验证水印是否真的稳,别只看 PSNR。我的习惯是:嵌入后先存 JPEG 质量 80、70、60 各一份,再分别提取,三次都成功才算过关。另外裁剪测试要模拟真实场景——从中心裁、从边缘裁、裁不同比例,记录哪些区域的水印先丢。从那以后我每次做完水印,都强制走一遍“压缩+裁剪+加噪”三件套,确认提取率再交付。希望帮到你。
本文还有配套的精品资源,点击获取