暗通道去雾算法详解:基于OpenCV和Python的图像去雾实践
2026/9/16 3:57:53 网站建设 项目流程

你有没有遇到过这样的情况:周末兴冲冲出门拍了一组照片,结果赶上雾天,远处的山、建筑全埋在一片灰白里,后期怎么拉对比度都救不回来。这时候,一个叫暗通道去雾的算法就派上用场了。它是计算机视觉里最经典的图像去雾方法之一,由何恺明在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 算法五步流程总览

整个暗通道去雾算法的流程可以浓缩成五步,后面的代码也是严格按照这五步来实现的:

  1. 计算输入图像的暗通道图。
  2. 从暗通道图中估计全局大气光A。
  3. 根据大气散射模型反推透射率t(x)。
  4. 对透射率图做精细化优化,消除块状边缘和光晕伪影。
  5. 用估计出的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_size=15): """ 计算暗通道图 img: float类型,取值0~1,HxWx3 window_size: 窗口尺寸,一般为15 """ min_channel = np.min(img, axis=2) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (window_size, window_size)) dark = cv2.erode(min_channel, kernel) return dark

这里有两个关键点。第一,np.min(img, axis=2)是取三通道最小值操作,效率极高,千万别写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_ratio=0.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(axis=1) 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_size=15, omega=0.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, radius=40, eps=1e-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, ksize=ksize) mean_p = cv2.boxFilter(p, ddepth=-1, ksize=ksize) mean_Ip = cv2.boxFilter(I * p, ddepth=-1, ksize=ksize) cov_Ip = mean_Ip - mean_I * mean_p mean_II = cv2.boxFilter(I * I, ddepth=-1, ksize=ksize) 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, ksize=ksize) mean_b = cv2.boxFilter(b, ddepth=-1, ksize=ksize) 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_size=15): min_channel = np.min(img, axis=2) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (window_size, window_size)) return cv2.erode(min_channel, kernel) def estimate_atmosphere(img, dark, top_ratio=0.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(axis=1) best_idx = np.argmax(bright_sum) return img[ys[best_idx], xs[best_idx]].astype(np.float64) def estimate_transmission(img, A, window_size=15, omega=0.95): normalized = img / A return 1 - omega * dark_channel(normalized, window_size) def guided_filter(I, p, radius=40, eps=1e-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, ksize=ksize) mean_p = cv2.boxFilter(p, ddepth=-1, ksize=ksize) mean_Ip = cv2.boxFilter(I * p, ddepth=-1, ksize=ksize) cov_Ip = mean_Ip - mean_I * mean_p mean_II = cv2.boxFilter(I * I, ddepth=-1, ksize=ksize) 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, ksize=ksize) mean_b = cv2.boxFilter(b, ddepth=-1, ksize=ksize) return mean_a * I + mean_b def recover_image(img, A, t, t0=0.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_size=15, omega=0.95, radius=40, eps=1e-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, t0=0.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,薄雾减小到9
omega去雾强度0.95雾越重越接近1,但不超过1
t0透射率下界0.1图像噪声大时提到0.2
radius导向滤波半径40大图可设60,小图30
eps导向滤波正则项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的估计逻辑,再动其它参数。

最后想说的是,暗通道去雾虽然在效果上已经被深度学习超越,但它依然是图像增强领域必修的一课。它让你理解什么叫从物理模型到统计先验再到工程实现的完整闭环,这种思维方式的训练,比单纯会调用一个库要有价值得多。后续如果大家感兴趣,我还可以写写怎么把它扩展成批量处理工具,或者怎么和深度学习检测模型配合使用。这次先到这里,有问题的朋友可以在评论区聊聊你的调参经历,一起交流避坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询