ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

暗通道去雾算法详解:基于OpenCV和Python的图像去雾实践

暗通道去雾算法详解:基于OpenCV和Python的图像去雾实践 你有没有遇到过这样的情况周末兴冲冲出门拍了一组照片结果赶上雾天远处的山、建筑全埋在一片灰白里后期怎么拉对比度都救不回来。这时候一个叫暗通道去雾的算法就派上用场了。它是计算机视觉里最经典的图像去雾方法之一由何恺明在2009年的CVPR上提出思路简洁、效果惊艳用OpenCV和NumPy几百行就能完整复现。这篇文章我会从物理模型开始讲清楚它为什么有效然后手把手带你把整个算法的每一步用Python写出来最后再分享一些我在实际调参中踩过的坑希望对做图像增强、目标检测前处理、无人机航拍图处理的朋友都有帮助。1. 从雾图说起暗通道先验为什么有效1.1 雾天成像的物理直觉在动手写代码之前得先搞清楚一个问题雾天拍出来的图为什么总觉得灰蒙蒙的这不是相机白平衡的问题而是物理层面的现象。雾是由空气中的微小水滴和颗粒物组成的。光线在传播过程中遇到这些颗粒会发生散射一部分原本应该直接反射进镜头的光被散射掉了同时周围的天空光和环境光也被散射进镜头。最终相机传感器接受到的光强等于“目标反射光衰减后的部分”加上“大气光散射进入镜头的部分”。你可以把雾想象成一层半透明的纱帘挡在你和拍摄物体之间。隔着纱帘看东西目标本身的颜色会变淡纱帘本身的颜色通常是灰白混了进来。物体离得越远纱帘越厚画面就越模糊、越发白。去雾要做的本质上就是把这层“纱帘”的影响从图像中估计出来并剔除掉恢复出原本的场景反射光。这也解释了为什么去雾不是简单的“提高对比度”或“锐化”就能解决的——因为雾对图像的影响是随深度变化的远处像素受大气光污染严重近处像素相对较轻。线性增强工具根本没法区分这些差异必须用物理模型来做。1.2 暗通道先验的统计学发现何恺明最聪明的地方在于他没有直接去解一个复杂的病态方程而是先做了一个统计观察。他收集了超过5000张清晰、无雾的自然图像统计后发现一个规律在绝大多数非天空的局部区域里某些像素总会在RGB三个颜色通道中至少有一个通道的值非常低接近0。他把这个规律定义为暗通道先验Dark Channel Prior数学表达就是每个局部区域内RGB三通道最小值的二次最小值趋近于0。为什么会这样你可以想一想生活中常见的场景绿色植被有很强的绿色分量但红色和蓝色通道的值很低彩色物体的某个通道也往往偏低建筑和道路的阴影里所有通道都被压制得比较低即使是白色物体在微小凹凸表面上也会产生阴影形成暗像素。这些局部暗像素大量存在于自然图像中于是“局部区域某个通道值很低”在统计上就成了一条非常可靠的先验知识。这个发现本身就相当反直觉——我们通常觉得照片应该是色彩饱满的可一旦把每个像素取三通道最小值再做局部最小值处理有雾图像和无雾图像展现出的差异是撕裂性的无雾图像的暗通道图几乎全黑有雾图像的暗通道图却因为雾的散射光抬高了整体亮度显得灰白一片。正是这个差异给了算法破解雾的关键线索。2. 大气散射模型与算法总览2.1 大气散射模型公式拆解如果说暗通道先验是一把钥匙那大气散射模型就是这把钥匙要打开的锁。这个模型描述的是有雾图像和清晰图像之间的数学关系几乎所有经典去雾算法都建立在这个模型之上。模型公式长这样I(x) J(x) * t(x) A * (1 - t(x))逐项拆开看I(x) 是实际拍摄到的有雾图像也就是我们的输入。J(x) 是我们想恢复的清晰无雾图像是算法的最终输出目标。A 是全局大气光可以理解为场景中无限远处最明亮区域的像素值通常用天空区域近似。t(x) 是透射率取值在0到1之间。它描述的是物体反射光经过介质到达相机的比例1表示完全透射没有雾0表示完全被散射等同于全雾。这个公式非常直观右边第一项 J(x) * t(x) 表示目标光线衰减了多少第二项 A * (1 - t(x)) 表示大气光叠加了多少。雾越浓t(x)越接近0大气光占的比重就越大最终图像就越接近纯大气光A也就是我们看到的一片灰白。问题在于我们手里只有一个已知量I(x)却有三个未知量J(x)、A、t(x)。一个方程解三个未知数这是典型的欠定问题数学上不可解。暗通道先验的作用就是把一个额外的强约束加进来让方程组从不可解变成可解这就是整套算法的数学支点。2.2 算法五步流程总览整个暗通道去雾算法的流程可以浓缩成五步后面的代码也是严格按照这五步来实现的计算输入图像的暗通道图。从暗通道图中估计全局大气光A。根据大气散射模型反推透射率t(x)。对透射率图做精细化优化消除块状边缘和光晕伪影。用估计出的A和t(x)反解出无雾图像J(x)。这个流程看起来简单但每一步都有很多细节值得深究。比如暗通道图怎么算才准确大气光的估计为什么不能直接在原图上找最亮的点透射率估计出来为什么还要优化这些细节直接决定最终去雾效果的好坏也决定了你的代码和论文复现版本之间差距有多大。接下来我会带着你一步步写完这些代码。3. Python逐步实现从暗通道到透射率3.1 环境准备与依赖说明实现这个算法只需要三个东西Python 3.6以上版本、NumPy、OpenCV。我建议用NumPy做数组运算用OpenCV来处理滤波和图像读写。安装命令很简单pip install numpy opencv-python如果后面你想用现成的导向滤波接口还需要安装opencv-contrib-python不过我在下面的代码里会手动实现导向滤波不依赖扩展模块这样兼容性最好换台机器也能直接跑。这里有一个初学者容易踩的坑OpenCV的默认颜色通道是BGR不是RGB。读入的图像用cv2.imread()得到的就是BGR顺序。我的建议是代码里始终保持BGR通道顺序处理只在最终显示或保存时用cv2.cvtColor转换避免因为通道顺序不一致导致实验对比时出现诡异偏色。3.2 计算暗通道图暗通道的数学定义是对图像中每个像素先取RGB三通道的最小值得到一个最小值图再对这个最小值图做一次局部区域内的最小值滤波通常窗口大小取15x15。用代码表示就是import cv2 import numpy as np def dark_channel(img, window_size15): 计算暗通道图 img: float类型取值0~1HxWx3 window_size: 窗口尺寸一般为15 min_channel np.min(img, axis2) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (window_size, window_size)) dark cv2.erode(min_channel, kernel) return dark这里有两个关键点。第一np.min(img, axis2)是取三通道最小值操作效率极高千万别写for循环遍历像素那会慢到怀疑人生。第二最小值滤波可以直接用cv2.erode实现因为腐蚀操作的本质就是取邻域最小值这比手动滑窗或甚至比用scipy的minimum_filter都更直观。为什么窗口要选15这是何恺明在论文里实验得出的经验值对大部分自然场景都适用。窗口太小暗通道先验成立的范围不够估计出的透射率容易带进去太多场景深度信息窗口太大暗通道图的边缘会被过度平滑导致恢复图像出现明显光晕。我在实际项目中测试过7x7到25x25之间都有覆盖15是一个均衡的选择。3.3 估计全局大气光估计大气光A是整个算法里最容易做错的一步。很多初学者会直接在全图中找亮度最大的像素值当作A这在雾图中经常失败——因为场景里可能存在白色汽车、白色墙壁、高亮灯光等物体它们的亮度远高于真实的天空大气光直接用会把A估计过高导致去雾后图像整体偏暗、颜色失真。正确的做法是何恺明论文里的方法先在暗通道图中找到最亮的前0.1%像素位置再回到原始有雾图像中在这些位置上寻找RGB三通道强度之和最大的像素作为A。为什么要借助暗通道因为暗通道图能反映雾的浓度真正代表大气光贡献的往往是雾最浓郁的区域而白色物体在暗通道图中并不突出这样就能天然避开“把白色物体当大气光”的陷阱。我的实现分两步先用np.argpartition找到暗通道图中最亮的前0.1%像素索引然后在这些索引对应的原图位置计算亮度并取最大者。def estimate_atmosphere(img, dark, top_ratio0.001): 估计全局大气光 img: float类型HxWx3 dark: 暗通道图 top_ratio: 取暗通道最亮的前0.1% h, w dark.shape num_bright max(int(h * w * top_ratio), 1) # 使用argpartition比argsort快得多 flat_dark dark.flatten() idx np.argpartition(flat_dark, -num_bright)[-num_bright:] ys, xs np.unravel_index(idx, (h, w)) # 在原图对应位置计算每个像素的三通道亮度 bright_sum img[ys, xs].sum(axis1) best_idx np.argmax(bright_sum) A img[ys[best_idx], xs[best_idx]].astype(np.float64) return A注意我在计算时把图像转成了float型再算这能避免整数溢出。还有一个细节如果图像中出现高光光源或太阳本身这个算法也可能把太阳当作大气光这是暗通道先验的已知缺陷不是代码写错了。处理方式可以结合图像亮度阈值做保护后面在优化章节会展开讲。3.4 估计透射率图有了大气光A和暗通道图透射率就可以直接反推了。把大气散射模型稍微变个形两边同时除以大气光A再做暗通道运算利用暗通道先验J_dark趋近于0的性质透射率的初始估计公式就出来了t(x) 1 - ω * (暗通道(I/A)) (x)其中ω是一个保留系数。这里要解释一下ω的作用如果ω取1表示完全去雾恢复出来的图像往往颜色过于饱和、看起来很“假”像是被暴力渲染过何恺明在论文里建议ω取0.95也就是保留一点点雾感让图像保持自然的深度层次。我在实测中发现对于轻度雾霾图ω取0.85到0.95都可以对于浓雾图ω可以适当提高但一般不要超过1。实现代码如下def estimate_transmission(img, A, window_size15, omega0.95): 估计透射率图 img: float类型HxWx3 A: 全局大气光1x3数组 # 逐通道除以大气光 normalized img / A dark_norm dark_channel(normalized, window_size) transmission 1 - omega * dark_norm return transmission这个代码最巧妙的地方在于归一化操作img / A为什么能行得通因为在暗通道操作里我们对每个通道做比例归一化实际上是在估计“当前像素相对大气光的衰减比例”。雾越浓的区域归一化后暗通道值越大透射率就越小这符合物理直觉。需要注意如果归一化后某些像素值大于1比如原图中有比大气光更亮的物体暗通道值可能超过1导致透射率出现负值。这在后续恢复图像时会被clip兜底处理但如果负值区域面积很大说明大气光A估计偏低了需要回到上一步检查。4. 优化透射率导向滤波是点睛之笔4.1 初始透射率图的块状效应用上面的代码估计出的透射率图有个明显毛病它是一块一块的。这是因为暗通道计算里使用了15x15的局部最小值滤波在深度突变的边缘处透射率值也会呈现阶梯状变化。如果直接用这个透射率图去恢复图像会在物体边缘看到一圈一圈的光晕就像直接用美颜工具涂抹过度的痕迹非常难看。这个问题在论文里是通过soft matting解决的但soft matting本质上是求解一个大型稀疏线性方程组计算量巨大一张100万像素的图可能要跑几十秒甚至更久。后来何恺明和合作者又提出了导向滤波Guided Filter专门用于精细化透射率图效果接近soft matting速度却提升了几个数量级现在已经是实际项目里的标配做法。4.2 导向滤波的原理与实现导向滤波的核心思想非常优雅用一张“干净”的导向图来指导另一张图的滤波过程。在我们的场景里导向图用原始有雾图像的灰度图待滤波的图是粗糙的透射率图。滤波的约束条件是输出图像和导向图在局部窗口内保持线性关系这样就迫使最终结果在边缘位置和原图对齐而在平坦区域被平滑过渡。效果直观一点说物体边缘处透射率突变和原图边缘对齐消除光晕平坦区域内部透射率平滑变化去除块状效应。导向滤波的实现并不复杂全流程就是几次均值滤波和像素级计算完全可以手动实现def guided_filter(I, p, radius40, eps1e-3): 导向滤波 I: 导向图float类型0~1灰度图 p: 待滤波图float类型 radius: 滤波窗口半径 eps: 正则化参数防止除零 I I.astype(np.float64) p p.astype(np.float64) ksize (2 * radius 1, 2 * radius 1) mean_I cv2.boxFilter(I, ddepth-1, ksizeksize) mean_p cv2.boxFilter(p, ddepth-1, ksizeksize) mean_Ip cv2.boxFilter(I * p, ddepth-1, ksizeksize) cov_Ip mean_Ip - mean_I * mean_p mean_II cv2.boxFilter(I * I, ddepth-1, ksizeksize) var_I mean_II - mean_I * mean_I a cov_Ip / (var_I eps) b mean_p - a * mean_I mean_a cv2.boxFilter(a, ddepth-1, ksizeksize) mean_b cv2.boxFilter(b, ddepth-1, ksizeksize) q mean_a * I mean_b return q两个参数很关键。radius控制平滑范围我习惯取40对应到图片上大约是图像短边的1/20左右太大容易丢失透射率细节太小导向效果不明显。eps是防止a系数爆炸的正则化项一般取1e-3这个值是针对像素值范围0~1设定的如果你把图像拖到0~255范围计算必须相应放大到1左右否则效果完全不同。把导向滤波应用到透射率图之前需要先把原图转成灰度图并归一化到0~1范围。这一步我用cv2.cvtColor做注意BGR到GRAY的转换权重是标准化的直接调用就行。5. 恢复无雾图像与完整代码整合5.1 图像恢复公式透射率优化完成后剩下的工作就很简单了。把大气散射模型变形直接解出J(x)J(x) (I(x) - A) / max(t(x), t0) A这里有两个细节要处理清楚。第一是t0的下界保护。当透射率t(x)非常接近0时I(x) - A会被一个接近于0的数相除结果会剧烈放大噪声图像会出现大量随机色点。实际使用中所有基于物理模型的去雾算法都会给透射率设一个下界t0一般取0.1。这等于承认哪怕场景浓雾到完全看不清我们也不会把透射率真的推到0保留了10%的原始信息换取噪声的可控性。第二是恢复结果的范围裁剪。公式计算出的J(x)可能落在0~1范围之外尤其在天空等大气光非常强的区域恢复后可能出现负值像素。要对最终图像做np.clip处理把像素值截断到合法范围内。5.2 完整代码整合到这里所有零件都齐了。我把从读取图片、估计参数、优化透射率、恢复图像的全流程整合成一个完整的函数import cv2 import numpy as np def dark_channel(img, window_size15): min_channel np.min(img, axis2) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (window_size, window_size)) return cv2.erode(min_channel, kernel) def estimate_atmosphere(img, dark, top_ratio0.001): h, w dark.shape num_bright max(int(h * w * top_ratio), 1) flat_dark dark.flatten() idx np.argpartition(flat_dark, -num_bright)[-num_bright:] ys, xs np.unravel_index(idx, (h, w)) bright_sum img[ys, xs].sum(axis1) best_idx np.argmax(bright_sum) return img[ys[best_idx], xs[best_idx]].astype(np.float64) def estimate_transmission(img, A, window_size15, omega0.95): normalized img / A return 1 - omega * dark_channel(normalized, window_size) def guided_filter(I, p, radius40, eps1e-3): I I.astype(np.float64) p p.astype(np.float64) ksize (2 * radius 1, 2 * radius 1) mean_I cv2.boxFilter(I, ddepth-1, ksizeksize) mean_p cv2.boxFilter(p, ddepth-1, ksizeksize) mean_Ip cv2.boxFilter(I * p, ddepth-1, ksizeksize) cov_Ip mean_Ip - mean_I * mean_p mean_II cv2.boxFilter(I * I, ddepth-1, ksizeksize) var_I mean_II - mean_I * mean_I a cov_Ip / (var_I eps) b mean_p - a * mean_I mean_a cv2.boxFilter(a, ddepth-1, ksizeksize) mean_b cv2.boxFilter(b, ddepth-1, ksizeksize) return mean_a * I mean_b def recover_image(img, A, t, t00.1): t np.clip(t, t0, 1)[:, :, np.newaxis] result (img - A) / t A return np.clip(result, 0, 1) def dehaze(image_path, window_size15, omega0.95, radius40, eps1e-3): # 读取图像并归一化 bgr cv2.imread(image_path) if bgr is None: raise FileNotFoundError(f无法读取图像: {image_path}) img bgr.astype(np.float64) / 255.0 # 1. 估计大气光 dark dark_channel(img, window_size) A estimate_atmosphere(img, dark) # 2. 估计并优化透射率 t estimate_transmission(img, A, window_size, omega) gray cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float64) / 255.0 t_refined guided_filter(gray, t, radius, eps) # 3. 恢复无雾图像 result recover_image(img, A, t_refined, t00.1) return (result * 255).astype(np.uint8)在外层调用时也可以做成双击就能跑的脚本方便批量处理整个文件夹的图片。就我自己日常实验的体验来说这段代码在500万像素级别的图片上处理一张图大约需要200到500毫秒完全能满足离线批量处理的需求。6. 常见问题与调参实战6.1 天空区域偏色怎么解决暗通道去雾最经典的翻车现场就是天空。有雾图像的天空区域本来就接近大气光的颜色并不满足“暗通道值趋近于0”的先验假设结果恢复出来的天空会变成偏黄或偏红的奇怪色调在我接触过的项目里凡是第一次跑通这个算法的朋友几乎都会遇到这个问题。处理办法有好几层。最简单粗暴的是在估计透射率时对天空区域做保护把天空像素的透射率强制拉高到接近0.9以上或者根据亮度和暗通道值判断天空区域然后用权重混合的方式过渡。我推荐的做法是计算一个天空掩膜凡是暗通道值大于某个阈值且亮度较高的区域都视为天空这些位置的透射率不再使用估计值而是用周围非天空区域透射率的平滑填充。还有一种更优雅的做法就是在暗通道计算这一步就加入一个下限保护让暗通道值最大不超过某个上限从而限制透射率的下界间接避免天空被过度去雾。我在实践中倾向用掩膜方案因为它的语义更可控可以微调局部效果。6.2 大图性能优化技巧如果你要处理的是无人机拍的4K甚至8K航拍图直接全尺寸跑暗通道去雾会明显卡顿。我的经验是两步走先把图像缩小到长边1000像素左右完成去雾再把得到的透射率图上采样回原尺寸最后用原始大图和上采样的透射率图做恢复。因为透射率本质上是低频信息在缩小的尺度上估计和上采样不会损失太多精度但计算量能降到原来的几分之一。另外前面估计大气光时用np.argpartition代替np.argsort也是性能优化的细节。对小图两个方法差别不明显但大图上argsort是对全数组排序复杂度O(n log n)而argpartition只做部分排序复杂度O(n)实测能快出不少。如果要做视频实时处理我建议把透射率图做时间域平滑——连续帧之间大气光变化不大透射率图的均值也接近加入指数移动平均可以大幅抑制闪烁现象。6.3 参数速查表与实践建议我整理了一张自己在实验中常用的参数表覆盖不同场景的推荐设置方便你调参时直接抄作业参数作用推荐值调整倾向window_size暗通道滤波窗口15浓雾增大到25薄雾减小到9omega去雾强度0.95雾越重越接近1但不超过1t0透射率下界0.1图像噪声大时提到0.2radius导向滤波半径40大图可设60小图30eps导向滤波正则项1e-3像素范围0~255时改为1这里的每一条我都踩过坑。比如把eps改成1会导致透射率几乎不被平滑光晕全回来了omega设成1.0虽然雾去得干净但整体图像会发灰、发干t0设成0.01表面上是保留了更多细节但噪声放大后图像反而没法看。我给新手的建议是先跑通默认参数再针对自己的图片类型做单变量调优一次只动一个参数记录效果对比。不要一上来就五个参数一起调那样出了问题根本定位不到原因。7. 扩展方向与个人的一些体会7.1 算法可以怎么扩展暗通道去雾作为经典算法最大的价值在于给了后来者一个清晰的思考范式从物理模型出发找到统计先验再设计可计算的估计流程。沿着这个思路你可以做很多扩展。一个方向是把单张图像去雾扩展到视频去雾。我上面提过的时间平滑技巧就够用再进一步可以结合光流场来做运动补偿让动态区域的透射率估计更稳定。另一个方向是结合深度学习。现在很多主流去雾网络比如AOD-Net、DehazeNet内部仍然会参考大气散射模型的结构只是把参数估计换成了神经网络来学。暗通道先验也常被当作训练数据的先验约束帮助网络在特征层面保持物理一致性。如果你想深入研究计算机视觉里的图像恢复方向把暗通道算法吃透再去看这些论文理解成本会低很多。还有一个非常实用的扩展是把它用在自动驾驶和安防监控的前处理里。雾天环境下目标检测的准确率会显著下降先把图像去雾再送入检测模型往往比直接硬跑模型效果更好。我实测过在浓雾场景下去雾预处理能让一个轻量级目标检测模型在验证集上的mAP提升好几个点这个收益非常可观。7.2 写在最后少走弯路的几点经验从第一次跑通暗通道去雾到现在我前后在大量图片上调过这套算法说几点最有感触的经验。第一不要只看去雾后的单张效果图就下结论。同一套参数在不同的图上表现差异很大一定要准备一个包含不同雾浓度、不同场景类型的测试集多次对比再定参数。第二去雾不等于颜色越鲜艳越好。过度去雾会让图像失去大气透视感显得扁平、塑料感很强。保留一点点雾感画面反而更自然。第三大气光A是否估计准确对最终效果的影响比大多数人想象得大。如果你觉得去雾后图像整体偏暗90%的情况是A估计偏高如果图像发白、去雾不彻底多半是A偏低。优先调A的估计逻辑再动其它参数。最后想说的是暗通道去雾虽然在效果上已经被深度学习超越但它依然是图像增强领域必修的一课。它让你理解什么叫从物理模型到统计先验再到工程实现的完整闭环这种思维方式的训练比单纯会调用一个库要有价值得多。后续如果大家感兴趣我还可以写写怎么把它扩展成批量处理工具或者怎么和深度学习检测模型配合使用。这次先到这里有问题的朋友可以在评论区聊聊你的调参经历一起交流避坑。
返回列表