简介:这份开源工具面向研究张量分解、高维数据恢复与去噪的Matlab用户,重点以凸优化框架实现低秩张量近似,可用于图像、视频及多模态数据的噪声抑制与信号重建。压缩包共48个文件,以45个.m函数脚本为主体,辅以1个Markdown说明及版本管理配置文件,整体约55KB,覆盖算法实现、实验脚本与绘图对比模块。内容围绕交替方向法、软阈值、张量与矩阵补全、去噪实验展开,并包含CP、Tucker等分解思路的验证代码,便于读者理解参数调节、重建误差评估与结果可视化。目录中可见张量/矩阵补全、降噪实验与多组绘图脚本,还提供属性配置、随机张量生成、误差计算等辅助函数,便于按实验流程快速定位与复用。资源已有739人学习,适合具备Matlab基础、希望从代码层面掌握凸优化张量去噪流程的研究者与工程师,可据此搭建实验基线、复现对比曲线并迁移到自身数据。
1. 一张 25dB 的彩色噪声图,为什么值得用张量凸优化去啃
把一张彩色图加 σ=25 的高斯噪声,逐通道各做一遍经典空域滤波,PSNR 从 20dB 拉到 30dB 出头之后就开始原地打转,再调窗口、再换核,收益都是零点几个 dB。问题不在滤波器调得不够细,而在于 R、G、B 三个通道被当成三张互不相干的灰度图处理,通道间那条极强相关性被整条扔掉了。这套以开源脚本形式分发的 MATLAB 张量分解去噪系统,走的是另一条路:把图像当成 n1×n2×n3 的三阶张量,用一个凸优化目标(数据保真项加张量核范数)一次性求解,让低秩先验同时在两个空间模态和通道模态上生效。适合两类人:想把张量分解从公式变成能跑代码的研究生,以及手上有一堆多光谱、高光谱、视频帧数据、需要一套可改可调的去噪骨架的从业者。它不负责教你怎么推导近端算子,但能把“凸松弛怎么落到矩阵运算”这件事摊开给你看。
2. 凸松弛的骨架:从 CP/Tucker 到张量核范数,公式怎么落到矩阵
2.1 逐通道去噪到底丢掉了什么信息
先把张量的三个模态摆清楚。一张 H×W×3 的彩色图,模态 1 是行方向,模态 2 是列方向,模态 3 是通道方向;换成一叠 31 波段的高光谱立方体,模态 3 就变成光谱维。噪声在三个模态上基本是独立同分布的,但真实信号在通道维上的相关性极强——相邻波段的反射率曲线是光滑的,RGB 三通道在同一边缘处同时跳变。逐通道处理等于强行假设模态 3 上没有任何结构,把可用的观测样本量砍掉了三分之二。
张量分解的思路是把这种结构写成“低秩”。麻烦在于“张量秩”不是一个唯一的概念:CP 秩定义为把张量写成最少多少个秩一张量之和,Tucker 秩(n-rank)是各模态展开矩阵秩组成的向量,tubal 秩则来自 t-SVD,把张量沿第三模态做傅里叶变换后在频域逐片看矩阵秩。三种定义对应三套算法,也对应三种不同的去噪行为:CP 分解去噪能力强但秩选择极敏感、容易在迭代中退化;Tucker 分解灵活、可用 HOSVD 快速初始化,但秩向量要人工定;t-SVD 那套在彩色图像和高光谱上表现最稳,因为 FFT 沿通道维做,天然把通道相关性收进了频域。
我一般的判断标准是:通道数小于 8、且追求“改起来简单”,用 Tucker 或展开矩阵核范数之和;通道数几十上百、数据本身有平移不变结构,用 t-SVD 的张量核范数。这套代码的主体是后者,所以你会在源码里看到大量fft(..., [], 3)和ifft(..., [], 3),那不是随便写的,那是 t-乘积的定义所在。
2.2 秩函数非凸,为什么换成核范数就能解
直接最小化秩是 NP-hard,这一点矩阵和张量都一样。矩阵情形有现成的答案:核范数(奇异值之和)是秩函数在谱范数单位球上的凸包络,于是min rank(X)松弛成min ||X||_*,可以用 SVD 加软阈值精确求解近端算子。张量情形没有这么幸运——CP 秩的凸包络没有可计算的形式,所以工程上只能退而求其次,选一个“凸、且近端算子能算”的替代品。
展开矩阵核范数之和(SNN)是最容易想到的替代:把张量沿每个模态展开成矩阵,各算一次核范数再加权求和。它显然是凸的,近端算子需要对每个模态做一次 SVD,实现简单,但缺点是把模态之间的耦合关系拆散了。t-SVD 版张量核范数(TNN)换了个思路:沿第三模态做 FFT,得到 n3 个前向切片,把每个切片的奇异值全部加起来取平均。这个量仍然是凸的,而且近端算子只需要 n3 次矩阵 SVD,计算量和 SNN 同阶,却保留了通道维的全局信息。
于是去噪问题写成:
min_X 0.5 * ||X - Y||_F^2 + lambda * ||X||_TNN第一项是数据保真,保证结果别离观测太远;第二项是低秩约束,把噪声对应的那些小奇异值压掉。lambda 就是这两项的兑换率,它决定了“多相信观测”和“多相信低秩先验”。这个目标函数是强凸的(数据项是严格凸的二次型),所以解唯一,不存在 CP 分解那种初值依赖和局部极小的问题——这也是我在这类任务上更愿意用凸模型而不是交替最小二乘的原因:结果可复现,换个人跑出来是同一张图。
2.3 展开-折叠算子:把 n 模运算写成能跑的矩阵乘
不管你走 SNN 分支还是要在预处理里做 HOSVD 初始化,第一个要写对的工具函数都是模态展开与折叠。MATLAB 没有内置的unfold,得自己按 permute + reshape 拼。这段代码几乎每套张量代码包里都有,但十个里有三个写错,错的地方还都在逆置换上。
function Xk = unfold_mode(X, k) % 将张量 X 沿第 k 模展开成矩阵,行数为 size(X,k) n = size(X); order = [k, 1:k-1, k+1:ndims(X)]; % 把第 k 模挪到最前面 Xp = permute(X, order); Xk = reshape(Xp, n(k), []); % 其余模态按原顺序摊平 end function X = fold_mode(Xk, k, n) % unfold_mode 的逆运算,n 是原始张量的尺寸向量 order = [k, 1:k-1, k+1:numel(n)]; Xp = reshape(Xk, [n(k), n(order(2:end))]); inv_order(order) = 1:numel(order); % 关键:构造逆置换 X = permute(Xp, inv_order); end逻辑说明:permute负责把目标模态搬到第一维,reshape负责把剩下所有维度按列优先顺序摊成一列,两步合起来就是标准的 k 模展开。折叠时必须先把矩阵 reshape 回“置换后”的尺寸顺序,再用逆置换permute回去。inv_order(order) = 1:numel(order)这行是核心,它保证inv_order(order(i)) == i,也就是置换和逆置换互为反函数。
参数说明:k的取值范围是 1 到 ndims(X),注意 MATLAB 里末尾长度为 1 的维度会被size省略(比如 4×5×1 的size返回[4 5]),所以传n的时候要显式补齐,别直接用size(X)。写完立刻验证一遍,别等到主循环里才发现维度错位:
X = randn(4,5,3); for k = 1:3 err = norm(fold_mode(unfold_mode(X,k), k, size(X)) - X, 'fro'); fprintf('mode %d 往返误差 %.3e\n', k, err); assert(err < 1e-12); enderr应该是 0 或者 1e-16 量级。如果跑出 0.1 以上的数,八成是fold_mode里n(order(2:end))的顺序和unfold里的order不一致,或者把reshape的维度参数写成了[n(order(2:end)), n(k)]——那是转置,不是折叠。这种错误不会报错,只会让 PSNR 莫名其妙低两三个 dB,属于最难查的一类。
3. 把仓库跑通:环境、入口脚本与四个必须调的参数
3.1 依赖的最小集合与版本检查
先把“必须装什么”这件事说清楚,省得你打开脚本看到一堆undefined function就以为包坏了。去噪主循环真正依赖的内置函数只有fft、ifft、svd、reshape、permute、norm,这些在基础 MATLAB 里全有,不需要任何工具箱。真正需要 Image Processing Toolbox 的只有外围:imread读图、psnr/ssim算指标、fspecial造滤波核。也就是说,你想把算法接进自己的流水线,完全可以把这几处换成自己的实现。
版本上,建议 R2018b 及以上。原因很实际:老版本不支持隐式扩展,代码里Y + sigma*randn(size(Y))这种写法没问题,但一旦出现矩阵减向量广播就会报错;另外 R2020b 之后有pagemtimes,如果你的数据通道数上百、想批量化处理,改写成pagemtimes能快一截。开跑之前花三十秒做一次自检:
function report_env() v = ver('matlab'); fprintf('MATLAB %s (%s)\n', v.Version, v.Release); need = {'fft','ifft','svd','imread','psnr','ssim','fspecial'}; for i = 1:numel(need) if exist(need{i}, 'file') ~= 0 || exist(need{i}, 'builtin') ~= 0 s = '可用'; else s = '缺失'; end fprintf('%-10s : %s\n', need{i}, s); end endexist(name,'file')返回 2 表示找到了 .m 文件,exist(name,'builtin')返回 5 表示内置函数,两个都试一遍才不会漏判。跑出来如果psnr、ssim、fspecial三行是“缺失”,说明没装图像工具箱,算法能跑但指标得自己算,第 4 章给了替代实现。
3.2 从入口脚本读起:调用链与数据流
拿到一个陌生的代码包,我的习惯是先找最短的那个脚本——通常叫demo、main或run_*,从它开始顺着调用往下读,而不是一上来就啃求解器。这套代码的调用链大致是四层:入口脚本负责读图加噪和配置参数,求解器负责 ADMM 主循环,近端算子负责 SVD 软阈值,工具函数负责张量展开与范数计算。数据流也非常线性,你只要盯住三个变量就够了:Y(观测)、X(当前估计)、Z(低秩约束的辅助变量)。
% run_denoise.m —— 入口脚本的最小骨架 addpath(genpath(fullfile(pwd, 'src'))); % 把子目录一次性加进路径 rng(2024); % 固定随机种子,保证可复现 cfg = struct('sigma', 25/255, ... % 噪声标准差,已归一化 'lambda', 0.06, ... % 正则权重 'beta', 0.5, ... % ADMM 罚参数 'maxIter', 300, ... 'tol', 1e-5); I = im2double(imread('peppers.png')); % 统一转 double,范围 [0,1] assert(ndims(I) == 3 && size(I,3) >= 2, '输入必须是三阶张量'); Y = min(max(I + cfg.sigma*randn(size(I)), 0), 1); [X, info] = tensor_denoise_admm(Y, cfg.lambda, cfg); fprintf('PSNR %.2f -> %.2f dB,迭代 %d 次\n', ... my_psnr(Y, I, 1), my_psnr(X, I, 1), info.iters);这段骨架里有两个地方值得特别注意。一是addpath(genpath(...))只加当前工作目录下的src,如果你在别的目录里双击运行,pwd会变,路径就断了,稳妥写法是mfilename('fullpath')取脚本自身位置再拼。二是那句assert,看着多余,其实是拦住 90% 新手问题的闸门——单通道灰度图、四通道 RGBA、被imread读成 uint8 的图,全靠这一行提前暴露。运行中想追某一步,用dbstop if error配合dbup看调用栈,比满屏disp干净得多。
3.3 参数表:λ、β、迭代上限与停止准则
这套代码真正需要你动的参数不超过六个,剩下的都能用默认值跑出可看的结果。但每一个调错都有明确的症状,认识症状比记住数值有用。
| 参数 | 物理含义 | 取值建议 | 调错后的典型症状 |
|---|---|---|---|
lambda | 低秩项权重,越大越平滑 | 与噪声相关,σ=25/255 时约 0.04~0.08 | 太小去噪不足、噪点仍在;太大细节糊成一片,边缘发蜡 |
beta | ADMM 罚参数,控制约束收敛速度 | 0.3~2,通常取 0.5 起步 | 太小残差下降极慢;太大前期震荡、目标函数非单调 |
maxIter | 迭代上限 | 200~500 | 设太小结果还在变,PSNR 未收敛 |
tol | 相对残差停止阈值 | 1e-5~1e-4 | 太松提前退出;太紧白跑几十轮,指标只动 0.01dB |
betaMax | 自适应 β 的上限 | 10~50 | 不设上限时 β 一路飙升,后期更新几乎冻结 |
rankBound | 仅 SNN/截断分支用的秩上界 | 不超过 min(n1,n2)×0.3 | 设太大等于没约束;设太小直接欠拟合,图像塌成色块 |
lambda是唯一需要按数据调的。经验做法是先估噪声水平再定它,估法用高频残差的中值绝对偏差,对高斯噪声稳健:
function s = est_sigma(Y) % 用一阶差分当高通,MAD 估噪声标准差(已归一化到 [0,1]) d = diff(Y, 1, 3); % 沿通道维差分,抹掉平滑的信号成分 s = median(abs(d(:))) / 0.6745; % 0.6745 是高斯分布的中值绝对偏差系数 end拿到s之后,lambda从0.5*s到2*s之间扫五六个点就够,别做网格搜索,收益和耗时不成比例。至于beta,如果残差曲线在前 50 轮内下降不到两个数量级,先别怀疑算法,把beta乘 2 再跑一次,通常就通了。
3.4 一次完整的运行记录与输出解读
跑通之后控制台会打出类似下面这样的东西。这段输出比最终 PSNR 更有信息量,建议每次都留着。
[init ] size = 512 x 512 x 3, sigma_hat = 0.0491 [admm ] iter 20 res 3.12e-02 obj 1.842e+03 beta 0.50 [admm ] iter 60 res 8.71e-03 obj 1.418e+03 beta 0.75 [admm ] iter 120 res 1.94e-03 obj 1.296e+03 beta 1.13 [admm ] iter 187 res 9.02e-06 obj 1.284e+03 beta 1.13 [done ] iters = 187, time = 12.4s PSNR 20.16 -> 31.42 dBres是原始残差||X - Z||_F / ||X||_F,它必须单调下降到tol以下;如果它在 1e-2 附近来回横跳,那多半是beta和lambda的比例失衡,或者近端算子的阈值用错了。obj是目标函数值,理论上一路非增,偶尔因为自适应 β 有微小上跳是正常的,连续上跳就是实现有问题。最右边beta一列记录了自适应策略的生效过程,从 0.5 涨到 1.13 就停了,说明残差比值没有持续触发放大条件,属于健康状态。
“187 轮收敛”这个数字也值得记一下:如果你的数据跑出 300 轮还没停,先看res的末值,如果停在 1e-3 量级不动,说明tol设得过严或者精度已经到浮点极限,把tol放宽到 1e-4 即可,别硬堆迭代数。
4. 复现一组对比实验:加噪、求解、评估的可抄流程
4.1 加噪口径必须和评估口径对齐
同一套代码,两个人跑出来的 PSNR 差 3dB,八成不是算法差异,而是加噪和评估的口径不一样。这里有三个细节必须钉死:动态范围、噪声生成的时机、以及是否裁剪。im2double把 uint8 映射到 [0,1],此时sigma必须写成25/255而不是25;如果你偷懒直接I + 25*randn(...),整张图会变成一片饱和白,PSNR 反而“很好看”,因为两张图都被裁成了全 1。
function [Y, I] = make_noisy(path, sigma_pix, seed) % 统一加噪流程:读图 -> 归一化 -> 固定种子加噪 -> 裁剪 if nargin < 3, seed = 2024; end rng(seed); % 每次生成同一份噪声 I = im2double(imread(path)); % uint8 -> double, [0,1] sigma = sigma_pix / 255; % 像素级 σ 换算成归一化尺度 Y = I + sigma * randn(size(I)); Y = min(max(Y, 0), 1); % 裁剪回合法范围 end逻辑说明:rng(seed)放在加噪之前而不是脚本开头,是为了保证不管前面做了多少次随机初始化,噪声张量本身都一样,这样不同方法拿到的是同一份噪声,对比才公平。参数说明:sigma_pix用像素单位(15、25、50 这些常见档位),函数内部统一换算;裁剪那一步不能省,不裁剪会导致后续 SVD 在超出范围的值上浪费秩,也会让 PSNR 计算失去意义。
还有一个常被忽略的点:评估时用哪张图做参考。必须用裁剪前的干净图I做参考,而不是裁剪后的Y与X比。有些人图省事拿Y当参考算“改进量”,那个数只能看趋势,不能和文献里的绝对值放一起比。
4.2 ADMM 主循环:三段更新逐行拆
整套算法的核心就是下面四十行。ADMM 的套路是引入辅助变量Z把目标拆成两块:一块是二次型(好求导,有闭式解),一块是核范数(近端算子能精确算),然后用对偶变量U把两者拴在一起。
function [X, info] = tensor_denoise_admm(Y, lambda, opts) % 求解 min_X 0.5*||X-Y||_F^2 + lambda*||X||_TNN % Y : n1 x n2 x n3 含噪张量,double,范围 [0,1] % lambda : 正则权重 % opts : 结构体,字段见下方默认值 if nargin < 3, opts = struct(); end def = struct('maxIter',300,'tol',1e-5,'beta',0.5,'betaMax',20,'verbose',true); fn = fieldnames(def); for i = 1:numel(fn) if ~isfield(opts, fn{i}), opts.(fn{i}) = def.(fn{i}); end end X = Y; Z = Y; U = zeros(size(Y)); res = zeros(opts.maxIter, 1); beta = opts.beta; for k = 1:opts.maxIter Xold = X; % --- 1) X 更新:二次项 + 增广项,直接求导得闭式解 --- X = (Y + beta * (Z - U)) / (1 + beta); % --- 2) Z 更新:张量核范数的近端算子,即 t-SVT --- Z = tsvt_tnn(X + U, lambda / beta); % --- 3) 对偶变量更新(缩放形式) --- U = U + X - Z; % --- 4) 残差、自适应 beta、停止判据 --- res(k) = norm(X(:) - Z(:)) / max(norm(X(:)), eps); if k > 1 && res(k) > 10 * res(k-1) && beta < opts.betaMax beta = min(beta * 1.5, opts.betaMax); U = U / 1.5; % 罚参数变了,对偶变量要同步缩放 end if opts.verbose && mod(k, 20) == 0 fprintf('[admm] iter %4d res %.2e beta %.2f\n', k, res(k), beta); end if res(k) < opts.tol && ... norm(X(:)-Xold(:)) / max(norm(Xold(:)), eps) < opts.tol break; end end info.iters = k; info.res = res(1:k); info.beta = beta; end逻辑说明:第 1 步对0.5||X-Y||² + (β/2)||X-Z+U||²求导令其为零,得到那个除以(1+β)的闭式解;第 2 步把λ||Z||_TNN + (β/2)||X-Z+U||²化成近端算子prox_{λ/β · ||·||_TNN}(X+U);第 3 步是最普通的对偶上升。自适应 β 那段有个容易漏的细节——beta改了之后U必须除以同一个倍数,因为缩放形式下对偶变量和 β 是绑定关系,不缩放会让约束项瞬间失衡,残差曲线直接翘起来。
参数说明:beta的初值取 0.5 是保守选择,betaMax卡在 20 是为了防止后期更新步长过小导致结果冻结;res用相对残差而不是绝对残差,是因为绝对残差随图像尺寸线性增长,换张 1024×1024 的图阈值就全废了。停止判据用了双条件,原残差和对偶残差都要小于tol,只看一个容易出现“残差小但结果还在漂”的假收敛。
近端算子单独一个文件,看着短,坑全在阈值上:
function Z = tsvt_tnn(Z0, tau) % t-SVT:张量核范数的近端算子 % Z0 : 输入张量;tau : 阈值(等于 lambda/beta) [n1, n2, n3] = size(Z0); Zf = fft(Z0, [], 3); % 沿第 3 模做 FFT,进入频域 for i = 1:n3 [Uu, S, Vv] = svd(Zf(:,:,i), 'econ'); s = diag(S); if i == 1 t = tau; % 第 1 片用 tau else t = tau / n3; % 其余片用 tau/n3 end s = max(s - t, 0); % 奇异值软阈值 Zf(:,:,i) = Uu * diag(s) * Vv'; end Z = real(ifft(Zf, [], 3)); % 回到空域,虚部理论上为 0 endi == 1那个分支是整份代码里最容易被“顺手优化”掉的地方。有人觉得三行太啰嗦,直接把阈值统一成tau,代码是短了,结果整体偏暗、边缘发糊,PSNR 掉两三个 dB,而且不会报任何错。原因在 t-乘积的定义:频域第 1 片对应的是直流分量,它的奇异值尺度和其他片不一样,TNN 的近端解里只有它用tau,其余片都要除以n3。同理,最后的real()不是可选项,ifft会留下 1e-17 量级的虚部,不取实部的话后面norm和psnr都会因为复数输入报错。
4.3 评估:没有图像工具箱也能算 PSNR 和 SSIM
很多人的 MATLAB 没装图像工具箱,跑到评估那行就断了。两个指标都能手写,PSNR 三行就够,SSIM 稍长但也不复杂。
function p = my_psnr(A, B, peak) % 峰值信噪比,peak 为动态范围上限(归一化数据取 1) mse = mean((A(:) - B(:)).^2); if mse == 0, p = Inf; return; end p = 10 * log10(peak^2 / mse); end function s = my_ssim(A, B, L) % 简化版 SSIM:11x11 均匀窗,单通道输入 K = ones(11) / 121; muA = convn(A, K, 'valid'); muB = convn(B, K, 'valid'); sAA = convn(A.^2, K, 'valid') - muA.^2; sBB = convn(B.^2, K, 'valid') - muB.^2; sAB = convn(A.*B, K, 'valid') - muA.*muB; C1 = (0.01*L)^2; C2 = (0.03*L)^2; s = mean(((2*muA.*muB + C1) .* (2*sAB + C2)) ./ ... ((muA.^2 + muB.^2 + C1) .* (sAA + sBB + C2)), 'all'); endmy_psnr里那个mse == 0的提前返回不是洁癖,是防止log10(0)返回-Inf污染后续统计。my_ssim用均匀窗而不是高斯窗,算出来的值会比工具箱低 0.001~0.003,做方法间横向对比完全够用,但别拿去和论文表格里的绝对值逐位对。彩色图要先降到单通道再算 SSIM,用mean(X, 3)就行,别用rgb2gray,那个又是工具箱函数。
有一点必须提醒:算指标之前先确认两张图范围一致。如果你的X因为阈值处理跑出了 [0,1] 区间(比如最小值是 -1e-4),my_psnr会算出比实际低零点几 dB 的值。加一句X = min(max(X,0),1)的成本几乎为零。
4.4 批量参数扫描与结果落盘
单张图跑通之后,下一步一定是扫lambda。别在脚本里手改数字重跑,写个循环,把结果存下来,事后再画曲线。
[Y, I] = make_noisy('peppers.png', 25, 2024); lambdas = [0.02 0.04 0.06 0.08 0.10 0.12]; res = zeros(numel(lambdas), 4); for i = 1:numel(lambdas) opts = struct('maxIter', 300, 'tol', 1e-5, 'verbose', false); t0 = tic; X = tensor_denoise_admm(Y, lambdas(i), opts); X = min(max(X, 0), 1); res(i,:) = [lambdas(i), my_psnr(X, I, 1), ... my_ssim(mean(X,3), mean(I,3), 1), toc(t0)]; end fprintf('%8s %8s %8s %8s\n', 'lambda', 'PSNR', 'SSIM', 'time'); fprintf('%8.3f %8.2f %8.4f %8.1f\n', res'); save('sweep_lambda.mat', 'res', 'lambdas');verbose关了是因为扫描时满屏日志反而看不清;toc记进第四列,是为了判断“多花 30 秒换 0.05dB”值不值。结果存成.mat而不是打印到控制台,因为参数扫描往往要跑一晚上,中途断了不至于从头再来。fprintf那两行的格式串要对齐,res'是转置成列优先,否则打出来是一整行挤在一起。
扫完之后别只看最大值。把res的第二列画出来,你会看到一个很典型的形状:lambda从 0.02 涨到 0.06 时 PSNR 稳步上升,0.06 到 0.08 之间出现平台,再往上就开始掉。平台区的中点通常就是最优解附近,而且平台越宽说明算法对参数越不敏感,这本身就是这套凸模型相对 CP 分解的一个实际优势。
5. 避坑与排查:五条踩过的记录
5.1 数值类问题:阈值、步长与收敛
现象一:结果整体偏暗、边缘发糊,PSNR 比预期低 2~3dB,但程序不报错。原因基本锁定在 t-SVT 的阈值上。把频域所有切片的阈值统一成tau,等于对直流分量过度惩罚,图像的均值被压下去了,视觉上就是“蒙了一层灰”。解决方法是回到 4.2 那段代码,确认i == 1走tau、其余走tau/n3。验证手段很简单:把tau从 0 开始慢慢加大,观察输出与输入的均值和 PSNR,正常情况下 PSNR 应该先升后降,如果从第一个非零点就开始掉,阈值实现一定有问题。
现象二:残差曲线降到 1e-2 附近开始上下横跳,迭代到上限也不收敛。这不是算法不收敛,是 ADMM 的经典毛病:罚参数和正则权重比例失衡。lambda/beta太大时,近端算子把Z压得太狠,X跟不上去,残差就在两者之间来回弹。解决方法是打开自适应 β(4.2 里那段),或者手动把beta加倍重跑,一般 2~4 倍之内就能压下去。另外记得同步缩放对偶变量U,只改beta不改U会让震荡更严重。
现象三:迭代次数正常、残差也收敛,但 PSNR 明显不如自己手算的预期。检查数据类型。imread读进来是 uint8,如果某条分支忘了im2double,svd会先做类型提升再算,结果数量级完全不对;反过来,如果sigma没除以 255,加噪后整张图饱和,PSNR 反而虚高。排查手法:在求解器入口加一行fprintf('range [%.3f, %.3f] class %s\n', min(Y(:)), max(Y(:)), class(Y));,正常应该是[0.000, 1.000] double,不是这个就是口径错了。
5.2 工程类问题:维度、路径与评估
现象四:单通道灰度图或 RGBA 图跑出诡异结果,甚至是全黑。维度假设崩了。整套算法默认第 3 模是通道维且长度大于 1,灰度图size(X,3)返回 1,fft(..., [], 3)退化成恒等变换,svd只对一个切片作用,低秩先验直接失效;RGBA 的第 4 通道(透明度)参与运算更是无意义。解决方法是入口处强制把维度规范成三阶:灰度图repmat成三通道,或者显式reshape(X, [n1, n2, 1])后走 SNN 分支。加一句assert(size(Y,3) >= 2)能把这类问题挡在第一步。
现象五:自己算的 PSNR 比别人高 1dB,但肉眼看图明显更糊。评估口径不一致。最常见的三种:算指标前没裁剪导致参考图和结果图动态范围不同;拿加噪图当参考而不是干净图;把不同方法的输出用不同的后处理(一个做了 round 一个没做)。解决办法是写一个统一的评估函数,所有方法的结果都走同一个入口,函数内部先min(max(X,0),1)再算。另外别拿psnr的默认peak——老版本默认按 255 算,你传 [0,1] 的数据进去会得到 48dB 这种明显不合理的数,务必显式传峰值 1。
6. 进阶:用一个验证闭环判断这套代码值不值得留
代码能跑通和代码可信,是两件事。我接手任何一套张量去噪实现,都会花二十分钟做一次“先降质再复原”的自检,通过了才敢往真实数据上铺。
自检的核心是构造一个已知低秩的合成张量,加噪后看求解器能不能把它恢复回来。比如用两个随机矩阵外积生成一个 tubal 秩为 2 的三阶张量,加 σ=0.05 的噪声,然后跑求解器。如果算法实现正确,恢复出的张量相对误差应该随lambda在合理区间内单调下降;如果实现里张量展开或阈值有错,误差曲线会是一条平的乱线,或者干脆发散。这套判断比看 PSNR 靠谱得多,因为 PSNR 里混进了自然图像的统计特性,而合成张量的真值你完全掌握。
| 验证项 | 通过判据 | 常见失败原因 |
|---|---|---|
| 展开/折叠往返 | 三个模态的往返误差均 < 1e-12 | 逆置换构造错、reshape维度顺序反了 |
| 合成低秩恢复 | 相对误差 < 0.05,且随 λ 单调 | t-SVT 阈值错、对偶变量未同步缩放 |
| 目标函数单调性 | 全程非增,允许 ≤ 2 次微小上跳 | 自适应 β 后未缩放 U、步长写错 |
| 残差双判据 | 原残差与相对变化量同时 < tol | 只判其一,出现假收敛 |
| 极值退化 | sigma=0时输出应近似等于输入 | 保真项系数写错,或裁剪位置不对 |
第一行那个往返误差我吃过亏。有次重写fold_mode图省事,把reshape的维度参数顺序调了一下,往返误差 0.3 看着不大,但主循环跑了 300 轮不收敛,查了三小时才定位到。从那以后我每次改张量工具函数,都强制走一遍往返断言,宁可多写两行assert,也别把 bug 带进主循环。第二行的单调性检验也有用,它能区分“参数没调好”和“实现有错”——前者是曲线整体偏移,后者是曲线形状不对。
最后一件事:把自检脚本固化下来,和数据预处理放在同一个目录,每次改动求解器代码就重跑一次。这套流程跑顺了,你再去改lambda、换数据、接自己的流水线,心里是有底的。这套基于凸优化的张量分解去噪代码,价值不在于它能给你多少 dB,而在于它的每一步都可验证、可解释、可回退——这在你需要向别人证明“我的结果不是我调出来的”时,比什么都重要。希望帮到你。
本文还有配套的精品资源,点击获取