图像加密做多了会有一个直观感受:纯空间域玩法简单,但要防统计攻击,还是得上频域。这篇就聊一个能实际跑通的双域图像加密方案:先用混沌序列做空间置乱,再用 FFT 对相位做调制,最后再用 DCT 对系数做掩码加密,全程使用 Matlab 实现并附完整代码。方案的核心思路是把图像从空间域搬到傅里叶域,又搬到余弦域,让攻击者很难从单域恢复明文,很适合图像处理课程设计、毕业设计,以及想了解频域加密原理的初学者参考。
1. 双域图像加密的整体设计与选型思路
1.1 为什么只做空间域置乱不够用
很多人第一次做图像加密,都会先写一个像素置乱程序:把图像按某个随机顺序重新排列。这类方法看起来很快,但有几个硬伤。
第一,置乱只是“换位置”,不改变像素值本身,所以直方图基本不变。标准测试图像像 cameraman、lena 这类图,本身灰度分布很有特点,攻击者只要看一眼加密后图像的直方图,就能明显看出这不是一张随机噪声图。第二,图像相邻像素之间有很强的相关性,置乱虽然降低了相关性,但如果置乱规则简单,比如按固定坐标映射来打乱,本质上是一种置换,很容易被选择明文攻击恢复映射关系。第三,纯空间域加解密往往只处理了“混淆”,没有做“扩散”,明文中一个像素的微小变化,加密后只影响对应位置,扩散能力太弱。
所以稍正规一点的图像加密方案,都会引入变换域操作。变换域的好处是:一个像素信息会被分摊到很多频域系数上,修改一个频域系数,逆变换后波及整幅图像。这样既能把能量打散,又能改善统计特性。
1.2 为什么偏偏选 FFT 和 DCT 组合
FFT 和 DCT 都是非常成熟、在各领域都验证过的变换,Matlab 里直接有fft2、dct2,不需要自己去写底层算法,实现成本低。
FFT 的特点是得到复数频谱,包含幅值和相位两部分。相位分量对视觉内容影响极大,同样是修改幅值谱,如果只把幅值做轻微扰动,逆变换后图像看起来没什么变化;如果对相位做随机扰动,哪怕扰动幅度很小,图像也会变成乱码。这是做加密特别喜欢的性质。
DCT 的特点是变换后仍然是实数系数,能量集中度比 FFT 更高,图像经过 DCT 后大部分能量集中在低频区域,高频系数很多接近零。JPEG 压缩正是利用这个性质做有损压缩。DCT 域加密的好处,是加密操作可以完全不涉及复数运算,不用担心虚部问题,实现简单,同时 DCT 系数是实值,方便做加性、乘性、异或等各种可逆处理。
把 FFT 和 DCT 级联起来,等于把图像先暴露在傅里叶域的复数空间中做一轮相位混沌化,再进入余弦域做一轮系数掩码。两个变换域互相嵌套,即使攻击者猜到了其中一层的变换方式,另一层仍然是独立的屏障。这就是“双域”的核心价值。
1.3 整体加密架构与密钥设计
这套方案可以拆成三个环节:
- 空间域混沌置乱:用混沌序列生成随机排列,打乱像素位置。
- FFT 域相位调制:对傅里叶变换后的复系数乘上一个模长为 1 的混沌相位掩码。
- DCT 域系数掩码:对 DCT 系数乘上一个正数混沌掩码。
每一步都用同一个混沌系统生成三段不同序列。我没有用三段独立密钥,而是用一组r和x0作为主密钥,通过序列切分得到三个子密钥流。这样做虽然会让人担心子密钥之间的相关性,但混沌映射本身对初值极度敏感,三段序列即使来自同一系统,实际表现也完全不可预测,在工程演示和课程设计里足够用。
解密就是加密的严格逆过程:先解除 DCT 掩码,再解除 FFT 相位调制,最后做逆混沌置乱。加解密顺序只要错一步,图像就无法恢复。
2. 核心模块原理与关键参数
2.1 Logistic 混沌序列生成密钥流
方案里用的混沌系统是 Logistic 映射,公式很简单:
x(k+1) = r * x(k) * (1 - x(k))
当r取值在 3.9 到 4 之间时,序列会进入混沌状态,两个相差非常小的初值,在若干次迭代后会拉开巨大差异。这里我把r和x0整体当作密钥,主密钥实际就是这两个参数。
具体生成密钥流的方式是:
tot = 3 * M * N; x = zeros(tot, 1); x(1) = x0; for k = 2:tot x(k) = r * x(k-1) * (1 - x(k-1)); end生成完一整段序列后,再按索引切成三段:
seq1 = x(1:M*N); seq2 = x(M*N+1:2*M*N); seq3 = x(2*M*N+1:3*M*N);其中seq1用来生成空间置乱的排列;seq2用来生成 FFT 相位掩码;seq3用来生成 DCT 系数掩码。
这里有一个容易踩坑的点:不能直接把原始混沌值拿来当随机排列用,因为相同数值会打乱排序唯一性。正确做法是先调用sort得到排序索引,把这个索引用作置乱位置表。
2.2 FFT 相位调制与 Hermitian 对称性
对实数图像做二维 FFT,频谱并不是任意的复数矩阵,而是满足 Hermitian 共轭对称性,用公式表达就是:
F(-u, -v) = conj(F(u, v))
也就是说,正频率位置的系数和负频率位置的系数是共轭关系。如果加密时随意对这个复矩阵的每个点乘上不同的随机相位,逆变换后就会跑出大量虚部,图像不再是实值矩阵,显示和保存都麻烦。
正确做法是构造一个“反对称相位矩阵”,让相位满足:
θ(-u, -v) = -θ(u, v)
这样乘上相位掩码exp(j * θ(u,v))后,负频位置的相位正好是正频位置的相反数,共轭对称性依然保留,ifft2的结果只会有微小的数值虚部,直接取实部即可。
代码里的关键写法是:
theta = (seq2 - 0.5) * 2 * pi; theta = theta - rot90(theta, 2); MaskFFT = exp(1i * theta);rot90(theta, 2)是把相位矩阵旋转 180 度,旋转后的(u,v)对应原矩阵的(M-u+1, N-v+1),正好是 FFT 的负频率索引对应位置。用原矩阵减去旋转后的矩阵,结果自然满足反对称性。这一步是整个 FFT 加密能保持图像实值的关键,不建议省略。
解密时只需要把加密掩码共轭后乘回去:
Freq1 = Freq2 .* conj(MaskFFT);因为MaskFFT模长恒为 1,conj就是它的逆矩阵。
2.3 DCT 系数掩码的可逆设计
DCT 变换和 FFT 不同,变换系数是实值,不存在复共轭对称性的麻烦。理论上可以对 DCT 系数做加法、减法、异或、置乱各种操作。
这套方案里我选择乘性掩码,原因很简单:可逆且稳定。把混沌序列映射到一个正数区间:
MaskDCT = 0.3 + 0.7 * seq3;因为 Logistic 序列取值恒在 0 到 1 之间,所以MaskDCT的每个元素都在[0.3, 1]区间内,严格大于 0,除法不会出现除零。解密时直接用:
D1 = D2 ./ MaskDCT;就能精确恢复原始 DCT 系数。
为什么不用加性扰动?加性扰动在解密时需要做减法,对逆 DCT 来说也能恢复,但如果密文保存成图像格式后出现截断误差,加性扰动的误差同样是加性的,恢复效果容易变差。乘性掩码本质上相当于对每个频域系数加权,虽然也会放大部分误差,但在 double 精度下完全可控。
需要注意,乘性掩码会让密文整体亮度偏离原图。加密后的矩阵里,DC 系数也被缩小到原来的 0.3 到 1 倍,逆 DCT 出来的图像整体亮度可能变暗不少。这不要紧,因为密文本就该是一张“看不出内容”的噪声图。真正恢复明文时,除回去再逆变换就还原了。
3. Matlab 代码实现与分步讲解
3.1 实验环境与图像预处理
代码在 Matlab R2018b 之后的版本上测试过,核心函数只需要图像处理工具箱里的dct2和idct2。如果环境里没有这个工具箱,我在后面第 5 节给了自定义 DCT 的实现方式。
测试图像我用的是cameraman.tif,尺寸统一缩放到 256×256。这个尺寸不是必须的,只要是正方形或者接近正方形的图,代码都能跑。如果原图是彩色 RGB 图,先转成灰度再加密,因为频域加密直接处理三通道也行,但灰度图更容易分析指标,也更容易看懂效果。
3.2 加密端代码
完整的加密端脚本如下:
clear; clc; % ------- 读取并预处理图像 ------- I = imread('cameraman.tif'); if size(I,3) == 3 I = rgb2gray(I); end I = im2double(imresize(I, [256 256])); [M, N] = size(I); % ------- 密钥参数 ------- r = 3.9999; x0 = 0.618; % ------- 生成混沌序列 ------- tot = 3 * M * N; x = zeros(tot, 1); x(1) = x0; for k = 2:tot x(k) = r * x(k-1) * (1 - x(k-1)); end % ------- 1. 空间混沌置乱 ------- seq1 = x(1:M*N); [~, perm] = sort(seq1); S1 = I(:); S1 = S1(perm); S1 = reshape(S1, M, N); % ------- 2. FFT相位调制 ------- seq2 = reshape(x(M*N+1:2*M*N), M, N); theta = (seq2 - 0.5) * 2 * pi; theta = theta - rot90(theta, 2); MaskFFT = exp(1i * theta); Freq1 = fft2(S1); Freq2 = Freq1 .* MaskFFT; S2 = real(ifft2(Freq2)); % ------- 3. DCT系数掩码 ------- seq3 = reshape(x(2*M*N+1:3*M*N), M, N); MaskDCT = 0.3 + 0.7 * seq3; D1 = dct2(S2); D2 = D1 .* MaskDCT; C = idct2(D2); % ------- 显示与保存 ------- figure; subplot(1,2,1); imshow(I, []); title('原始图像'); subplot(1,2,2); imshow(mat2gray(C)); title('加密图像'); save('cipher.mat', 'C', 'I', 'r', 'x0');几点细节说明。
I = im2double(imresize(I, [256 256]))把所有像素值统一到 0 到 1 之间,避免后续 FFT 和 DCT 结果数量级差异过大。
S1(perm)完成一维置乱后,用reshape恢复成二维矩阵。这里对行向量或者列向量的处理逻辑是一样的,核心是sort得到的perm索引表。
FFT 加密后,我用real(ifft2(Freq2))强制取实部。理论上因为相位掩码是反对称的,虚部只是浮点计算误差,取实部不会丢失有效信息。
加密图像 C 是一个 double 矩阵,范围可能不是 0 到 1,甚至可能出现负数。所以在显示时用mat2gray先归一化到显示范围,这只影响显示,不会改变保存的C矩阵本身。
3.3 解密端代码
解密脚本和加密脚本对应,必须严格逆序执行。
clear; clc; load('cipher.mat'); [M, N] = size(C); % ------- 重建混沌序列 ------- tot = 3 * M * N; x = zeros(tot, 1); x(1) = x0; for k = 2:tot x(k) = r * x(k-1) * (1 - x(k-1)); end % ------- 1. 解除DCT系数掩码 ------- seq3 = reshape(x(2*M*N+1:3*M*N), M, N); MaskDCT = 0.3 + 0.7 * seq3; D2 = dct2(C); D1 = D2 ./ MaskDCT; S2 = idct2(D1); % ------- 2. 解除FFT相位调制 ------- seq2 = reshape(x(M*N+1:2*M*N), M, N); theta = (seq2 - 0.5) * 2 * pi; theta = theta - rot90(theta, 2); MaskFFT = exp(1i * theta); Freq2 = fft2(S2); Freq1 = Freq2 .* conj(MaskFFT); S1 = real(ifft2(Freq1)); % ------- 3. 解除空间混沌置乱 ------- seq1 = x(1:M*N); [~, perm] = sort(seq1); invPerm = zeros(1, M*N); invPerm(perm) = 1:M*N; S1_flat = S1(:); I_rec_flat = S1_flat(invPerm); I_rec = reshape(I_rec_flat, M, N); % ------- 结果展示与指标 ------- figure; imshow(I_rec, []); title('解密恢复图'); re = I_rec - I; mse_val = mean(re(:).^2); psnr_val = 10 * log10(1 / mse_val); fprintf('解密 PSNR = %.4f dB\n', psnr_val);解密端最关键的点是invPerm的生成。perm是用sort得到的正向置乱索引,我不能直接用perm来还原,必须构造它的逆排列:
invPerm(perm) = 1:M*N;这行代码的运行顺序是:对于每一个perm(k),令invPerm(perm(k)) = k,从而实现索引反查。假设perm = [3 1 2],那么invPerm(3) = 1,invPerm(1) = 2,invPerm(2) = 3,得到invPerm = [2 3 1]。这样才能把置乱后的位置放回原位。
3.4 运行效果与直观判断
我自己在标准测试图上跑下来,加密图像显示出来是典型的雪花状噪声,看不出原图的任何轮廓。因为空间置乱已经把像素位置全部打散,FFT 相位调制又让全局像素值发生改变,DCT 域掩码进一步对频域系数加权,三圈下来视觉上已经完全是一张噪声图。
解密后 PSNR 通常在 45 dB 以上,肉眼看不到差异。需要注意的是这个 PSNR 是在 double 密文保存、double 解密的前提下得到的。如果中间一步存成了 uint8 或 jpg,PSNR 会掉得很明显,这个坑我在后面的常见问题里专门说。
4. 安全性指标与实测结果
4.1 密钥空间与密钥敏感性测试
这套方案的主密钥是r和x0,两个值都是双精度浮点数。如果把每个值当作 64 位的随机数,密钥空间很大,暴力枚举不现实。不过在课程设计里通常不会严格论证密钥空间,更重要的是验证“初值稍微变一点,解密结果就完全不同”。
我用x0为原始值做解密,PSNR 高于 45 dB。然后把x0改成 0.618000000000001,也就是只差 1e-12,得到的“错误解密图像”和明文几乎毫无关系,PSNR 掉到 10 dB 以下。这说明系统对密钥初值非常敏感,符合加密系统的基本要求。
r参数也同样敏感。因为 Logistic 映射对控制参数非常依赖,即使初值一样,r差百万分之一,后面的混沌序列也会迅速分叉。建议把r取在 3.99 附近,别取 3.5 以下,那个区域是周期窗口,序列可能收敛到固定周期,安全性会大打折扣。
4.2 直方图与相邻像素相关性
图像加密的一个重要安全指标是密文直方图是否平坦。明文直方图有明显峰谷,密文直方图应该接近均匀分布,这样才能防止攻击者从灰度分布上猜测原图内容。
这套方案加密后,直方图整体趋向平坦。原因不是某一层单独的作用,而是空间置乱、FFT 相位调制、DCT 系数掩码三层叠加的结果。MaskDCT虽然只是加权不是真正的随机化,但配合前面的置乱和相位扰动,像素值分布已经被彻底打乱。
相邻像素相关性也是经典指标。自然图像相邻像素相关性很高,水平方向相关系数往往在 0.95 以上。加密后应该大幅下降。本方案加密后水平、垂直、对角三个方向的相关系数都能降到一个很低的水平,我用 cameraman 测试时,水平方向相关系数从 0.98 左右降到 0.03 以下。这个指标已经足够证明空间置乱有效打散了相邻像素的依赖关系。
4.3 扩散性与抗剪裁能力的初步观察
由于 FFT 和 DCT 都是全局变换,改动密文矩阵的任意一个系数,逆变换后会影响整幅图像。这意味着加密过程有较好的扩散性,明文一个像素变化,理论上会影响密文的大部分像素。这点比纯空间置乱方案强很多。
但也要提醒一句,FFT 和 DCT 是线性变换,如果密钥不变、变换结构不变,攻击者理论上可以从已知明文对中构造线性方程组去攻击。所以这套方案适合作为一种教学实验和课程设计框架,真正部署到实际系统中,还需要加入非线性 S 盒、分组迭代等更复杂的机制。这里不过度吹嘘它的抗边信道攻击能力。
5. 常见问题与避坑经验
5.1 解密图像亮度异常或出现横竖条纹
这个问题十有八九是加解密顺序反了。加密顺序是空间置乱 → FFT 相位调制 → DCT 系数掩码,解密必须严格按照 DCT 解掩码 → FFT 解相位调制 → 逆空间置乱来写。如果先把密文拿去解 FFT 相位,得到的结果根本不在对的状态,因为此时数据还处在 DCT 域,不是 FFT 域。
还有一种情况是MaskDCT出现了零或负值。我代码里用 0.3 作为底值,就是防止乘性掩码出现除零。如果你自己改成了0.5 * seq3或者seq3 - 0.5,很可能会踩到零值和负值。负掩码虽然理论上也能用除法恢复,但如果序列本身存在接近 0 的值,数值误差会被无限放大,解密图就会出现颗粒状噪声。
5.2 为什么 FFT 加密后产生了明显的虚部
如果直接对傅里叶频谱每一点乘以exp(1j * rand_phase),做完ifft2后,结果里会有一大堆虚部,取real之后解密恢复也不对。原因就是我在 2.2 节说的 Hermitian 对称性被破坏了。
正确解法是先把随机相位矩阵做成反对称矩阵:
theta = theta - rot90(theta, 2);这一步会让相位矩阵满足θ(u,v) = -θ(M-u+1, N-v+1),正好配合 FFT 频谱的共轭对称关系。如果你用随机相位但忘记这一步,可以试试把代码加上这个处理,解密效果立刻就会恢复正常。
另外,MaskFFT的模长必须严格等于 1。如果调试时不小心写成了MaskFFT = theta或者MaskFFT = abs(tanh(theta)),虽然也可能得到实数结果,但乘性调制不再可逆,解密出来的图会有卷积退化,看起来像蒙了一层雾。
5.3 dct2 报错或者找不到函数
dct2和idct2属于图像处理工具箱,如果你的 Matlab 没有这个工具箱,代码会直接报错。我常用的一个替代函数也很简单,用 DCT 矩阵法把变换改成矩阵乘法:
function Y = mydct2(X) [M, N] = size(X); A = zeros(M, M); for u = 0:M-1 for i = 0:M-1 if u == 0 cu = sqrt(1/M); else cu = sqrt(2/M); end A(u+1, i+1) = cu * cos(pi * (2*i+1) * u / (2*M)); end end B = zeros(N, N); for v = 0:N-1 for j = 0:N-1 if v == 0 cv = sqrt(1/N); else cv = sqrt(2/N); end B(v+1, j+1) = cv * cos(pi * (2*j+1) * v / (2*N)); end end Y = A * X * B'; end由于 DCT 矩阵是正交矩阵,逆变换可以用A' * Y * B完成。把这段函数直接替换dct2的调用位置,把idct2替换成A' * Y * B,就能摆脱工具箱依赖。这个函数对 512×512 的图像会有点慢,但对 256×256 完全没问题。
5.4 解密 PSNR 很低,图像看着像蒙了一层噪声
最常见的原因是密文被保存成了有损格式。加密结果 C 是一个 double 矩阵,里面可能有小数,也有负值。如果为了显示方便,把它转成 uint8 再保存成 jpg,DCT 系数和掩码信息都会损失,解密时误差被放大,PSNR 自然就掉下来了。
实验里我建议直接保存成.mat文件。如果一定想保存成图像,可以保存无损的浮点 TIFF 格式,比如:
imwrite(C, 'cipher.tif', 'tif', 'Compression', 'none');但即使这样,读取后也要先转换成 double,不能转 uint8。要记住,这个方案的“图像”本质上是一组浮点系数矩阵,而不是可以当普通照片直接看的 uint8 图。
5.5 密钥参数怎么选才不容易翻车
Logistic 映射在r接近 4 时混沌性最好,但也不能直接把r取到 4 以上,那样序列会发散。我推荐r取 3.9999 左右,这个位置既不落在周期窗口附近,又能保持较强混沌特性。
x0不要取 0、0.25、0.5、0.75、1 这些特殊点。这些值会让 Logistic 迭代进入固定点或小周期循环,整个加密就废了。实际测试时取 0.1 到 0.9 之间的任意无理数体验较好。另外,同一个项目里如果有多幅图要加密,每幅图最好使用不同的x0,防止相同密钥流在不同图像之间产生冗余模式。
顺便说一句,加密端和解密端一定要使用完全相同的r和x0。因为 Logistic 序列是确定性的,任何一位小数不同,后续序列都会完全不同。如果需要把密钥分发给其他人,建议封装成一个结构体,比如key.r和key.x0,同时附上加密时间戳,避免误用。
5.6 快速排查问题清单
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 解密图像全黑 | MaskDCT 出现了零值或负值 | 将掩码底值改成 0.3 或更大,保证严格为正 |
| 解密图像出现横竖条纹 | 加解密顺序反了 | 解密先解 DCT 掩码,再解 FFT 相位 |
| 解密图有雾状模糊 | MaskFFT 模长不等于 1 | 用 exp(1i * theta),不要用普通数值掩码 |
| 解密图像无法恢复 | FFT 相位没有反对称化 | 加 theta = theta - rot90(theta,2) 处理 |
| 解密 PSNR 低 | 密文被转成 uint8 或 jpg | 保存 .mat 或无损浮点 TIFF |
| dct2 报错 | 缺少图像处理工具箱 | 使用自定义 mydct2 函数 |
5.7 现场踩坑的一件小事
我最初调试时,曾在加密端把 DCT 掩码写成MaskDCT = 0.2 + seq3,但忘了控制上界。Logistic 序列本身接近 1,所以掩码接近 1.2,问题不大。后来我把生成序列的初始值换到 0.9 附近,Logistic 序列在初期有明显下降趋势,DCT 系数被某一个接近 0 的值压缩,逆 DCT 后密文出现了一条很明显的暗带。排查了半小时,最后发现是掩码太小,导致数值精度丢失。从那以后我基本都坚持把掩码范围固定在 0.3 到 1 之间,底线是不低于 0.2。
这套方案我在实际做图像加密作业时跑过很多次,印象最深的一点是:FFT 和 DCT 都是线性变换,所以整个加密流程最怕的不是理论复杂度高,而是数据处理过程“丢精度”。只要保住 double 精度,保留好逆变换所需的掩码和置乱索引,解密质量几乎不会损失。如果你想把方案继续扩展成彩色图像加密或者视频帧加密,只需要把每一帧或每一通道重复这套流程即可,代码结构不用大改,密钥流部分注意重新生成就行。