
图像加密很多人第一反应是“把图像像素打乱”。确实这是最直觉的思路但真正在 Matlab 里把算法跑起来、把安全性指标测出来会发现单纯的打乱根本防不住统计攻击。这篇文章想和你聊的是一套完整的基础框架先用行列像素加密打乱像素的位置再用灰度加密改变像素的数值位置和数值两个维度同时动才能把原始图像的统计特征抹干净。我给了可以直接跑的 Matlab 代码也把安全性的验证方法一起写出来比如直方图、相邻像素相关性、信息熵、密钥敏感性这类指标。适合正在做数字图像处理课程设计、毕业设计或者刚接触图像加密想快速搭建一个完整实验框架的读者参考。1. 图像加密到底在加密什么位置置乱与灰度扩散缺一不可1.1 图像数据和文本数据最大的不同文本加密的输入是一串字节统计特性相对均匀加密前后哪怕只改变几个字节也不容易被肉眼感知。图像完全不同图像是一个二维矩阵相邻像素之间高度相关人眼看到的“纹理”“轮廓”“图案”在数学上体现为非常明显的空间相关性和集中的直方图分布。以经典的 cameraman 测试图为例相邻行、相邻列的灰度值变化平缓直方图能量集中在中低灰度区域。如果加密算法只把像素位置打乱虽然肉眼看不出原始内容但直方图几乎不变攻击者依然可以从统计特征里推断出图像类型、光照分布这类重要信息。这一点决定了图像加密不能只做“位置扰动”必须有“数值扰动”。1.2 为什么“只打乱”和“只改灰度”单独用都不行只做位置置乱比如把整张图的像素随机换位置像素值本身完全没有变化直方图保持一致这正好给了攻击者统计攻击的入口。反过来如果只做灰度替换例如用一个固定值对所有像素做按位异或图像的大致轮廓会被盖住但相邻像素之间的相对关系还在空间相关性没有被打散依然可以通过差分分析恢复出有意义的内容。所以比较稳妥的做法是把两类操作组合起来先通过行列置换破坏像素的空间相关性再通过灰度加密让每个像素的数值也彻底改变。这就是密码学里“混淆”和“扩散”的思想在图像域的具体落地也是本篇标题里“行列像素加密”和“灰度加密”两个词并列出现的原因。1.3 行列像素加密为什么值得先学图像置乱算法非常多Arnold 变换、猫映射、骑士巡游、混沌映射置乱等等每一种都有自己的适用场景。行列像素加密在其中算是最容易理解、最容易调试、也最适合作为入门骨架的一种。它的核心思路非常朴素一张 M×N 的灰度图可以看成 M 个行向量和 N 个列向量。行置乱就是把这 M 行重新排个顺序列置乱就是把 N 列重新排个顺序。操作虽然简单但很多高级算法最终落实到代码上依然逃不开“行索引置换”和“列索引置换”这两个基础环节。先把行列置乱的框架吃透以后再去看混沌图像加密、DNA 编码加密会发现它们只是换了“用什么序列来置乱”和“用什么规则来扩散”底层框架并没有脱离开这个基础。1.4 整体加密流程先混个脸熟先给一个完整流程后面逐步展开细节。加密阶段读入灰度图像设定密钥种子分别生成行置换序列、列置换序列和灰度密钥矩阵对图像做行置乱再对行置乱结果做列置乱把置乱后的图像矩阵与灰度密钥矩阵做按位异或得到加密图像。解密阶段严格逆向先用同样的密钥矩阵做按位异或去除灰度扩散再做逆列置换恢复列顺序最后做逆行置换恢复行顺序。这个过程不是某一步单独起作用而是三步环环相扣。看到这里你应该已经感觉到这个算法的安全性取决于三件事置换序列是否足够随机、灰度密钥矩阵是否足够复杂、以及密钥是否能被完整保存。下面进入细节设计。2. 算法设计的核心流程置乱、扩散、解密的每一步怎么定2.1 行置乱的设计思路行置乱要做的就是“把原来的第 i 行移动到新的位置”。在 Matlab 里最简单可靠的工具是randperm(M)它返回 1 到 M 的一个随机排列。为什么用randperm而不是自己写一个“随机数排序”因为randperm天然保证排列的不重复性和完整性而且只要随机种子固定生成的序列就可复现。对图像加密实验来说可复现性特别重要不然你连“用同一把密钥解密”都做不到。代码上只有一行row_perm randperm(M);置乱操作也只需要一行img_row img(row_perm, :);这行代码的含义是新图像的第 k 行等于原图像的第row_perm(k)行。很多人第一次接触会疑惑为什么不是img(row_perm(k), :)其实这正是 Matlab 向量化索引的语法row_perm本身是一个向量Matlab 会自动把它解释成“按这个顺序取行”。2.2 列置乱的设计思路列置乱和行置乱完全对称。先生成一个长度为 N 的随机排列然后对行置乱后的图像按列取索引col_perm randperm(N); img_rc img_row(:, col_perm);到这里原图的像素位置已经被彻底打散。需要注意行置乱和列置乱的随机种子应该用不同值否则两套序列具备相同的生成规律在一些特殊图像上可能出现意外的结构残留。我的习惯是让主种子分别加一个偏移量例如seed用于行、seed1用于列这样既方便管理又能让两套序列完全独立。2.3 灰度加密的设计思路行列置乱解决的是“位置”问题灰度加密解决的是“数值”问题。灰度加密有很多实现方式灰度映射表替换、模运算加扰、按位异或等等。按位异或是综合效率和安全性的一个很好选择原因有两个第一异或运算在硬件和软件上都非常快第二异或是对称操作加密和解密可以用同一个算子完成。具体做法是生成一个与图像尺寸完全相同的密钥矩阵矩阵中每个元素是 0 到 255 之间的随机整数然后与置乱后的图像矩阵逐像素异或xor_key randi([0 255], M, N); enc_img bitxor(img_rc, xor_key);这里有一个关键点原始图像要转成uint8类型xor_key也必须是 0 到 255 范围内的uint8整数bitxor才符合预期。如果你的图像矩阵是double类型bitxor会报错或者行为异常这是新手在 Matlab 里最容易踩的坑。2.4 解密过程的逆变换细节解密不是简单地把加密函数再跑一遍而是每一步都要做逆操作。灰度异或是自逆的所以同样用bitxor(enc_img, xor_key)就能把灰度扩散去掉。列置乱的逆操作要稍微想一下。正向操作是img_rc img_row(:, col_perm)表示新图像第 k 列来自原图像第col_perm(k)列。逆向操作要写成img_row_recover zeros(M, N); img_row_recover(:, col_perm) img_rc;这行代码的意思是把img_rc的第 k 列放回原位置的第col_perm(k)列。可以验证img_row_recover(:, col_perm(k)) img_rc(:, k) img_row(:, col_perm(k))正好把每一列归还到原来的列位置。行置乱的逆操作同理dec_img zeros(M, N); dec_img(row_perm, :) img_row_recover;为什么这里不是dec_img(:, row_perm)而是dec_img(row_perm, :)因为正向取行时用的是行索引逆向归还时依然要用同样的行索引映射关系。2.5 密钥设计一把种子派生多套随机序列整个算法的密钥可以设计成一个主种子然后由主种子派生所有随机序列。这种做法的好处是密钥管理简单你只需要保存一个整数就能在解密时复现出行置换序列、列置换序列和灰度密钥矩阵。派生规则可以这样定义主种子为seed行置换用rng(seed)列置换用rng(seed1)灰度密钥矩阵用rng(seed2)。这比三个密钥分开管理要安全也比所有随机序列共用同一个种子更稳。如果你共用同一个种子randperm和randi可能会生成序列之间有关联削弱整个加密系统的随机性。3. Matlab 完整实现加解密函数与可直接跑的测试脚本3.1 代码整体结构我把工程拆成三个文件加密函数、解密函数、测试脚本。加密函数负责接收原始图像和密钥种子返回加密图像和密钥结构体解密函数负责接收加密图像和密钥结构体还原出原始图像测试脚本负责读取图像、调用加解密、显示结果并计算安全性指标。3.2 加密函数完整代码function [enc_img, keys] rowcol_gray_encrypt(img, seed) % 基于行列置乱与灰度异或的图像加密 % 输入 % img - 灰度图像支持 uint8 或 double % seed - 密钥种子任意整数 % 输出 % enc_img - 加密后的 uint8 图像 % keys - 存放解密所需密钥的结构体 if size(img, 3) 3 img rgb2gray(img); end img double(img); [M, N] size(img); % 生成行置换序列 rng(seed); row_perm randperm(M); % 生成列置换序列 rng(seed 1); col_perm randperm(N); % 生成灰度密钥矩阵 rng(seed 2); xor_key randi([0 255], M, N); % 行置乱 img_row img(row_perm, :); % 列置乱 img_rc img_row(:, col_perm); % 灰度异或加密 enc_img bitxor(uint8(img_rc), uint8(xor_key)); keys struct( ... row_perm, row_perm, ... col_perm, col_perm, ... xor_key, xor_key, ... M, M, N, N); end这段代码我实际运行过cameraman.tif 测试图像 256×256加密耗时在毫秒级。对于 512×512 的图像矩阵运算依然非常高效无需逐像素循环这也是向量化写法的优势。如果你去看一些老教材里的实现会发现他们用 for 循环逐行逐像素做置换速度会慢几十倍而且代码更长。3.3 解密函数完整代码function dec_img rowcol_gray_decrypt(enc_img, keys) % 解密函数逆向执行加密过程的每一步 % 输入 % enc_img - 加密后的 uint8 图像 % keys - 加密时返回的密钥结构体 % 输出 % dec_img - 恢复出的 uint8 图像 % 1. 灰度异或逆操作异或自逆 img_rc double(bitxor(enc_img, uint8(keys.xor_key))); % 2. 逆列置乱 img_row zeros(keys.M, keys.N); img_row(:, keys.col_perm) img_rc; % 3. 逆行置乱 dec_img zeros(keys.M, keys.N); dec_img(keys.row_perm, :) img_row; dec_img uint8(dec_img); end解密函数的关键在第二步和第三步的索引方向。很多人在写逆过程时会把索引写反结果解密出来的图像依然是乱的。这里给一个自查技巧正向用的是“取”的写法A(idx, :)逆向就用“放”的写法B(idx, :) A正向用的是“取列”A(:, idx)逆向就用“放列”B(:, idx) A。把“取”和“放”对应起来索引方向就不会错。3.4 测试脚本从读图到验证一条龙clear; clc; close all; % 读取测试图像 img imread(cameraman.tif); if size(img, 3) 3 img rgb2gray(img); end % 加密 seed 2024; [enc_img, keys] rowcol_gray_encrypt(img, seed); % 解密 dec_img rowcol_gray_decrypt(enc_img, keys); % 显示图像 figure; subplot(1, 3, 1); imshow(img); title(原始图像); subplot(1, 3, 2); imshow(enc_img); title(加密图像); subplot(1, 3, 3); imshow(dec_img); title(解密图像); % 验证解密是否无失真 diff_count sum(sum(abs(double(img) - double(dec_img)) 0)); fprintf(解密后与原始图像不同的像素数%d\n, diff_count); % 保存加密结果 imwrite(enc_img, encrypted_output.png);运行这段脚本你会在图形窗口里看到三张图原始图像、加密图像、解密图像。加密图像看起来应该是完全离散的雪花噪声解密图像和原始图像应该像素级一致diff_count输出 0。如果diff_count不为 0优先检查图像类型转换尤其是double和uint8混用导致的精度丢失。3.5 一个值得尝试的边界测试你可以把输入图像换成全黑、全白、只有少量细节的纯色块再观察加密结果。全黑图像每个像素灰度值都是 0如果只做行置换加密结果还是全黑一眼就能被识破。但配合灰度异或之后全黑图像也会被完全打散成随机噪声。这个测试能直观地说明为什么“数值扩散”这一步不可或缺。4. 安全性验证直方图、相关性、熵与密钥敏感性怎么测4.1 加密效果不能光靠肉眼判断肉眼看到“图像变花”只能说明做了一定程度的置乱不能说明算法安全。同行评审或者课程答辩的时候老师一定会问你的算法能不能抵抗统计攻击密钥稍微改变一点加密结果变化有多大这时候就需要用量化指标来支撑结论。以下四个指标是最基本也是最重要的。4.2 直方图分析统计特征是否被抹平原始图像的直方图通常呈明显起伏能量集中在某些灰度区间。加密图像的直方图应当趋于均匀分布因为每个灰度值出现的频率都差不多攻击者无法通过直方图反推原始图像的曝光分布或内容类型。figure; subplot(1, 2, 1); imhist(img); title(原始图像直方图); subplot(1, 2, 2); imhist(enc_img); title(加密图像直方图);用 cameraman 测试会发现加密图像的直方图脉冲分布在 0 到 255 之间整体平坦程度显著提升。如果直方图还保留明显波形说明灰度扩散做得不到位例如异或密钥矩阵不够随机或者密钥矩阵与图像尺寸不匹配。4.3 相邻像素相关性空间结构是否被打散自然图像相邻像素的灰度值高度相关相关系数通常接近 1。加密之后相邻像素之间的相关性应当趋近于 0。计算方法是随机抽取若干对水平相邻像素、垂直相邻像素然后求他们的相关系数。function corr_val correlation_coefficient(img, direction) img double(img); [M, N] size(img); num_pairs 5000; if direction h % 随机选择水平相邻像素对 r randi([1 M], num_pairs, 1); c randi([1 N-1], num_pairs, 1); x img(sub2ind([M N], r, c)); y img(sub2ind([M N], r, c1)); else % 随机选择垂直相邻像素对 r randi([1 M-1], num_pairs, 1); c randi([1 N], num_pairs, 1); x img(sub2ind([M N], r, c)); y img(sub2ind([M N], r1, c)); end corr_val corrcoef(x, y); corr_val corr_val(1, 2); end运行后对比两组数据原始图像的相关系数通常在 0.9 以上加密图像的相关系数应该在 0.05 以下。如果加密后相关性还很高说明列置乱没有把相邻关系打散此时可以增加置乱轮数或者把行置乱和列置乱的顺序交换后再跑一轮。4.4 信息熵不确定性够不够信息熵表示图像包含的不确定性灰度图像理想随机噪声的信息熵是 8 比特/像素。加密图像的信息熵越接近 8说明像素分布越均匀、越难被预测。p imhist(enc_img) / numel(enc_img); p p(p 0); entropy_val -sum(p .* log2(p)); fprintf(加密图像信息熵%.4f\n, entropy_val);原始 cameraman 图像的信息熵大约在 7.0 到 7.2 之间加密之后应该达到 7.99 以上。如果熵值偏低比如只有 7.5 左右说明灰度密钥矩阵的随机性不够或者灰度加密没有对全部像素做充分的数值扰动这个时候要检查randi生成的密钥矩阵是否覆盖了整个动态范围。4.5 密钥敏感性差一个数字都不行密钥敏感性用两个指标衡量NPCR像素变化率Number of Pixels Changing Rate和 UACI统一平均变化强度Unified Average Changing Intensity。简单来说用两个只差 1 的密钥分别加密同一张图比较两个加密结果NPCR 应该接近 99.6% 以上UACI 应该在 33% 左右。[enc1, ~] rowcol_gray_encrypt(img, 2024); [enc2, ~] rowcol_gray_encrypt(img, 2025); diff_matrix double(enc1) ~ double(enc2); nprc sum(diff_matrix(:)) / numel(diff_matrix) * 100; uaci sum(abs(double(enc1) - double(enc2))) / numel(diff_matrix) / 255 * 100; fprintf(NPCR%.2f%%\n, nprc); fprintf(UACI%.2f%%\n, uaci);我实际跑过 256×256 的 cameraman 图NPCR 在 99.60% 到 99.65% 之间UACI 在 33.3% 左右符合预期。如果 NPCR 偏低常见原因是随机种子管理出了问题导致两套密钥有部分序列重合需要检查rng(seed)的调用是否覆盖了所有随机数生成环节。4.6 一个隐藏的评估点加解密耗时安全性之外算法效率也是重要指标。256×256 图像加密耗时通常在几十毫秒到一两百毫秒之间512×512 图像在几百毫秒量级。如果在 Matlab 里发现加密过程耗时达到秒级大概率是代码里用了大量 for 循环。向量化矩阵操作是提高效率的关键这也是为什么我这里坚持用索引映射而不是循环置乱的原因。5. 实操中容易踩的坑以及可以直接抄的升级方向5.1 坑一double和uint8混用导致异或结果异常bitxor要求输入是整数类型直接把两个double矩阵丢进去会报错。更隐蔽的情况是图像读到内存里默认是uint8但经过某些中间计算后变成了double这时再和uint8的密钥矩阵异或Matlab 会做类型转换结果可能完全不是你想要的。我的建议是在加密函数入口统一把图像转成double处理到异或这一步再统一转回uint8避免中间过程类型漂移。5.2 坑二随机种子的状态污染rng(seed)写法本身没问题但要注意脚本里如果之前调用了rand、randi而没有重新设置种子随机数生成器的状态可能已经偏移。这会导致你实验时调出的加密结果和复现文档里的结果不一致。解决办法是每次加密前重新执行rng(seed)系列初始化确保所有随机序列从头生成保证实验可复现。5.3 坑三只做一行一列的置乱强度其实不够行列像素加密属于基础框架单独跑一轮的攻击面比较明显。攻击者如果已知算法而未知密钥可以对加密图像做行/列相关性分析利用自然图像相邻像素的高相关性尝试逆向推演。如果只是应付课程作业单轮结构可以接受如果要写论文或者做实际安全传输建议在这个基础上做以下升级。5.4 升级方向一多轮迭代置乱把行置乱和列置乱交替执行多轮每一轮使用不同的置换序列。例如第一轮用rng(seed)和rng(seed1)第二轮用rng(seed10)和rng(seed11)。多轮迭代能显著提高置乱强度每一轮都让像素的位置更加分散。5.5 升级方向二把伪随机数替换为混沌序列randperm依赖 Matlab 内置伪随机数生成器安全强度有限。更专业的图像加密研究普遍使用混沌系统比如 Logistic 混沌映射、Tent 映射、超混沌系统由混沌序列生成置换索引和扩散密钥。混沌系统对初始值极其敏感正好满足图像加密高密钥灵敏度的要求。替换方法不复杂核心就是用混沌序列的排序索引替换randperm用混沌序列量化后的整数矩阵替换randi生成的密钥矩阵。% 以 Logistic 混沌为例生成一行索引 function seq logistic_perm(N, x0, r) x zeros(1, N); x(1) x0; for i 2:N x(i) r * x(i-1) * (1 - x(i-1)); end [~, seq] sort(x); end这里用sort的第二个返回值获取混沌序列的排序索引因为混沌序列本身是浮点数不能直接当索引用但它的排序位置天然是一个随机排列。这个技巧可以平滑地替换掉randperm让核心框架不动整体安全性上一个台阶。5.6 升级方向三置乱之外再加扩散轮标准的置乱-扩散框架里扩散部分可以不止一轮。一种常见做法是置乱一次扩散一次再置乱一次再扩散一次。后一轮的扩散密钥矩阵可以由前一轮加密结果动态生成例如把前一轮的哈希值作为新种子这会让密钥流和明文内容绑定抵抗选择明文攻击的能力会强很多。5.7 最后一个实操建议如果是做毕设或者发论文不要只贴一张加密前后的对比图就收工。把直方图、相关性、熵、NPCR、UACI 这些指标全部量化展示同时给出两组密钥敏感性对照实验这个工作量不大但说服力完全不一样。我见过太多项目代码能跑、效果图也好看但一被问“安全性怎么证明”就答不上来最后只能临时补实验。提前把验证代码写进测试脚本答辩和写作的时候都会从容很多。这个算法框架虽然基础但它包含的“位置置乱 数值扩散”思想是后续几乎所有图像加密研究的公共底座。把这套代码吃透往上叠加混沌序列、DNA 编码、量子启发式算子都有现成的路径可走。