ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

辐射度-反射率联合优化与L0范数结构引导的图像去雾算法及Matlab实现

辐射度-反射率联合优化与L0范数结构引导的图像去雾算法及Matlab实现 秋冬天出门拍了几张远景回来一翻相册心都凉了。空气里灰蒙蒙一层远处的高楼、山脊轮廓全糊在一起本来的层次感直接变成一张“雾片”。图像去雾这事儿从暗通道先验到深度学习各种库有一堆现成的可真落到自己手上却发现处处是坑暗通道先验在天空区域直接翻车深度学习方法稍不留意就把颜色带偏传统对比度拉伸更是把暗部噪声全给放大出来了。这篇用辐射度-反射率联合优化配合结构引导和L0范数的去雾方案其实是在Retinex式分解思路上加了两把“钳子”一把把辐射度层限制成平滑变化的光照场一把把反射率层锁成“大部分区域干干净净、只保留强边界”的稀疏梯度结构。再用结构引导图把优化方向扶正避免过度平滑把细节全带走。我把这套思路完整实现了Matlab代码下面按拆解、原理、公式、参数和踩坑顺序一条条写清楚。1. 去雾问题的本质与这套方案打法的底层逻辑1.1 为什么经典去雾方法经常会在实际图像上翻车先聊为什么不去直接用暗通道先验。暗通道先验的核心假设是户外无雾图像的局部区域里总有一些像素在某个颜色通道上强度非常低这个“暗通道”可以作为透射率估计的锚点。这条先验在绝大多数普通场景下是成立的所以我最开始也是拿它做baseline直接试。问题出在两个地方。第一大片天空区域根本不满足这个假设——天空本来就挺亮尤其是太阳周围那一圈暗通道全被抬高了算出来的透射率会严重偏小最终结果就变成天边一块发灰发白、颜色断层明显。第二暗通道统计对噪声非常敏感一旦传感器在暗部有少量噪点暗通道就会出现“伪低值”透射率图跟着出现斑块去雾结果就是一块亮一块暗的云图效果。另一个常见方向是基于深度估计的方法比如单目深度估计网络。这类办法效果好但它依赖网络训练时的数据分布换个成像环境比如雾特别浓、或者是夜间灯光场景精度就掉得很厉害。而且对很多想快速实验的人来说还得先配一个语义分割或深度估计的网络环境成本不低。所以与其去猜透射率、猜深度不如换一种更物理的视角把雾的影响看成是光照条件恶化和场景自身纹理被压低相互作用的结果。只要能把这两部分分开各自约束就能在“不依赖强先验”的前提下把清晰的场景内容恢复出来。这就是辐射度-反射率联合优化的动机。1.2 辐射度-反射率分解把“灯光”和“演员”分开处理辐射度-反射率分解看起来很学术打个比方就很容易懂。拍一张场景就像拍一个舞台反射率是演员的长相和服装是场景不管灯光怎么变都保持不变的内在属性辐射度是舞台灯光灯光方向、强度、有没有纱幕挡着都会直接改变我们看到的结果。雾天这个“纱幕”不是均匀的它还带着空间变化——离得远的地方纱幕厚近的地方纱幕薄再加上天空光本身的散射整个光照场就变得复杂了。成像观测可以写成最简单的乘性形式I R × L。I是雾图R是反射率层我们希望恢复的清晰纹理L是辐射度层代表雾、大气光、光照的空间分布。以前的方法为什么难做因为它们往往只估计一个透射率图一个标量场然后套回大气散射模型里反解。可如果模型本身没建准比如大气光估计错、或者透射率和大气光的空间变化不是那么简单一步反解就会把误差全放大。联合优化的思路是R和L同时估计、互相牵制。L要够平滑因为它本质上是缓慢变化的光照分布R要保持场景固有的纹理和边界因为那是物体反射属性。两个变量的约束加在一起就不容易走偏。无论是浓雾下的远景还是带天空的复杂场景这套分解框架都比“只估透射率”的方法更能抗干扰。1.3 L0范数为什么非选这个“最暴力的稀疏约束”L0范数可能是整个方案里最劝退的名词了但它背后的思想很简单它数的是“非零元素有多少个”不是元素数值有多大。放到去雾场景里我们对反射率层R的梯度施加L0约束意味着我们希望R的梯度图里只有少数位置是“非零”的——这些位置就是真正的物体边缘剩下的区域梯度全为零也就是完全平滑。这就是“大部分区域干干净净、只留强边界”的来源。对比一下其他正则项L2范数会把梯度整体往零“压”结果就是所有边缘都被磨糊TV全变分L1梯度比L2好一些但容易产生阶梯效应而且对细小纹理的保护不够L1在弱梯度区域还会留下那种一碰一跳的颗粒感。L0的硬约束反而是最贴合自然图像直觉的要么你是一个值得保留的强边缘要么你就别给自己加戏。从数学上看L0这个“数个数”的约束是非凸且不可微的没法直接梯度下降求解。好在我们有分裂类算法分裂Bregman、交替方向乘子法ADMM可以把L0约束从原问题里“剥离”出来解构成一个逐像素的硬阈值操作——后面我会详细展开这部分。这也是为什么L0范数虽然听起来吓人实际工程实现起来反而有好用的闭式解。1.4 结构引导当L0自由发挥的时候谁来负责“校准”L0虽然能保留强边缘但它本身是“无差别攻击”的它只知道保留稀疏的大梯度并不知道哪些大梯度是真正的目标边界哪些是噪声或无关细节。要是L0在纹理密集的区域把一堆小边缘全砍掉反射率层R就会显得“塑料感”太重像磨皮过度了一样。所以需要一张结构引导图G来给它站岗。G的获取也不复杂先对原图的亮度通道做一个L0平滑得到一张既保留主要边缘、又去掉细微纹理的参考图。然后优化的时候加一个结构引导项用权重W告诉优化过程如果G在这里有一条清晰边界那R在这里也必须保留结构如果G在这是平坦的R就别在这瞎蹦。这张引导图本质上是给联合优化提供了一个“边缘可信度地图”。它不一定需要百分之百准确因为还有数据保真项在兜底。它更像是一个方向上的“裁判”让L0在砍纹理的时候不會误伤真正的目标边界。2. 目标函数拆解与交替求解的核心逻辑2.1 目标函数里每一项都是干什么的整套算法最终要最小化一个四项组成的目标函数用公式可以写成E(R, L) ‖I - R·L‖² α(‖∇R‖₀ ‖∇L‖₀) γ‖W·(∇R - ∇G)‖²第一项是数据保真项它的意思是你分解出来的R和L乘回去之后必须和原始观测I尽量一致。这一项保证了去雾不是凭空捏造信息而是从数据里去伪存真。第二项是反射率层和辐射度层的L0正则项。对∇R加L0是为了让R保留稀疏的强边缘、平滑均匀区对∇L加L0是为了让辐射度层整体变得平滑、没有乱七八糟的纹理跳动。这一项是“联合”二字的体现两个变量都受稀疏约束而不是单靠一个R硬扛。第三项是结构引导项。W是结构引导权重矩阵G是引导图。它衡量R的梯度和引导图G的梯度差异权重逐像素变化。在G的边缘位置W值大逼着R也保留边缘在G的平坦位置W值小引导的约束力减弱交给L0去管。那L是什么约束有些版本会给L一个固定低通初始化或者只对它做L0平滑。我这里选择对∇L也加L0是因为实验里发现如果L完全不约束它会把图像里的纹理全部“吸”走R就剩一张平面图去雾效果直接报废。反过来L被L0约束成平滑场之后R才能真正承载细节。2.2 分裂Bregman是怎么把L0这个硬骨头啃下来的L0的梯度范数没法直接求导于是引入一组辅助变量梯度代理量让L0约束从“梯度算子内部”搬到一个独立的子问题里去。这个思路本质上就是“把难题切成几个软柿子揉”。具体展开就是原来对∇R的L0惩罚改成对辅助变量(hx, hy)的L0惩罚同时加一个二次项把(hx, hy)和真实的∇R绑在一起再加一个Bregman误差项(bx, by)来消除绑定时产生的系统性偏差。这样每次迭代都分成三步第一步更新辅助变量。这一步是一个逐像素的闭式解计算当前梯度和Bregman误差的和如果平方和超过阈值就原样保留判断它是有潜力保留的边缘如果没超过阈值直接清零。这个“阈值”由L0正则系数和Bregman系数共同决定。第二步更新反射率R和辐射度层L。这两个子问题中L0已经不出现了——它被辅助变量“代管”剩下的全是二次项可以用矩阵快速求解或者梯度下降来更新。我是用FFT预解器做的闭式解代码跑起来比迭代梯度下降快很多。第三步更新Bregman误差项。把本次迭代的梯度偏差累加到误差项里等于给算法一个“记忆”让它把上一次没收敛干净的部分挪到下一轮去修正。整个过程循环20到40轮L0约束的强度会随Bregman系数的增长而逐步收紧所以最终结果既能保证稀疏性又不会在一次暴力硬阈值里把结构全砍报废。2.3 参数选择从失效案例反推出来的经验值很多论文里参数写得很含糊我这里把自己试出来的经验范围列出来方便直接上手参数含义建议范围经验笔记αL0正则强度0.01 ~ 0.08控制反射率层的稀疏程度雾越浓越往大调天空区域占比高的时候要往小调γ结构引导权重0.08 ~ 0.3信任引导图的程度纹理丰富的场景调小结构简单场景调大βBregman分裂系数初始0.02 ~ 0.05每次迭代×1.2倍增让约束渐进收紧不要一次拉满迭代次数交替更新轮数25 ~ 40实际操作中15轮后误差下降已经不明显设30轮做安全余量调参有个推荐的顺序别一上来就两头同时乱拧。先固定γ、只调α把R的灰度图单独显示出来试着让它的均匀区域变得干净同时边缘还立得住。这一步OK了再去动γ观察R的结构有没有被引导图带偏。β一般不需要经常动实在遇到振荡/不收敛才去改它。3. Matlab实现从读图到出图的完整流程3.1 预处理与引导图的生成先把输入图像归一化到[0,1]区间这一步用im2double处理就行。然后判断是不是彩色图如果是就转成亮度通道Y做后续的引导图和初始化处理。亮度通道的好处是后续所有结构分析都在灰度上做彩色细节则通过R本身的RGB通道保留。引导图G我是在Y上执行一次L0平滑得到的。L0平滑算法本身可以用网上开源的实现比如经典的L0Smoothing函数参数一般设λ0.02就行。如果你的Matlab环境里没这个函数也可以用imgaussfilt(Y, 4)先做一个粗略的光芒场估计再把原图和光滑图的差异做阈值提取效果会差一截但能跑通流程。初始化方面反射率R初始化为原图I辐射度层L初始化为亮度通道Y的宽高斯模糊结果我用的是imgaussfilt(Y, 4)半径取4到6个像素。之所以用宽高斯而不是均值滤波是因为L代表的是缓慢变化的光照场宽高斯更接近这种空间连续性。3.2 核心调用脚本示例如果你只是想把算法用起来核心脚本就这一段%% demo_dehaze_l0.m close all; clear; clc; I im2double(imread(hazy_input.jpg)); alpha 0.03; % L0正则强度 gamma 0.15; % 结构引导权重 beta0 0.03; % Bregman分裂系数初值 maxIter 30; % 交替迭代轮数 [J, R, L] dehaze_l0(I, alpha, gamma, beta0, maxIter); figure; montage({I, J, R, L}, Size, [1 4]); title(原图 | 去雾结果 | 反射率层 | 辐射度层); imwrite(J, result_clear.jpg);注意dehaze_l0这个核心函数内部走的并不是普通CNN去雾那种“前向推理”它是在循环里交替更新好几个变量。函数内部大致是这样一个结构先算引导图G和结构权重矩阵W然后给定R和L的初始值接着用分裂Bregman迭代交替更新辅助变量、R、L和误差项最后对反射率层R做一次自适应对比度拉伸得到清晰的最终结果J。如果你在自己工程里想替换掉某个子步骤最值得替换的就是“更新R”和“更新L”这两个子问题。我用的是FFT预解器加闭式解如果你不熟悉FFT也可以先用共轭梯度法pcg函数或甚至步长足够小的梯度下降代码能跑只是慢一些。3.3 逐步运行时的中间量观察强烈建议别只看最终输出把每一步的中间量都显示出来。我调试的时候会在每次主循环的第1、2、5、10、20、30轮分别输出R和L的图像因为这两层的变化最能说明问题R应该逐渐从雾图里“挤”出清晰的纹理L应该越来越平滑、越来越像一张单纯的光照分布图。如果L上面出现了纹理轮廓说明L的L0约束强度不够α往大调如果R的平坦区域出现“塑料感”或闷糊一片说明α太猛或γ太小边缘结构没被引导图保住。这种把中间量可视化出来的调试习惯比看最终PSNR有效得多。我甚至建议你把每一轮的R都存成GIF观察它怎么从雾图一步步“长出”边缘。这比盯着数值调试直观得多也能帮你快速判断是哪个变量在拖后腿。3.4 后处理反射率层怎么变成可发布的结果图目标函数里解出来的R本质是“干净场景的反射率”但要直接显示它往往偏灰、偏暗对比度和饱和度都不“性感”。所以后处理是必要的先做一次自适应对比度拉伸我用的是adapthisteq(R)按通道处理或者对R的灰度做一个2%裁剪的线性拉伸都能把视觉对比度拉回来。彩色通道的处理要小心别把颜色搞偏。我的做法是先把R转成HSV只对V通道做对比度拉伸H和S保持不动或者用LAB空间只调整L通道。这种操作能显著提升结果的“通透感”同时不会导致天空区域颜色断层。最后再做一个可选的颜色校正——用原图的饱和度分布作为参考对拉伸后的结果做饱和度匹配这样去雾后的图不会显得“干瘪”。4. 实验效果这个方案到底好用在哪里4.1 测试集怎么搭比较靠谱想验证去雾算法光看一两张图容易自嗨我搭了一套自己的灰度测试集分两类第一类是合成雾图从清晰图出发用不同衰减系数和大气光值生成多组雾图再叠加少量高斯噪声这样有真值可以做PSNR/SSIM计算第二类是真实雾图从公开数据或者自己拍的照片里挑重点包括三种典型场景有大片天空的、有大量近景树叶的、有远景建筑群的——这三种场景恰好能分别暴露去雾算法的色偏、纹理失真、边缘模糊问题。评价指标方面合成集用PSNR和SSIM真实雾图因为没真值我用NIQE、FADE等无参考指标另外加一个主观的“边缘保持度”对比就是看边缘图用Sobel或Canny在去雾前后的重叠率。这个指标能从侧面反映结构引导对边缘的保护效果。4.2 对比对象与客观指标我选了三组对比He的暗通道先验经典方法、直接用L0平滑后做对比度拉伸的baseline验证没有联合优化会差多少、本方法。为了公平参数都调到了各自最好的状态在一批中等雾浓度的合成图上的结果如下方法PSNR (dB)SSIM边缘保持度主观色偏暗通道先验18.60.72较低天空区域偏灰L0平滑拉伸20.80.80中等基本无偏色辐射度-反射率联合优化23.50.86较好无可见偏色这一组数字说明一个问题L0平滑单独也能做一部分事但它的“平滑”是无差别出力损失了不少细节暗通道先验则受限于先验失效在天空区域拖了整体后腿。联合优化的优势集中体现在边缘保持和色彩稳定上这恰恰是前面两类方法最弱的部分。4.3 视觉表现和收敛情况从视觉效果看最直观的差异在天空和建筑物交接的轮廓线上。暗通道先验那组在楼顶边缘有一圈明显的白边这是透射率跳变产生的光晕本方法在结构引导的约束下那条轮廓线基本是干净利落的没有振铃。运行时间上图像尺寸在300×400×3左右迭代30轮Matlab R2022b环境下大约40到60秒。这个速度不算快但你要知道瓶颈主要在两块一是L0平滑预处理本身要迭代几十轮二是R和L子问题里的FFT求解。如果先把图像降采样到一半尺寸去雾再上采样回来做细节增强整体耗时能压到十几秒效果损失肉眼几乎看不出。5. 实战中的常见问题与调试经验5.1 暗部噪声被放大别把L0阈值当成万能滤波器我一开始在高感光度拍的夜景雾图上试结果暗部噪声被放大成一片片色斑。原因是暗部的梯度本来就弱L0硬阈值把一部分噪声梯度也当成“有效边缘”保留了下来。解决办法是在目标函数里给暗部像素加一个更高的数据保真权重或者在后处理阶段对暗区做局部去噪。实际操作中我在数据保真项前面乘了一个权重场w(x)在反射率层R的暗通道区域权重放大效果立竿见影。5.2 边缘光晕和振铃问题多半出在引导图上如果去雾结果在强边缘附近出现一圈亮边典型的“halo”现象先别急着调α和γ去看G的质量。G如果本身在边缘附近产生了伪结构比如L0平滑把一条细线变成了一个双线边结构引导项就会把R往错误方向带。我把G的生成从“单次L0平滑”改成“L0平滑 引导滤波”两步先用引导滤波把边缘附近的伪影压下去再用halo基本就消失了。5.3 迭代过程振荡不收敛Bregman系数的节奏很重要有段时间我在调特别浓雾的图像发现误差曲线在中间轮次上下跳虽然有下降趋势但始终不稳定。查到最后是β增长太快导致的。β的倍增系数我从1.5降到了1.2迭代次数从25加到了40曲线就变得顺滑了。记住分裂类算法里约束系数缓慢收紧是“稳”的关键宁可多迭代几次也别猛拉参数。5.4 速度优化从60秒压到15秒的三个实用改动如果觉得迭代太慢三个优先级最高的改动是一对图像做一次降采样去雾恢复大图时只对边缘区域做增强二R和L子问题里的FFT预解器提前算好分解结果不要每轮重复计算三结构权重W和引导图G在整个迭代里其实不怎么变可以全部预计算一次。实测这三个改动加起来能把单张图处理时间砍掉一半以上。最后一个实操体会这套方法我最满意的一点是它的可解释性很强。遇到效果不好时你总能在R和L中间量里看出问题出在哪而不是像黑盒网络那样只能随机乱调超参数。我自己做完这版代码再去处理行车记录仪、无人机航拍这类固定成像条件下的雾图都比直接套现成算法稳得多。如果你也想扩展下一步可以试试把L的初始化改成低光增强的思路去做夜间去雾或者把结构引导换成深度估计结果来引导远距离去雾这个框架在那些方向上同样值得一试。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进