简介:这份资源围绕离散余弦变换(DCT)数字水印的嵌入与提取流程展开,面向做数字水印方向毕业设计的学生及图像处理入门者,帮助其理解频域水印的基本原理与实现路径。压缩包共10个文件,以6个bmp图像素材、3个m脚本文件和1个db缓存文件为主,图像素材可用于载体图与水印图的实验对照,脚本文件对应嵌入、提取及PSNR、NC等质量评价环节,整体约521KB,体量轻便、便于直接运行调试。资源已有3840人学习下载,说明其在同类毕业设计选题中具有一定参考价值。内容覆盖图像预处理、分块DCT、系数选择与量化、逆变换还原,以及水印提取与验证的完整链路,并涉及几何攻击、信号处理攻击、压缩攻击和噪声注入等常见攻击场景,读者可据此搭建实验框架、观察不同攻击下的鲁棒性表现,并在此基础上改进算法、补充对比实验,为论文撰写与答辩准备提供可复用的实践素材。
1. 从一张被裁过的图说起:DCT 数字水印到底在解决什么
去年帮一个做毕业设计的同学看代码,他把一张带水印的图发给我,说“提取不出来”。我打开一看,图片被微信压过、被裁掉一圈边、还调过亮度。他用的算法是空域最低有效位,改一个像素就崩。这就是很多人第一次接触数字水印时的翻车现场:嵌入容易,扛住攻击难。DCT 数字水印嵌入与提取这套东西,核心就是把水印从“像素级涂改”搬到“频域系数修改”,让水印藏在图像的中频段里,裁剪、压缩、加噪之后还能捞回来。它适合两类人:一是做数字水印毕业设计、需要一套能跑通嵌入与提取全流程代码的学生;二是想理解离散余弦变换在图像里怎么落地、怎么把一段二值序列塞进频域系数的开发者。下面我按“这套流程怎么跑、代码长什么样、哪里容易踩坑、怎么改成自己的”来讲。
2. 整体链路拆解:一张图从读入到水印落地的完整路径
2.1 项目在做什么:嵌入与提取是两条对称的流水线
这套 DCT 数字水印的骨架非常清晰,本质是两条镜像的流水线。嵌入侧:读原图 → 转灰度 → 分块做二维 DCT → 选中频系数按规则修改 → 逆 DCT 还原 → 拼回整图得到含水印图。提取侧:读含水印图 → 同样分块 DCT → 按同一套规则从中频系数里读出比特 → 拼成水印矩阵 → 还原成可视水印图。两条线共用同一套“分块尺寸、DCT 变换、系数位置、量化步长”参数,参数一旦对不上,提取就是一堆噪声。
我一般把这个项目拆成四个可独立验证的模块:图像预处理、DCT 分块变换、系数嵌入/提取规则、水印序列与图像互转。这样拆的好处是,哪一步出问题可以单独打印中间结果,而不是整条链路一起黑匣子。比如嵌入后 PSNR 很低,先看是不是系数改得太狠;提取全是乱码,先看分块顺序和水印序列长度是否一致。
常见做法是用 8×8 分块,和 JPEG 的块尺寸对齐,这样后续即使被 JPEG 压缩,频域结构也相对稳定。水印通常用二值图像或一串 0/1 序列,长度决定能嵌入多少信息,也决定需要修改多少个块。下面这张表是我整理的关键参数,改代码前先对着看一遍:
| 参数 | 典型取值 | 作用 | 改错的后果 |
|---|---|---|---|
| 分块尺寸 block_size | 8 | 每块做 DCT 的粒度 | 与提取端不一致直接读错 |
| 中频系数位置 | (3,2)/(2,3) 一类 | 水印藏在哪里 | 选低频影响画质,选高频易被压缩抹掉 |
| 量化步长 alpha | 10~30 | 修改强度 | 太小提取不出,太大肉眼可见块效应 |
| 水印长度 | 由水印图尺寸决定 | 需要修改的块数 | 超过可嵌入容量会越界或覆盖 |
| 灰度化方式 | 加权平均 | 统一到单通道 | 彩色图直接处理会通道错位 |
2.2 目录与依赖:一个能跑的最小工程长什么样
这类项目不需要复杂框架,Python 加 OpenCV、NumPy、SciPy 就够。我习惯的目录结构是这样,读者拿到源码后可以对照着找文件:
dct_watermark/ ├── main.py # 入口:嵌入 / 提取二选一 ├── watermark.py # 核心:分块 DCT、系数修改、提取 ├── utils.py # 图像读写、PSNR、水印序列互转 ├── images/ │ ├── original.png # 原始载体图 │ └── logo.png # 待嵌入的二值水印 └── output/ ├── watermarked.png # 嵌入结果 └── extracted.png # 提取结果依赖安装就三行,版本不用卡太死,但 OpenCV 和 NumPy 的数组维度约定要心里有数:
pip install opencv-python numpy scipy matplotlib这里有个容易忽略的点:OpenCV 读进来是 BGR,Matplotlib 显示是 RGB,中间如果混用,彩色图会变色。数字水印项目里我一般第一步就cv2.cvtColor转灰度,后面全程单通道,省掉通道顺序的玄学问题。utils 里我会放两个函数:img_to_bits把水印图二值化拉平成一维 0/1 数组,bits_to_img再按原尺寸还原,这两个函数是嵌入和提取共用的,保证序列顺序绝对一致。
2.3 分块 DCT:为什么是 8×8,以及系数矩阵怎么读
离散余弦变换把一块像素从空间域搬到频率域,左上角是低频(亮度大势),右下角是高频(细节边缘),中频在中间偏左上。水印放低频,画质掉得厉害;放高频,压缩和滤波一上来就没了。所以工程上几乎都选中频,这也是这套项目最值得讲清楚的一步。
核心分块变换代码大概是这样,我加了逐行注释:
import cv2 import numpy as np def block_dct(img, block_size=8): """对灰度图做不重叠分块,每块做二维 DCT,返回系数块列表""" h, w = img.shape blocks = [] coords = [] # 只处理能整除的部分,边缘不足一块的直接丢弃,避免补零引入伪影 for i in range(0, h - block_size + 1, block_size): for j in range(0, w - block_size + 1, block_size): block = np.float32(img[i:i+block_size, j:j+block_size]) # 先减 128 再 DCT,把直流分量压到 0 附近,数值更稳 dct_block = cv2.dct(block - 128.0) blocks.append(dct_block) coords.append((i, j)) return blocks, coords逻辑说明:外层双重循环按block_size步长滑窗,cv2.dct要求输入是 float32,所以先转类型。减 128 是 DCT 的常规预处理,让系数动态范围更集中,逆变换时记得加回来。coords记录每块的左上角坐标,提取时必须按同样顺序遍历,否则块对不上,读出来就是随机比特。参数上,block_size一旦定了,嵌入和提取两端必须完全一致,我一般把它写成模块级常量,不靠函数默认值传递,避免两边各写一个 8 一个 16 的血泪教训。
逆变换就是cv2.idct之后加 128,再 clip 到 0~255 转 uint8。这里要注意 clip 的位置:先加 128 再裁剪,顺序反了会出现大面积死黑或死白。
3. 嵌入与提取核心实现:系数怎么改、比特怎么读回来
3.1 嵌入规则:用系数差值编码一个比特
水印嵌入的本质是“用系数的相对大小表示 0 或 1”。最常用的是比较两个中频系数的绝对值大小:让 A 大于 B 表示 1,B 大于 A 表示 0,中间留一个量化步长 alpha 作为安全间隔。这样即使图像被轻微扰动,大小关系不容易翻转。
def embed_bit(dct_block, bit, alpha=20.0, pos1=(3, 2), pos2=(2, 3)): """在单个 DCT 块的中频系数对里嵌入一个比特""" a = dct_block[pos1] b = dct_block[pos2] if bit == 1: # 保证 a 明显大于 b,差值至少 alpha if a - b < alpha: mid = (a + b) / 2.0 dct_block[pos1] = mid + alpha / 2.0 dct_block[pos2] = mid - alpha / 2.0 else: if b - a < alpha: mid = (a + b) / 2.0 dct_block[pos1] = mid - alpha / 2.0 dct_block[pos2] = mid + alpha / 2.0 return dct_block逻辑说明:pos1、pos2是两个中频坐标,选 (3,2) 和 (2,3) 是因为它们频率适中、能量不低,压缩后相对稳。alpha是强度,20 左右在 8×8 块上肉眼基本看不出,但提取余量够。修改时不是简单加减,而是以两系数均值为中心对称拉开,这样块的整体能量偏移小,画质更稳。参数怎么调:alpha 调大提取更稳但 PSNR 下降,调小画质好但一压缩就丢,我一般从 15 试到 25,看 PSNR 和提取误码率的平衡。
嵌入主流程就是把水印拉平成一维比特,按顺序逐块调用embed_bit,块数不够就报错,别硬塞。全部改完后逐块idct加 128 拼回原图,得到含水印图。
3.2 提取规则:不看原图,只看系数大小关系
提取是嵌入的逆操作,不需要原图,属于盲水印。对每个块取同样的两个坐标,比较绝对值大小,谁大判 1 或 0:
def extract_bit(dct_block, pos1=(3, 2), pos2=(2, 3)): """从系数对的大小关系读出一个比特""" a = abs(dct_block[pos1]) b = abs(dct_block[pos2]) return 1 if a > b else 0逻辑说明:这里用绝对值比较,是因为 DCT 系数有正有负,嵌入时改的是带符号值,但大小关系在取绝对值后依然保持(对称拉开时符号通常不变)。如果嵌入端用了带符号直接比较,提取端也必须带符号,两端规则必须一字不差。提取出的比特按嵌入顺序拼回一维数组,再按水印原尺寸 reshape 成二维,乘 255 转 uint8 就是可视水印。
这里有个细节值得说:提取时块遍历顺序必须和嵌入完全一致,包括行列谁在外层。我见过有人嵌入时for i外层、提取时for j外层,结果水印像被转置了一样,排查半天以为是算法问题,其实是循环顺序。稳妥做法是把遍历逻辑抽成一个iter_blocks生成器,两端共用。
3.3 质量评估:PSNR 和误码率两个数说了算
跑完嵌入不能只看“图还在”,要量化。PSNR 衡量含水印图和原图的差异,一般 35dB 以上肉眼难辨;误码率衡量提取水印和原始水印的比特差异,理想是 0。这两个指标是毕业设计里必写的实验数据,也是调 alpha 的依据。
def psnr(original, watermarked): mse = np.mean((original.astype(np.float64) - watermarked.astype(np.float64)) ** 2) if mse == 0: return float('inf') return 10 * np.log10(255.0 ** 2 / mse) def ber(original_bits, extracted_bits): """误码率:不一致比特占比""" return np.mean(original_bits != extracted_bits)逻辑说明:PSNR 用均方误差算,注意转 float64 防止 uint8 相减溢出回绕,这是新手常翻的车。BER 直接比较两个 0/1 数组,长度必须一致,长度不一致说明水印尺寸或分块容量对不上,先查这个再查算法。实际调参时我会画一条 alpha 从 5 到 40 的曲线,看 PSNR 和 BER 的交叉点,选一个画质可接受、误码率接近 0 的值。
4. 避坑与排查:那些让水印“提取不出来”的真实原因
4.1 现象:提取结果全是噪点,BER 接近 0.5
原因通常是分块参数或遍历顺序两端不一致,也可能是水印比特顺序被打乱。解决:先把嵌入端刚生成的含水印图直接喂给提取端,不经过任何保存压缩,如果这时能提出来,说明算法没问题,问题出在中间环节;如果这时就错,逐块打印系数对,确认pos1、pos2、alpha、block_size四个值两端完全相同。我一般会在两端各打印前 10 个块的系数对,肉眼对一遍。
4.2 现象:保存成 JPG 后提取失败,PNG 却正常
原因是 JPEG 有损压缩会改变 DCT 系数,尤其高频和中频都会被量化。解决:调试阶段一律存 PNG 无损格式;如果项目要求抗 JPEG,就把水印放在更低频一点的位置、加大 alpha,或者做重复嵌入(同一比特嵌多个块,提取时投票)。注意这不是算法 bug,是载体格式的有损特性,别在 PNG 上验证通过就以为 JPG 也行。
4.3 现象:含水印图出现明显 8×8 方块感
原因是 alpha 太大或修改的系数太靠低频,单块能量变化被放大。解决:把 alpha 降到 15 左右,或把嵌入坐标往中高频挪一格;同时检查是否对直流分量动了手,直流系数绝对不能碰,一改整块亮度就跳。我一般会加一条断言,嵌入前确认坐标不是 (0,0)。
4.4 现象:水印图尺寸稍大就报数组越界
原因是可嵌入容量 = 块数 × 每块比特数,块数由载体图尺寸除以 block_size 决定,水印比特数超过容量就塞不下。解决:嵌入前先算capacity = (h//8) * (w//8),和水印比特数比较,不够就缩小水印图或换大载体图,并在代码里显式抛异常提示,而不是让 NumPy 抛一个看不懂的索引错误。
4.5 现象:彩色载体图嵌入后颜色发灰或偏色
原因是只处理了某一个通道,或者 BGR/RGB 顺序搞混。解决:统一转灰度做水印,或者对 YCbCr 的 Y 通道做嵌入再转回,后者画质更好但代码多一层转换。新手建议先灰度跑通全流程,再考虑彩色扩展,别一上来就三通道,出错点直接翻三倍。
5. 二次开发改造:从能跑到能打,我的几条实操习惯
这套代码跑通之后,真正花时间的是改造。第一条改造线是抗攻击能力:现在的单块单比特很脆弱,我一般改成重复嵌入加多数投票,同一个比特嵌到 3 个不同块,提取时三个里取多数,裁剪和噪声下误码率能明显降下来。代码上就是把比特序列按 3 倍展开,提取后按组投票,改动不大但效果立竿见影。
第二条是水印类型升级:从二值图换成灰度水印或字符串编码,字符串要先转比特流再加长度头,提取时先读长度再读内容,避免长度对不上。第三条是嵌入位置自适应:固定 (3,2)/(2,3) 在平滑块上容易看出,可以按块方差选位置,纹理块多改、平滑块少改,这是提升 PSNR 的常用技巧。
验证方法我固定成一套流程:原图嵌入 → 存 PNG 提取(应 BER=0)→ 存 JPG 质量 90 提取 → 中心裁剪 10% 后提取 → 加高斯噪声后提取,四个场景各记一次 BER,做成表格对比不同 alpha。这样改任何参数都有据可查,不会凭感觉说“好像好一点”。
| 改造方向 | 改动点 | 预期收益 | 代价 |
|---|---|---|---|
| 重复嵌入投票 | 比特序列 3 倍展开 | 抗裁剪、抗噪 | 容量降为 1/3 |
| 自适应位置 | 按块方差选系数对 | PSNR 提升 | 提取端需同步规则 |
| 灰度水印 | 多比特/块编码 | 信息量变大 | 实现复杂度上升 |
最后说个我自己的习惯:每次改嵌入规则,我一定先把“嵌入后立刻提取”这条最短路径跑通,BER 必须是 0,再去测压缩和裁剪。因为一旦跳过这步,后面所有失败你都分不清是规则写错还是攻击太狠。从那以后我每次动系数修改逻辑,都强制先走一遍无损回读,再谈鲁棒性。希望帮到你。
本文还有配套的精品资源,点击获取