拓冰建站拓冰建站
首页 / 资讯中心 / 正文

K-SVD字典学习与OMP稀疏编码:MATLAB图像去噪实战

简介稀疏表示是信号处理与计算机视觉中的核心思想旨在用尽可能少的原子线性组合逼近原始数据广泛应用于图像去噪、压缩感知与特征提取。字典学习作为获取过完备字典的关键技术通过交替优化稀疏系数与字典原子实现数据自适应的表示模型。K-SVD算法凭借逐列SVD更新策略相比传统MOD方法具有更好的数值稳定性与收敛性已成为字典学习领域的经典方法。本文从稀疏表示与字典学习的基本原理出发深入剖析OMP稀疏编码与K-SVD字典更新的数学机制并给出完整的MATLAB实现与图像去噪实验流程。通过滑动窗口采样图像块、去直流、训练字典与稀疏重建可以显著提升含噪图像的PSNR同时保证字典原子的可解释性。文章还总结了参数调优、死原子处理与工程化优化经验为入门稀疏字典学习与工程实践提供可靠参考。 做图像去噪那阵子我手头有个老项目用的还是BM3D效果虽然不错但导师要求换一种可解释性更强的方案。翻了一圈文献几乎所有论文都在提稀疏字典学习尤其是K-SVD。论文看明白之后真到MATLAB里写实现还是踩了不少坑。这篇就把我整理好的方案分享出来从OMP稀疏编码到K-SVD字典逐列更新再到图像去噪完整Demo代码都直接可跑希望能帮正在啃这个方向的同学生省点时间。1. 先把原理说透稀疏字典学习到底在解什么问题1.1 稀疏表示是“用最少的原子拼出原样”稀疏表示这个事说白了就是想用极少数的“零件”拼出一幅图像、一段信号或者一个特征向量。想象一个图书馆里有很多本字典单词很多但你写一句话只需要挑几个词就够了。这里的“字典”就是一堆基础原子每一个原子是长度和样本一致的向量稀疏表示就是希望找少量的原子线性组合把原来的样本重建出来。数学上给定训练样本矩阵 $Y \in \mathbb{R}^{n \times N}$有一本过完备字典 $D \in \mathbb{R}^{n \times K}$其中 $K$ 通常大于 $n$也就是说原子的数量大于信号维度。我们需要求一个稀疏系数矩阵 $X$让 $D X$ 能够尽量还原 $Y$同时希望每一列 $x_i$ 的非零元素尽量少。这个“尽量少”用符号表达就是 $|x_i|_0 \le T_0$其中 $| \cdot |_0$ 统计非零元素个数$T_0$ 是我们设定的稀疏度。理解了这个设定后面所有代码都是围绕它转的。1.2 整个优化问题的数学表达字典学习的完整目标函数可以写成$$\min_{D, X} | Y - D X |_F^2 \quad \text{s.t.} \quad \forall i,\ |x_i|_0 \le T_0$$这个式子看起来简单实际上是一个非凸问题因为 $D$ 和 $X$ 耦合在一起没法一次性求全局最优。$L_0$ 范数还带来了组合爆炸直接求解是NP难的。所以学术界和工程界都采用了同一个套路交替迭代把大问题拆成两个小问题。第一步是固定 $D$更新 $X$也就是稀疏编码。给定当前字典用正交匹配追踪OMP等算法为每个样本求一个满足稀疏度约束的系数。第二步是固定 $X$更新 $D$也就是字典学习。这个阶段的目标是让字典更好地适应当前系数下的残差。两个步骤反复交替每轮都会降低重构误差迭代若干次之后字典和系数就稳定下来了。实际工程中10到30轮基本够用再往后收益很小。1.3 为什么K-SVD比MOD稳定逐列更新的门道早期用的字典更新方法叫MODMethod of Optimal Directions它的做法比较粗暴固定 $X$ 后直接对整个 $D$ 求一个最小二乘解 $D Y X^T (X X^T)^{-1}$。MOD的问题在于需要矩阵求逆当 $X X^T$ 条件数不好时数值上容易抖动而且字典的所有原子同时变化不好解释收敛过程。K-SVD的改进思路非常直接不一口气更新整个字典而是一列一列地更新。更新第 $k$ 列原子时只查看“哪些样本使用了第 $k$ 列原子”把这些样本上其他原子贡献的残差算出来然后对这个残差矩阵做奇异值分解SVD。SVD的第一左奇异向量就是新的原子第一右奇异向量乘以最大奇异值就是新的系数行。这个做法的妙处在于SVD给出了最小二乘意义下秩一逼近的最优解相当于在当前状态下每列更新都是朝着降低重构误差的方向迈了一步而且一次只动一个原子数值上比MOD稳得多。代价是要对所有原子循环一遍每个原子做一次SVD计算量比MOD大但换来了可靠性和可解释性这也是它到现在还是字典学习入门首选的原因。2. 核心代码逐行拆解从OMP到K-SVD的MATLAB实现2.1 代码工程结构怎么组织写MATLAB实现不需要搞复杂框架三个文件就够了ksvd.m是主函数负责交替迭代omp.m负责稀疏编码updateDict.m负责字典的逐列SVD更新。这样拆开的好处是逻辑清晰以后想换成其他稀疏编码方法比如Lasso或者批量最小角回归只需要替换omp.m就行。主函数的整体流程在伪代码上是这样的初始化字典随机选取训练样本中的若干列逐列归一化进入迭代循环调用omp.m用当前字典求稀疏系数调用updateDict.m逐列更新字典计算当前相对重构误差记录到历史序列里迭代结束返回字典、系数和误差曲线下面把每个文件单独拆开讲细节都在注释里。2.2 OMP稀疏编码函数的实现OMP是匹配追踪的升级版。匹配追踪每轮只找和残差内积最大的原子减掉这个原子的贡献后继续找OMP的不同在于每轮找到新原子后会在当前支撑集上做一次最小二乘把支撑集上所有系数一起优化。这一下子就减少了重复选择的可能收敛也快很多。MATLAB实现function X omp(D, Y, T0) % OMP 正交匹配追踪 % 输入: % D - n x K 字典 % Y - n x N 样本矩阵每列一个样本 % T0 - 稀疏度上限 % 输出: % X - K x N 稀疏系数矩阵 K size(D, 2); N size(Y, 2); X zeros(K, N); for i 1:N r Y(:, i); % 当前残差 idx zeros(T0, 1); % 支撑集索引 coefLen 0; % 实际用到的原子数 for t 1:T0 corr D * r; % 所有原子与残差的内积 corr(idx(1:t-1)) -inf; % 屏蔽已选原子防止重复 [~, j] max(abs(corr)); % 找最相关的原子 idx(t) j; % 在支撑集上做最小二乘更新系数 actIdx idx(1:t); coef D(:, actIdx) \ Y(:, i); r Y(:, i) - D(:, actIdx) * coef; % 更新残差 coefLen t; if norm(r) 1e-8 break; % 残差足够小提前结束 end end X(idx(1:coefLen), i) coef; end end几个容易出错的地方第一个是corr(idx(1:t-1)) -inf。如果不屏蔽已经选中的原子OMP在原子相关性高的时候可能把同一个原子反复选上从而浪费迭代次数。虽然理论上最小二乘会让已经选入的原子系数不再变化但实际操作里由于浮点误差不排除这种可能。第二个是D(:, actIdx) \ Y(:, i)。MATLAB反斜杠会自动选择合适的求解器对于过定系统走QR分解比手写正规方程稳定得多。不要为了省事去用 $(D^T D)^{-1} D^T$当字典原子之间有较强的相关性时正规方程的数值条件会很差。第三个是残差阈值1e-8。这个阈值和信号幅度有关。如果输入样本归一化到0到1之间$10^{-8}$ 的残差足够小如果输入是0到255的灰度值建议把阈值放宽到 $10^{-4}$否则OMP会一直选到T0个原子浪费时间。2.3 K-SVD主循环字典逐列更新主函数代码function [D, X, errList] ksvd(Y, K, T0, iters) % K-SVD 字典学习 % 输入: % Y - n x N 训练样本 % K - 字典原子个数 % T0 - 稀疏度 % iters - 迭代次数 % 输出: % D - n x K 学习到的字典 % X - K x N 稀疏系数 % errList - 每轮相对重构误差 n size(Y, 1); % 初始化随机选K列列归一化 D Y(:, randi(size(Y, 2), 1, K)); D D ./ (sqrt(sum(D.^2, 1)) 1e-6); errList zeros(iters, 1); for it 1:iters X omp(D, Y, T0); % 稀疏编码 [D, X] updateDict(Y, D, X); % 字典更新 relErr norm(Y - D * X, fro) / norm(Y, fro); errList(it) relErr; fprintf(iter %d, relative error %.6f\n, it, relErr); end end初始化时从训练样本里随机挑K列比用随机高斯矩阵更合理。图像块内容天然具备一定的结构从数据出发的初始字典能让OMP阶段的前几轮误差下降更快。列归一化是为了消除尺度模糊如果不归一化原子长度可以任意变化系数也跟着缩放训练过程会浪费容量在尺度调整上。字典更新函数function [D, X] updateDict(Y, D, X) % 字典逐列SVD更新 K size(D, 2); for k 1:K % 找到哪些样本使用了第k个原子 I find(X(k, :)); if isempty(I) continue; % 死原子处理见后文 end % 所有样本的残差 Ek Y - D * X; % 只保留使用第k个原子的样本 Ek Ek(:, I); % 取出该原子在当前样本上的系数 xkT X(k, I); % 对受限残差矩阵做SVD % 这一步本质是对Ek做最优秩一逼近 [U, S, V] svd(Ek, econ); D(:, k) U(:, 1); X(k, I) S(1, 1) * V(:, 1); end end这里最核心的就是Ek Y - D * X。注意这个残差是在“所有原子贡献都去掉”的基础上计算的也就是当前字典和系数对应的总残差。然后限定到I这些样本上把第 $k$ 个原子在这些样本中的贡献又从系数里临时剥离出去剩下的就是“待第 $k$ 个原子去拟合的部分”。SVD分解之后$U$ 的第一列是左奇异向量$S(1,1)$ 是最大奇异值$V$ 的第一列是右奇异向量。用 $U(:,1)$ 替代旧原子用 $S(1,1) V(:,1)^T$ 替代旧系数行就能保证这一列原子在最小二乘意义下达到了当前状态的最优秩一逼近。需要注意svd(Ek, econ)是经济型分解当Ek是 $n \times m$ 且 $m n$ 时$V$ 是 $m \times m$这样取第一列没问题。如果某个原子只被一个样本使用Ek退化为单列向量SVD得到的 $U$ 是 $n \times 1$$V$ 是 $1 \times 1$S(1,1) * V(:,1)会得到一个标量正好更新那个样本的系数。2.4 冒烟测试拿合成数据验证写完三个函数先用合成数据跑一遍确认逻辑没问题再上图像。用一个随机字典生成一组稀疏系数合成观测数据然后在不告知真实字典的情况下用K-SVD去学rng(42); n 20; % 信号维度 K 40; % 原子个数 T0 4; % 稀疏度 N 500; % 样本数 Dtrue orth(randn(n, K)); Xtrue zeros(K, N); for i 1:N idx randperm(K, T0); Xtrue(idx, i) randn(T0, 1) * 2; end Y Dtrue * Xtrue; [D, X, errList] ksvd(Y, K, T0, 30); figure; plot(errList, o-); xlabel(iter); ylabel(relative error); title(K-SVD convergence);正常情况下相对误差会迅速下降最终降到非常接近0。如果降到某个值不再动说明字典容量或者稀疏度不够可以调整K和T0。这个合成实验适合用来排查代码bug因为它有明确的收敛性预期。3. 用图像去噪练个手完整可运行的实验脚本3.1 采样重叠图像块图像去噪是字典学习最经典的应用之一。思路是从带噪图像上切出很多小图像块把这些块作为训练样本学习一本字典再对每一个块做稀疏编码然后重建最后拼回整幅图。切块有一个关键细节要重叠。一次切一个8×8的块、步长为1叫sliding窗口这样相邻块之间高度相关训练样本量大重建时每个像素会被多个块覆盖天然带一点平均效果能压住块效应。MATLAB里可以直接用im2colbs 8; Yall im2col(noisy, [bs bs], sliding); % Yall 的每一列是一个块展开后的向量长度64如果整幅图是512×512sliding模式会得到约 $(512-81)^2 \approx 25$ 万个块全量用来训练太慢。实际操作是随机抽1万到3万块来训练字典然后对所有块做稀疏编码和重建。抽样不会显著影响字典质量因为图像块高度冗余。另一个细节是去直流。每个块先减去自己的平均值只保留纹理结构。如果不去直流字典会花很大容量去表示亮度本身而亮度信息对所有块几乎是常数浪费原子。直流分量单独存下来重建的时候加回去。3.2 去噪与重建主流程完整去噪脚本如下clear; close all; clc; rng(42); % 读图转灰度、归一化 img im2double(imread(cameraman.tif)); if size(img, 3) 1 img rgb2gray(img); end % 加高斯噪声 sigma 25 / 255; noisy img sigma * randn(size(img)); % 参数设置 bs 8; % 块大小 K 256; % 字典原子数 T0 8; % 稀疏度 iters 20; % 训练轮数 Ntrain 20000; % 训练块数量 % 采样训练块去直流 Yall im2col(noisy, [bs bs], sliding); Yall Yall - mean(Yall, 1); sel randperm(size(Yall, 2), Ntrain); Ytrain Yall(:, sel); % 训练字典 [D, ~, errList] ksvd(Ytrain, K, T0, iters); % 所有块稀疏编码并重建 Xall omp(D, Yall, T0); recBlocks D * Xall mean(im2col(noisy, [bs bs], sliding), 1); % 拼回整幅图sliding重叠区域自动加权平均 denoised col2im(recBlocks, [bs bs], size(noisy), sliding); % 评估PSNR psnrDenoise psnr(denoised, img); fprintf(PSNR %.2f dB\n, psnrDenoise); figure; subplot(1, 3, 1); imshow(img); title(Original); subplot(1, 3, 2); imshow(noisy); title(Noisy); subplot(1, 3, 3); imshow(denoised); title(KSVD Denoised);col2im在sliding模式下会把重叠区域的多个块贡献叠加最后除以覆盖次数等价于加权平均。这个聚合方式能有效减少块边界痕迹。3.3 效果评估和主观感受以256×256的cameraman为例加入标准差25/255的高斯噪声噪声图PSNR大概在20.1dB左右。用以上参数跑完去噪后的PSNR一般能做到28dB以上效果肉眼可见地干净纹理细节比直接均值滤波好得多。如果想再压榨一点效果可以把迭代次数提高到30或者把K增到512但训练时间会明显涨。需要说明BM3D这类专用去噪方法的PSNR通常会比K-SVD再高1到2dB但K-SVD的价值在于字典本身是可解释的训练出来的每个原子对应一种结构模式比如边缘、条纹、角点这在人脸识别、信号稀疏表示、压缩感知等任务里更有用。去噪只是拿它练手的一个入口。训练出来的字典可以直接可视化digit 16; figure; for i 1:K subplot(16, 16, i); imshow(reshape(D(:, i), bs, bs), []); end一张256原子的字典用16×16网格展示能看到每个小方块都长成一定的方向纹理而不是随机噪声。这本身就是验证字典学习是否成功的一个直观手段。4. 调参避坑实录把K-SVD跑稳的经验总结4.1 字典初始化和“死原子”处理初始化看起来不起眼实际影响不小。如果随机选到的训练块很相似比如全是平滑区域初始字典会缺少边缘原子收敛到后期某些原子可能完全不被使用。这种“死原子”会让字典容量被浪费。处理死原子有两种常用策略在updateDict.m里如果I为空用当前重构误差最大的样本替换这个原子这样能强迫字典去关注最难拟合的样本。如果迭代过程中发现某个原子的稀疏系数行全是0也可以在每轮结束后统一检查替换为训练样本中残差最大的那一列。第二种更主动但要注意别在OMP完成后立即替换否则会破坏当前轮次的误差一致性。我比较喜欢在每轮字典更新结束之后做统一清理。代码里可以这样改function [D, X] replaceDeadAtoms(Y, D, X) K size(D, 2); active sum(X ~ 0, 2); dead find(active 0); if isempty(dead) return; end % 当前残差 R Y - D * X; for k dead [~, j] max(sum(R.^2, 1)); atom Y(:, j); nrm norm(atom); if nrm 1e-10 atom randn(size(D, 1), 1); nrm norm(atom); end D(:, k) atom / nrm; X(k, :) 0; X(k, j) nrm; % 更新残差 R Y - D * X; end end这个函数可以在主循环里每轮字典更新后调用一次能明显改善字典利用率和最终重构质量。4.2 参数范围和默认推荐先给一张我实测下来的参数推荐表再逐个解释参数推荐范围说明块大小 bs6~12越大越能捕捉结构但字典学习越慢字典规模 K64~512样本维度不高时K取大容易过拟合稀疏度 T03~12和噪声水平强相关噪声大要适当加大迭代次数 iters15~30超过30轮收益很小训练块数量10000~40000太少字典学不充分太多训练慢关键trade-off在T0。T0太小表示能力不够去噪之后图像会偏模糊因为细节无法用少量原子表达T0太大模型会把噪声部分也当成结构去拟合去噪效果变差PSNR反而下降。实际操作中我一般以噪声标准差为参考$\sigma15/255$ 时T0取5左右$\sigma25/255$ 取8$\sigma50/255$ 取12然后在这个基础上微调。K的选择和样本维度有关。8×8块展开后维度是64K取256相当于4倍过完备够用了。如果K取1024字典原子之间大量冗余训练时间成倍增加但重构误差下降很有限。K的收益不是线性的256到512能感觉到提升512到1024基本感觉不到。4.3 训练样本去直流和归一化的问题图像块本身包含亮度直流量这个量对所有块来说几乎不变。如果不做去直流K-SVD训练出的第一个原子很可能就是一个“近似全1”的原子专门用来表示平均亮度剩下的容量才用来学纹理。这本身不是错但会浪费容量而且在稀疏编码阶段所有块都得先用这个原子再用其他原子补偿细节T0压力会变大。所以我的统一做法是训练前把每个块减去自己的均值保存这些均值向量训练完字典之后对所有块编码时也要把均值加回来。上面的去噪脚本里已经体现了这一点。归一化这块用im2double把图像转到0到1范围即可。不要用uint8直接算SVD和反斜杠在uint8上不可用而且灰度级0~255的取值范围会让数值条件变差。如果输入数据不在0到1范围OMP的残差阈值也要相应调整这个前面OMP一节提过。4.4 运行报错和性能优化记录我用R2020b搭的这套代码在R2018a上也验证过基本语法没踩坑。有几个容易出问题的地方值得单独说svd(Ek, econ)在空矩阵或者单个样本时不会报错但如果Ek存在NaN结果会直接崩。所以训练数据里要确保没有NaN图像读进来检查一下是否有坏像素。im2col对内存不友好。512×512的图8×8块sliding模式会产生25万个列向量每列64维总共约1.25亿个浮点数约100MB。这在老电脑上可能卡。所以训练阶段的im2col可以只抽部分块或者用distinct模式加速但求质量还是建议sliding。MATLAB并行工具箱可以加速OMP把for i 1:N改成parfor i 1:N前提是每个样本之间没有数据依赖。我在8核机器上测试N20000时能快3倍左右。不过要注意parfor里对X的按列赋值需要改成X(:, i) ...的方式或者收集到cell再拼。老版本MATLAB对randperm第二个参数语义一致但im2col、col2im这些函数行为很稳定基本可以放心用。训练时间实测256×256图像、20000个训练块、K256、T08、20轮在普通办公笔记本上大约是20到40秒取决于CPU。如果时间太慢优先减少训练块数量到10000或者把K降到128质量损失不大。4.5 后续可以怎么扩展这套K-SVD实现是稀疏字典学习的一个扎实底座。想继续深入可以考虑几个方向一是判别式字典学习。K-SVD是重构导向的不关心稀疏系数能不能用于分类。LC-KSVDLabel Consistent KSVD在目标函数里加了分类误差项每个原子和类别标签挂钩训练出来的字典同时具备重构和判别能力。这个方向在人脸识别、SAR图像分类里效果很好。二是非负字典学习。如果数据本来就非负比如光谱数据、文本词频加上非负约束之后字典和系数都更可解释MATLAB里可以用坐标下降或者投影梯度实现。三是在线字典学习。K-SVD每次迭代都要处理全部训练样本数据量大时内存扛不住。Mairal等人提出的在线字典学习每次只取一小批样本更新字典时用一次梯度步替代SVD适合流式数据。四是结合深度学习的展开网络。像LISTALearnable ISTA这种思路把稀疏编码的迭代过程展开成网络层参数用数据学出来推理速度比在线OMP快很多。如果对加速有硬需求这个方向值得关注。我后来在几个实际项目里已经把图像去噪脚本换成了判别字典加在线更新的组合速度和效果都更适应生产环境。但无论怎么改K-SVD里的OMP和SVD更新这两个核心思想一直没变理解它们是理解整个稀疏表示工具箱的钥匙。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门