图像加密这个方向,大家见得最多的是基于混沌系统的方案,比如 Logistic、Tent、Arnold 置乱等。这次我拆一个不太常见但思路很干净的实现:用 Halton 序列同时完成位置扰乱和像素扰乱,并且整套逻辑用 Matlab 落地。这个方案适合图像处理课程设计、信息安全方向的小型项目,也适合想玩转“低差异序列 + 置乱扩散框架”的开发者。文章里我会把原理、代码、解密逆操作、常见报错坑一次性讲清楚,你甚至可以直接把它改成自己的加密函数库。
1. 整体设计思路:为什么选择 Halton 序列
1.1 低差异序列带来的均匀覆盖优势
Halton 序列属于低差异序列,简单理解就是“既不随机,又看起来随机”的确定性序列。和纯随机数序列相比,它最大的特点是均匀覆盖:生成的坐标点不会出现大片空白区域,也不会扎堆成团,而是近似均匀地铺满整个空间。
这个特性对图像加密非常重要。加密过程中如果用到采样点来做置换映射或密钥流生成,均匀性直接决定了加密图像的扩散效果。比如用随机数生成器做置乱索引,理论上总有一些区域会聚集较多像素,导致密文图像局部还残留原始图像的纹理痕迹;Halton 序列则能有效避免这种“采样偏置”。
另外,Halton 序列是纯确定性的,生成逻辑不依赖系统时间或硬件熵源。这意味着只要密钥一致,任何人在任何机器上都能复现完全相同的加密序列,解密端不需要传输庞大的序列文件,只需要同步密钥参数即可。相比之下,很多基于 Logistic 混沌的方案需要额外处理浮点迭代误差,对初始条件的位宽要求很高,Halton 序列则简单很多。
1.2 置乱与扩散的双阶段模型
这套加密框架采用了经典的【位置扰乱 + 像素扰乱】双阶段结构,也叫置乱-扩散架构。
位置扰乱解决的是空间相关性问题。图像中相邻像素的灰度值通常高度相关,如果直接把像素按行读取然后加密,统计特征仍然容易被破解。置乱阶段通过重新排列像素位置,把相邻像素打散到图像的各个位置,相当于把图像“搅浑”。Halton 序列在这里的作用是生成行置换索引和列置换索引,经过排序后得到一组确定性排列,按这个排列重新组织图像矩阵。
像素扰乱解决的是统计特征问题。即使做了置乱,图像直方图仍然保留原始数据的分布规律(只是位置变了)。扩散阶段通过密钥流对像素值本身进行改变,让密文图像的直方图趋近均匀分布,从而掩盖明文图像的灰度分布信息。Halton 序列在这里生成一串 0~255 的密钥流,与置乱后的像素按位异或,完成像素级别的扰动。
这里有一个值得注意的设计思路:置乱和扩散必须是互补的,不能互相替代。如果只做位置扰乱,直方图完全不改变,攻击者可以直接统计密文来推算明文;如果只做像素扰乱,相邻像素的相关性依然很高,空间结构没有破坏。只有两阶段叠加,才能同时破坏“空间相关性”和“灰度分布特征”,这也是这套方案安全性的根基。
2. Halton 序列的原理与 Matlab 实现
2.1 Van der Corput 序列的基础概念
Halton 序列是多维低差异序列,它的基础是一维 Van der Corput 序列。构成逻辑很简单:把一个正整数 n 写成基数 b 下的进制表示,然后把各个位上的数字反序,放到小数点后面,得到一个 0 到 1 之间的数。
以基数 b=2 为例,数字的二进制表示和对应的 Van der Corput 值如下:
- n=1,二进制是 1,反序后是 0.1,即 0.5
- n=2,二进制是 10,反序后是 0.01,即 0.25
- n=3,二进制是 11,反序后是 0.11,即 0.75
- n=4,二进制是 100,反序后是 0.001,即 0.125
可以发现,同样长度的区间内,Van der Corput 序列点分布得非常均匀。Halton 序列就是把多个不同基数的 Van der Corput 序列组合起来,构成多维坐标。常用组合是二维的 (基数2, 基数3),也可以扩展到更高维,只要各维基数互不相同即可。
需要特别说明的是,Halton 序列在高维场景下会出现周期退化问题,但二维的应用场景完全不用担心,基数 2 和基数 3 的组合在图像尺寸范围内表现稳定。
2.2 二维 Halton 序列的生成代码
Matlab 里没有直接提供 Halton 序列的现成函数,但实现并不复杂,核心就是写一个进制反转函数。
function phi = vdc(n, b) % 生成第1~n个Van der Corput序列值 % b: 基数(素数), n: 序列长度 phi = zeros(n, 1); for i = 1:n idx = i; f = 1 / b; while idx > 0 phi(i) = phi(i) + f * mod(idx, b); idx = floor(idx / b); f = f / b; end end end在此基础上,再加一个“跳跃”能力:从某个偏移量之后开始取序列,这在密钥设计中非常有用,可以让不同的密钥对应不同的序列段。
function phi = vdc_skip(n, b, skip) % 从skip+1开始,取n个Van der Corput值 phi = zeros(n, 1); for i = 1:n idx = skip + i; f = 1 / b; while idx > 0 phi(i) = phi(i) + f * mod(idx, b); idx = floor(idx / b); f = f / b; end end end二维 Halton 坐标就更加简单了,把两个基数生成的序列并成一列即可:
function H = halton2d(n, b1, b2, skip1, skip2) H = zeros(n, 2); H(:, 1) = vdc_skip(n, b1, skip1); H(:, 2) = vdc_skip(n, b2, skip2); end实测下来,这段代码在 512×512 的图像尺寸下完全够用,生成 26 万个序列点的耗时在几秒钟量级。如果你需要处理更大尺寸的图像,可以考虑把 while 循环改成预计算表,或直接用函数句柄提升效率,这部分我在第 5 节再展开。
2.3 参数选择与密钥设计
Halton 序列本身是公开算法,但它的初始参数可以作为加密密钥。常规做法是把这几个参数组成密钥向量:
| 参数 | 含义 | 推荐取值范围 |
|---|---|---|
| b1 | 行置乱基数 | 2, 3, 5 等素数 |
| b2 | 列置乱基数 | 与 b1 不同的素数 |
| b3 | 像素密钥流基数 | 7, 11, 13 等素数 |
| skip1 | 行序列起始偏移 | 100~10000 之间随机 |
| skip2 | 列序列起始偏移 | 100~10000 之间随机 |
| skip3 | 密钥流序列起始偏移 | 100~10000 之间随机 |
我实践中的密钥格式固定为key = [b1, b2, b3, skip1, skip2, skip3],例如key = [2, 3, 5, 137, 421, 919]。
这里要注意一个细节:b1 和 b2 不能相等,否则行列使用完全相同的序列,置乱效果会退化成一维置换,安全性大打折扣。从安全角度看,跳数 skip 的作用相当于把序列从某个随机起点“剪”掉一段,即使攻击者猜到了基数,还要同时猜对起始偏移才能复现序列,这能有效扩大密钥空间。
3. 位置扰乱与像素扰乱的具体实现
3.1 用排序索引构造像素置换
置乱的核心思路是:用 Halton 序列生成一组无序参考值,然后通过排序操作产生一个置换索引。
比如图像有 M 行,就先生成 M 个一维 Halton 值,再对这 M 个值排序。排序后的索引perm_r就是行置换向量:新图像的第 i 行取原图像的第perm_r(i)行。对列方向同理。
phi_r = vdc_skip(M, b1, skip1); phi_c = vdc_skip(N, b2, skip2); [~, perm_r] = sort(phi_r); [~, perm_c] = sort(phi_c); img_scrambled = img(perm_r, :); img_scrambled = img_scrambled(:, perm_c);这一步用排序而不是直接对 Halton 值取整,是因为排序得到的置换天然是“一对一”的,不会出现多个像素挤在同一个位置、某些位置却没有来源像素的情况。直接取整会造成冲突,严重破坏可逆性,我在早期实验里踩过这个坑,后面细说。
解密时则反向操作:从perm_r求逆置换inv_r,让img_restored = img_scrambled(inv_r, :)。
[~, inv_r] = sort(perm_r); [~, inv_c] = sort(perm_c);这里需要想明白一点:加密时用的是行置乱再列置乱,解密时的逆操作顺序是“先反列、再反行”,因为列置乱是在行置乱之后施加的,恢复时要从最后一步往前撤。第 4 节完整代码里我会严格按这个顺序写。
3.2 像素扰乱:异或密钥流的构造
像素置乱完成后,图像像素值本身没有改变,直方图依然保留明文特征,因此必须做像素级扩散。最简单可靠的方案是构造一串与图像尺寸相同的 0~255 整数密钥流,与像素值逐位异或。
密钥流的构造方式:
phi_k = vdc_skip(M * N, b3, skip3); key_stream = mod(floor(phi_k * 256), 256); key_stream = uint8(reshape(key_stream, M, N)); enc = bitxor(img_scrambled, key_stream);为什么用异或而不是加法?有两个原因。一是异或是自逆运算,解密端只需要再做一次同样的异或就能恢复明文,不需要额外处理模数回绕问题;二是按位异或对像素的每个二进制位独立作用,扩散效果更彻底,比如明文中一个像素从 127 变到 128,映射到密文上可能多位同时翻转。
我建议把mod(floor(...))两层嵌套保留:floor(phi_k * 256)把 0~1 之间的值放大到 0~255 的整数范围,mod(..., 256)则保证当浮点数值在边界上时不会越界成 256。如果你直接uint8(...)截断,Matlab 默认四舍五入可能在边界处产生不可预估的结果,这是解密还原时最容易出现“个别像素偏色”的根源。
3.3 解密端的逆操作顺序
解密是加密的镜像操作,但很多人第一次实现时会把顺序搞反。加密流程是:位置扰乱 → 像素扰乱,那么解密必须是:像素反扰乱 → 位置反扰乱。
理由其实很直观:加密时,位置扰乱在像素扰乱之前;解密时要从最后一个操作开始撤销,所以先撤销像素扰乱(对密文再次异或同一密钥流),再撤销位置扰乱(先反列再反行)。
这里有一个容易混淆的点:异或不改变像素的位置信息,置乱不改变像素的值信息,两阶段互相独立。所以“先反哪一个”貌似都应该能成立?理论上如果有完整的逆置乱和逆扩散,顺序确实可以交换,但在密钥流依赖图像尺寸、置乱索引依赖图像尺寸的前提下,保持严格逆序最不容易出错,也更符合加密协议设计的规范。我建议直接固定这个顺序,避免以后扩展多轮加密时逻辑混乱。
4. 完整加密/解密流程与代码
4.1 加密主函数
下面是完整封装好的加密函数。输入为灰度图像矩阵和六维密钥向量,输出为加密后的 uint8 图像矩阵。
function enc = halton_encrypt(img, key) % 输入: img - 灰度图像(uint8或double), key - [b1,b2,b3,skip1,skip2,skip3] % 输出: enc - 加密图像(uint8) if isa(img, 'double') img = uint8(round(img * 255)); elseif isa(img, 'uint16') img = uint8(img / 257); end [M, N] = size(img); b1 = key(1); b2 = key(2); b3 = key(3); skip1 = key(4); skip2 = key(5); skip3 = key(6); % 位置扰乱 phi_r = vdc_skip(M, b1, skip1); phi_c = vdc_skip(N, b2, skip2); [~, perm_r] = sort(phi_r); [~, perm_c] = sort(phi_c); img_scrambled = img(perm_r, :); img_scrambled = img_scrambled(:, perm_c); % 像素扰乱 phi_k = vdc_skip(M * N, b3, skip3); key_stream = mod(floor(phi_k * 256), 256); key_stream = uint8(reshape(key_stream, M, N)); enc = bitxor(img_scrambled, key_stream); end这个函数里的vdc_skip就是第 2 节给出的函数。你需要把它单独保存为vdc_skip.m,或者直接写在这个脚本文件的下方。Matlab 在同一个脚本文件里可以包含多个函数,但要放在主函数之后。
4.2 解密主函数
解密函数的核心是:重新生成同样的密钥流和置乱索引,然后按逆序操作。
function dec = halton_decrypt(enc, key) % 输入: enc - 加密图像(uint8), key - 与加密时相同的密钥 % 输出: dec - 解密还原图像(uint8) if isa(enc, 'double') enc = uint8(round(enc * 255)); end [M, N] = size(enc); b1 = key(1); b2 = key(2); b3 = key(3); skip1 = key(4); skip2 = key(5); skip3 = key(6); % 重新生成置乱索引并求逆置换 phi_r = vdc_skip(M, b1, skip1); phi_c = vdc_skip(N, b2, skip2); [~, perm_r] = sort(phi_r); [~, perm_c] = sort(phi_c); [~, inv_r] = sort(perm_r); [~, inv_c] = sort(perm_c); % 先撤销像素扰乱 phi_k = vdc_skip(M * N, b3, skip3); key_stream = mod(floor(phi_k * 256), 256); key_stream = uint8(reshape(key_stream, M, N)); img_scrambled = bitxor(enc, key_stream); % 再撤销位置扰乱:先反列置乱,再反行置乱 dec = img_scrambled(:, inv_c); dec = dec(inv_r, :); end核心就两步:bitxor(enc, key_stream)把像素恢复成“仅置乱但未扩散”的状态,再用逆置换inv_c和inv_r把像素位置归位。
4.3 单次完整的可视化测试
把整个流程跑起来非常简单,用 Matlab 自带的灰度测试图即可:
% 读取测试灰度图 img = imread('cameraman.tif'); if size(img, 3) == 3 img = rgb2gray(img); end % 设置密钥 key = [2, 3, 5, 137, 421, 919]; % 加密与解密 enc = halton_encrypt(img, key); dec = halton_decrypt(enc, key); % 可视化 figure; subplot(1, 3, 1); imshow(img); title('原始图像'); subplot(1, 3, 2); imshow(enc); title('加密图像'); subplot(1, 3, 3); imshow(dec); title('解密图像'); % 验证一致性 fprintf('像素差异总数: %d\n', sum(abs(double(img(:)) - double(dec(:))) > 0));正常输出应该是“像素差异总数: 0”,也就是解密图像与原图逐像素完全一致。这里强调一点:如果解密后图像肉眼看着没问题、细看却有零星噪点,几乎一定是uint8类型转换问题,下一节专门梳理这类坑。
5. 常见问题与排查技巧实录
5.1 解密回来图像不对,像“打码雪花片”
这是初学者最常遇到的问题,加密图像显示正常,但解密图像完全看不出原图轮廓。排查顺序我建议这样看:
第一,检查加密和解密是否使用了同一个密钥向量。注意key传给加密函数后,在解密时必须原样传入,任何一个skip值错一位,整个序列就会错位,图像自然无法还原。
第二,检查图像类型是否统一。bitxor对uint8和double的处理逻辑完全不同:bitxor(uint8(...), uint8(...))是按位异或,而double类型会被当作浮点数处理,结果完全不可控。我在加密函数里已经做了类型收敛,但如果你在外部提前把加密结果转成了double,解密时就必须先转回uint8。
第三,检查置乱和解密的逆置换方向。逆置换并不是把perm_r直接当作反向索引使用,而是要先求逆。比如某一行在原图中是第 5 行,加密后跑到第 2 行,那么解密时必须找到“第 2 行现在属于原图的第 5 行”这个映射关系。直接复用perm_r会导致行位置来回跳,图像变成网格状乱码。
5.2 彩色图像和多通道处理
标题里的方案核心是灰度图像处理,但真正的图像不一定是灰度图。彩色图像可以拆成 R、G、B 三个通道分别加密,再把三个通道重新拼合成 RGB 图像。
function enc_rgb = halton_encrypt_rgb(img, key) [M, N, ~] = size(img); enc_rgb = zeros(M, N, 3, 'uint8'); for ch = 1:3 enc_rgb(:, :, ch) = halton_encrypt(img(:, :, ch), key); end end这里有个细节值得留意:三个通道用同一个key加密,会保留通道间的相关性,严格来说安全性略有折扣。更保守的改进方式是给每个通道分配不同的skip3偏移,比如通道 R 用key中的skip3,通道 G 用skip3 + 500,通道 B 用skip3 + 1000。这样处理后三个通道的密钥流互不相同,能明显提升彩色图像的扩散效果。
5.3 大图像速度太慢,如何优化
vdc_skip本质上是一个逐点循环的进制反转过程。对一张 1024×1024 的图像,生成 104 万个序列点,在普通配置的电脑上可能要十几秒,这个速度在批量测试时会有些难受。
优化方向有两个。一是把 while 循环改成查表法:预先计算 “数字 → Van der Corput 值” 的映射表,后续直接索引取值。二是改用生成器循环替代向量内逐点循环,用 Matlab 的arrayfun或前面的预计算思路减少解释器开销。
我实测过的改进思路是:
function phi = vdc_fast(n, b, skip) idx = (1:n) + skip; phi = zeros(n, 1); % 预分配最大位数, 逐位累加 for k = 1:ceil(log(n + skip) / log(b)) bit_digits = mod(floor(idx / b^(k-1)), b); phi = phi + bit_digits / b^k; end end这个版本把“逐点进制反转”改成了“逐位批处理”,代码可读性稍差,但速度能提升一个数量级。如果你只处理 256×256 或 512×512 的图像,用最初的vdc_skip完全够用,不需要额外优化。
5.4 逆排列写法的常见错乱
逆排列是解密里最容易写错的一环。我见过不少同学把解密写成dec = img_scrambled(perm_r, :),这实际上会做第二次置乱,而不是还原。
求逆排列的正确写法是[~, inv_r] = sort(perm_r)。原理是:对任意排列 p,p经过sort后,第二个输出参数记录的是“各元素原来的位置”,这正好就是逆映射。以行置乱为例,如果perm_r = [3, 1, 2],表示加密时第 1 行取原图第 3 行,第 2 行取原图第 1 行,第 3 行取原图第 2 行;sort(perm_r)后得到inv_r = [2, 3, 1],解密时用inv_r依次取第 2 行、第 3 行、第 1 行,顺序就回到原图。
| 位置 | 加密时取原图第几行 | 解密时取密文第几行 |
|---|---|---|
| 1 | 3 | 2 |
| 2 | 1 | 3 |
| 3 | 2 | 1 |
6. 安全性评估与后续改进方向
6.1 直方图和相邻像素相关性验证
一个加密方案合不合格,先看两个量化指标:密文直方图是否均匀,相邻像素相关性是否被打破。
直方图测试代码很简单,读入明文和密文后各画一张直方图,肉眼对比即可。明文图像的直方图通常有明显起伏,密文图像的直方图应该趋于平坦,近似均匀分布。
相邻像素相关性可以按方向计算。以水平方向为例,从图像中随机取 N 对左右相邻像素,计算相关系数:
function corr = h_correlation(img, num_pairs) [M, N] = size(img); rng(42); row_idx = randi([1, M], num_pairs, 1); col_idx = randi([1, N-1], num_pairs, 1); p1 = double(img(sub2ind([M, N], row_idx, col_idx))); p2 = double(img(sub2ind([M, N], row_idx, col_idx + 1))); corr = corrcoef(p1, p2); corr = corr(1, 2); end明文图像的水平相邻相关性通常接近 0.9 以上,加密后应该掉到 0.05 以下。如果相关性没有明显下降,建议加密时增加一轮置乱和扩散,把整个流程迭代 2~3 次。
6.2 密钥敏感性与密钥空间
密钥敏感性是指:密钥只有一位微调,解密结果应该完全不同。用实际操作验证的方式非常直观:
key1 = [2, 3, 5, 137, 421, 919]; key2 = [2, 3, 5, 138, 421, 919]; % 只把skip1从137改成138 enc1 = halton_encrypt(img, key1); dec_wrong = halton_decrypt(enc1, key2); % 计算与原图的差异比例 diff_ratio = sum(abs(double(dec_wrong(:))) - double(img(:)) ~= 0) / numel(img);密钥空间方面要坦诚说:基础版本的可用密钥主要来自三个基数(可交换排列)和三个跳数(若跳数上限为 1 万,则有约 10 的 12 次方组合),对一般课程项目和内部工具足够;但如果要做更严格的加密系统,建议把 6 参数密钥扩展为 8~10 参数,比如加入图像尺寸校验、多轮次迭代轮数、以及通道独立偏移。
6.3 可以落地的升级方案
这套框架最大的优点是结构清晰、可扩展性强。基于现有代码,我建议从三个方向做升级。
第一,把纯 Halton 密钥流升级为 “Halton + 混沌” 混合模式。Halton 序列均匀性好,但伪随机性不如高质量混沌序列。先用 Halton 序列生成随机坐标,再用这些坐标作为混沌系统的初始值扰动,这样既保留低差异序列的均匀性,又增强了不可预测性。
第二,改为多轮迭代结构。把加密函数中的置乱+扩散封装成一轮,循环执行 2~3 轮。每轮可以切换不同的跳数偏移,让置乱和扩散充分混合。解密端只需要反向执行循环即可。
第三,引入分块处理。对大尺寸图像,把图像切分成若干块分别加密,块边界可以引入交叉混淆,这样能提升局部抗攻击能力,也方便并行化加速。
个人实操经验
最后分享一个小技巧:调试这套代码时,不要一开始就用真实图像。建议先用 8×8 或 16×16 的纯色块矩阵测试加密-解密闭环,确认还原误差为 0 后再切换到大图。小矩阵的置乱效果肉眼可见,像素错位一眼就能看出来,而大图的细节问题容易被视觉掩盖。
另外,密钥向量里的跳数尽量不要取太小的值,比如skip=1和skip=2的序列只差一个点位置,攻击者容易通过穷举试探。把跳数范围设置成 100 到 10000 之间的随机数,密钥空间和安全性都有明显提升。
如果你最终要写实验报告或论文,记得把第 6 节的直方图、相关性、密钥敏感性三个指标全部跑一遍,配上数据表格比任何文字描述都有说服力。