☰
暗通道先验单图去雾:原理、参数与工程实践全解析
2026/10/14 14:19:14 网站建设 项目流程

图像去雾这个方向,每年一到秋冬雾霾季就会被翻出来重新讨论一轮。我做图像增强和底层视觉算法落地差不多有七八年,从最早用OpenCV硬写,到后来复现各种深度学习去雾网络,再到现在回头把经典方法重新啃一遍,最大的感受是:暗通道先验(Dark Channel Prior)这套东西,放到今天依然是单图去雾里性价比最高的方案之一。它不需要训练数据,不需要GPU,一张雾图丢进去,几十毫秒就能出一张干净的结果,很多工业检测、监控预处理、无人机航拍增强的场景里,它仍然是首选。

这篇博文我就围绕“Single Image Haze Removal Using Dark Channel Prior”这个主题,把整套方法的来龙去脉、核心原理、参数计算、代码实现、踩坑经验全部拆开讲一遍。不管你是刚接触图像处理的学生,还是需要把去雾能力集成到产品里的工程师,看完应该都能直接上手复现。我会尽量把数学推导讲成人话,把参数怎么调、为什么这么调说清楚,而不是甩一堆公式就完事。

1. 去雾这件事到底难在哪

1.1 雾天成像的物理模型

要理解暗通道先验,得先搞清楚雾图是怎么形成的。大气散射模型是这一整套方法的物理基础,公式长这样:

I(x) = J(x) · t(x) + A · (1 - t(x))

这里每个符号的含义必须掰清楚:

  • I(x):我们观测到的雾图,也就是相机拍到的有雾图像,这是已知量。
  • J(x):我们想要恢复的无雾清晰图像,这是未知量,也是最终目标。
  • t(x):透射率(transmission),表示光线穿过雾霾到达相机的比例,取值在0到1之间。雾越浓,t越小。
  • A:大气光值(atmospheric light),可以理解为无穷远处天空的亮度,是一个全局常量。

这个模型说白了就是:你看到的图像 = 真实景物衰减后的部分 + 雾霾本身叠加的亮度。去雾的本质,就是从已知的I(x)里,反推出J(x)。但问题是,一个方程里有两个未知量(J和t),再加上一个未知的A,这是典型的欠定问题,数学上无解。

提示:很多人第一次看这个公式会懵,其实可以类比成“隔着毛玻璃看东西”。毛玻璃越厚(t越小),你看到的东西越模糊、越接近玻璃本身的颜色(A)。去雾就是想办法把这层毛玻璃的厚度估算出来,然后减掉。

1.2 为什么单图去雾是老大难

如果有多张同一场景不同雾浓度的图,或者有深度信息,去雾会简单很多,因为可以联立方程求解。但现实中我们往往只有一张图,这就麻烦了。早期的方法要么依赖用户交互(手动标注天空区域),要么假设场景深度是分段光滑的,效果都不稳定。

单图去雾的核心难点在于:透射率t和大气光A都是未知的,而且t在空间上是变化的。你得从一张图里同时估计出这两个量,还得保证估计得准。暗通道先验的厉害之处,就是它找到了一个非常强的统计规律,把这个问题变得可解。

1.3 暗通道先验的直觉从哪来

何恺明在2009年的CVPR上提出这个方法时,核心观察非常朴素:在绝大多数非天空的户外无雾图像里,总有一些像素,在至少一个颜色通道上的亮度值非常低,接近于0。

这个观察怎么理解?举个例子,你拍一片绿色的树叶,红色通道和蓝色通道的值都很低;拍一块红色的砖,绿色和蓝色通道很低;拍黑色的轮胎,三个通道都低。也就是说,自然场景里充满了“至少一个通道很暗”的像素。这些暗像素的强度,就构成了暗通道。

而雾的存在会把这些暗通道的亮度整体抬高,因为雾霾本身是灰白色的,它给所有通道都加了一层亮度。所以,暗通道的亮度值,某种程度上就反映了雾的浓度。雾越浓,暗通道越亮;无雾时,暗通道接近0。这就是整个方法的灵魂。

2. 暗通道先验的数学拆解与参数计算

2.1 暗通道的定义与计算

暗通道的定义分两步。第一步是逐像素求三通道最小值:

I_dark_pixel(x) = min over c in {R,G,B} of I_c(x)

第二步是在一个局部窗口内做最小值滤波:

I_dark(x) = min over y in Ω(x) of I_dark_pixel(y)

其中Ω(x)是以x为中心的局部窗口,窗口大小通常取15×15。为什么要做局部最小值滤波?因为单像素的暗通道噪声太大,局部窗口能保证“这个区域内确实存在暗像素”,让估计更鲁棒。

用Python和NumPy实现这一步非常直接:

import numpy as np import cv2 def get_dark_channel(img, patch_size=15): # img是float类型,范围0-1 min_channel = np.min(img, axis=2) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (patch_size, patch_size)) dark_channel = cv2.erode(min_channel, kernel) return dark_channel

这里用cv2.erode做最小值滤波,比循环快得多。注意patch_size必须是奇数,否则窗口中心对不齐。

2.2 大气光A的估计方法

大气光A的估计有个经典做法:在暗通道图中,取亮度最高的前0.1%的像素,然后回到原图中,找这些像素位置上亮度最高的那个点,把它作为A。

为什么这么做?因为暗通道最亮的区域,通常就是雾最浓的地方,也就是天空或者无穷远处。这些位置的像素值,最接近大气光。取前0.1%是为了避免个别噪声点干扰,回到原图取最亮值是为了保证A足够大。

def get_atmospheric_light(img, dark_channel, top_percent=0.001): h, w = dark_channel.shape num_pixels = h * w num_top = max(int(num_pixels * top_percent), 1) dark_flat = dark_channel.flatten() indices = np.argsort(dark_flat)[-num_top:] img_flat = img.reshape(-1, 3) top_pixels = img_flat[indices] # 取这些像素中亮度和最大的 brightness = np.sum(top_pixels, axis=1) best_idx = np.argmax(brightness) A = top_pixels[best_idx] return A

注意:A的估计非常关键,如果A估小了,去雾后图像会偏暗;如果A估大了,会过曝。实测下来,取前0.1%是个比较稳的经验值,但在一些特殊场景(比如画面里没有天空)可能需要调整。

2.3 透射率t的推导

有了暗通道和大气光,透射率就可以推导了。对大气散射模型两边同时取暗通道,并且假设局部窗口内透射率是常数,记作t̃(x),可以得到:

min over y in Ω(x) of (min over c of I_c(y)/A_c) = t̃(x) · min over y in Ω(x) of (min over c of J_c(y)/A_c) + (1 - t̃(x))

根据暗通道先验,无雾图像的暗通道J_dark趋近于0,所以中间那一项可以忽略,于是:

t̃(x) = 1 - ω · min over y in Ω(x) of (min over c of I_c(y)/A_c)

这里的ω是一个经验系数,通常取0.95。为什么要留0.05?因为完全去雾会让图像看起来不自然,保留一点点雾能增加景深感,让画面更有层次。这是何恺明论文里明确提到的技巧,实测确实有效。

def get_transmission(img, A, patch_size=15, omega=0.95): norm_img = img / A transmission = 1 - omega * get_dark_channel(norm_img, patch_size) return transmission

2.4 透射率的精细化:软抠图与导向滤波

上面算出来的透射率有个问题:它是基于局部窗口的,窗口边缘会出现块状效应(halo),看起来像马赛克。何恺明最初用软抠图(soft matting)来精细化,但那个算法太慢,一张图要好几秒。后来他改用导向滤波(guided filter),速度快了几十倍,效果几乎一样。

导向滤波的核心思想是:用原图的灰度图作为引导图,对透射率图做边缘保持的平滑。这样透射率图在物体边缘处能保持锐利,在平坦区域能平滑过渡。

def guided_filter(guide, src, radius=60, eps=1e-3): # guide和src都是单通道float图 mean_I = cv2.boxFilter(guide, -1, (radius, radius)) mean_p = cv2.boxFilter(src, -1, (radius, radius)) mean_Ip = cv2.boxFilter(guide * src, -1, (radius, radius)) cov_Ip = mean_Ip - mean_I * mean_p mean_II = cv2.boxFilter(guide * guide, -1, (radius, radius)) 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, -1, (radius, radius)) mean_b = cv2.boxFilter(b, -1, (radius, radius)) q = mean_a * guide + mean_b return q

半径radius取60、eps取1e-3是论文推荐的参数,实测在大多数图上都work。如果图像分辨率特别大,radius可以适当放大。

2.5 恢复无雾图像

最后一步,把估计好的A和t代入模型,反解J:

J(x) = (I(x) - A) / max(t(x), t0) + A

这里t0是一个下限,通常取0.1,防止t太小导致分母趋近于0,让图像出现噪声爆炸。这个下限非常重要,我见过太多人忘了加,结果暗部全是噪点。

def recover(img, A, transmission, t0=0.1): transmission = np.maximum(transmission, t0) J = np.zeros_like(img) for c in range(3): J[:, :, c] = (img[:, :, c] - A[c]) / transmission + A[c] J = np.clip(J, 0, 1) return J

3. 完整实操流程与代码实现

3.1 环境准备与依赖

这套方法对环境的依赖极低,只需要Python 3和两个库:

pip install numpy opencv-python

不需要PyTorch、TensorFlow这些重家伙,也不需要GPU。我用一台普通的笔记本跑1080P的图,整个流程大概200毫秒左右,其中导向滤波占了大头。如果对速度要求高,可以把导向滤波换成更快的近似算法,或者直接跳过精细化,接受轻微的块状效应。

3.2 完整代码串联

把前面的函数串起来,就是一个完整的去雾流程:

def dehaze(img_path, patch_size=15, omega=0.95, t0=0.1, radius=60, eps=1e-3): # 读取图像并归一化 img = cv2.imread(img_path).astype(np.float64) / 255.0 img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 步骤1:计算暗通道 dark = get_dark_channel(img, patch_size) # 步骤2:估计大气光 A = get_atmospheric_light(img, dark) # 步骤3:估计透射率 trans = get_transmission(img, A, patch_size, omega) # 步骤4:导向滤波精细化 gray = cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_RGB2GRAY).astype(np.float64) / 255.0 trans_refined = guided_filter(gray, trans, radius, eps) # 步骤5:恢复无雾图像 result = recover(img, A, trans_refined, t0) return result, A, trans_refined

3.3 参数选择与计算过程

参数不是随便拍的,每个都有它的道理。我把关键参数整理成一张表,方便你对照调整:

参数推荐值作用调整方向
patch_size15暗通道窗口大小雾浓调大,雾淡调小
omega0.95去雾强度调小保留更多雾,调大去雾更彻底
t00.1透射率下限暗部噪声大就调大
radius60导向滤波半径分辨率大就调大
eps1e-3导向滤波正则边缘模糊就调小

关于patch_size的选择,我做过一组对比实验。窗口太小(比如3×3),暗通道估计不准,去雾后会有明显的颜色失真;窗口太大(比如31×31),暗通道过于平滑,细节丢失。15×15是个甜点值,在大多数场景下表现最均衡。

omega这个参数值得多说两句。它控制的是去雾的“力度”。omega=1意味着完全按照模型去雾,结果往往偏暗、对比度过高,看起来像HDR过头了。omega=0.95保留5%的雾,画面更自然。如果你做的是监控预处理,希望尽可能看清细节,可以调到0.98;如果做的是艺术摄影,希望保留氛围感,可以调到0.85。

3.4 实操现场记录

我拿一张典型的城市雾霾图做测试,分辨率1920×1080。记录一下各步骤的耗时:

  • 暗通道计算:约15ms
  • 大气光估计:约8ms
  • 透射率计算:约12ms
  • 导向滤波:约150ms
  • 图像恢复:约10ms

总计约195ms。可以看到导向滤波占了75%以上的时间。如果要做实时视频去雾,导向滤波是瓶颈。我的做法是把radius降到30,eps提到1e-2,速度能提升到80ms左右,画质损失可以接受。

提示:如果你的场景是视频流,建议把大气光A做成滑动平均,不要每帧都重新估计。因为相邻帧的A变化很小,逐帧估计会导致画面亮度闪烁。我一般用前30帧的A做平均,之后每帧用0.99的衰减系数更新。

4. 常见问题与排查技巧实录

4.1 天空区域出现光晕或色偏

这是暗通道先验最经典的翻车场景。因为天空区域本身就很亮,暗通道先验在天空不成立,导致透射率估计偏低,去雾后天空会过曝或者出现奇怪的颜色。

解决办法有几个。最简单的是设置一个透射率下限,比如0.3,让天空区域不要被过度去雾。更精细的做法是先做天空分割,对天空区域单独处理。我常用的是一个基于亮度阈值的快速分割:把暗通道值大于某个阈值(比如0.8)且位置在图像上部的区域标记为天空,对这些区域降低去雾强度。

def sky_aware_transmission(trans, dark, sky_thresh=0.8, sky_t0=0.3): sky_mask = (dark > sky_thresh).astype(np.float64) sky_mask = cv2.GaussianBlur(sky_mask, (31, 31), 0) trans_adjusted = trans * (1 - sky_mask) + np.maximum(trans, sky_t0) * sky_mask return trans_adjusted

4.2 暗部噪声被放大

去雾本质上是做除法,透射率t越小的地方,放大倍数越大。暗部区域本来信噪比就低,一放大噪声就全出来了。这就是为什么t0这个下限必须加。

除了t0,还可以在去雾后做一次轻度的降噪。我一般用双边滤波,因为它能保边。但要注意,降噪要在去雾之后做,顺序反了没用。

问题现象可能原因排查方向解决方案
整体偏暗A估计过小检查大气光取值提高top_percent
天空过曝天空透射率过低看暗通道天空区域加天空保护
暗部噪点t0过小检查透射率下限提高t0到0.15
边缘光晕未做精细化检查导向滤波调小eps
颜色失真patch_size过小看暗通道图增大窗口

4.3 大气光估计踩过的坑

我早期实现时,直接取暗通道最亮的那个像素作为A,结果经常取到一些白色的物体(比如白墙、白车),导致A偏小,去雾后整体偏暗。后来改成取前0.1%的像素再筛选,稳定多了。

还有一个坑是彩色偏置。如果图像整体偏黄(比如黄昏拍摄),A的三个通道值差异很大,直接用它做归一化会导致颜色失真。我的做法是对A做一次白平衡校正,或者干脆把A的三个通道取平均,虽然会损失一点色彩准确性,但更稳。

4.4 什么时候暗通道先验会失效

不是所有图都适合这套方法。以下几种情况要特别注意:

  • 大面积白色或高亮物体:比如雪景、白墙,暗通道先验不成立,去雾后会发灰。
  • 浓雾且无天空:比如隧道内、室内雾,A估计不准。
  • 水下图像:水下的散射模型和大气不同,暗通道先验效果一般。
  • 夜景:暗通道本身就很暗,区分不出雾。

遇到这些场景,我的建议是老老实实上深度学习方案,或者做针对性调整。经典方法有它的边界,硬套只会浪费时间。

5. 效果评估与进阶优化思路

5.1 怎么判断去雾效果好不好

去雾没有绝对的标准答案,因为无雾的ground truth往往不存在。我一般从三个维度评估:

主观视觉:看细节是否清晰、颜色是否自然、有没有光晕和噪声。这是最直接的。

对比度指标:去雾后图像的对比度应该提升。可以算一下去雾前后的标准差,通常会有明显增加。

信息熵:去雾后图像的信息熵应该增加,说明细节更丰富。但如果熵增加太多,可能是噪声被放大了。

def evaluate(original, dehazed): def entropy(img): gray = cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_RGB2GRAY) hist = cv2.calcHist([gray], [0], None, [256], [0, 256]) hist = hist / hist.sum() hist = hist[hist > 0] return -np.sum(hist * np.log2(hist)) print(f"原图信息熵: {entropy(original):.4f}") print(f"去雾后信息熵: {entropy(dehazed):.4f}") print(f"原图对比度: {original.std():.4f}") print(f"去雾后对比度: {dehazed.std():.4f}")

5.2 从经典方法到深度学习的过渡

暗通道先验是手工设计的先验,它的局限在于假设太强。深度学习去雾(比如AOD-Net、GridDehazeNet、FFA-Net)本质上是让网络自己学习透射率和大气光的估计,不再依赖人工先验。

但我不建议一上来就上深度学习。原因有三:第一,训练数据难搞,合成雾和真实雾有domain gap;第二,推理需要GPU,部署成本高;第三,很多场景下暗通道先验的效果已经够用。我的经验是,先用暗通道先验打底,如果效果不达标,再考虑深度学习。

如果你确实要上深度学习,一个实用的技巧是:用暗通道先验的结果作为网络的输入或者辅助监督。这样网络不用从零学起,收敛更快,效果也更稳。

5.3 工程落地的一些经验

最后分享几个把去雾集成到实际产品里的经验。

批处理优化:如果要对大量图片去雾,把暗通道计算和导向滤波向量化,用NumPy的广播机制,比循环快几十倍。

内存控制:导向滤波的boxFilter会产生多个中间变量,大图容易爆内存。我的做法是分块处理,每块512×512,块之间留30像素重叠,最后拼接。

参数自适应:固定参数在不同图上表现差异很大。我写了一个简单的自适应逻辑:先算暗通道的均值,均值大说明雾浓,omega调大;均值小说明雾淡,omega调小。这个逻辑虽然粗糙,但比固定参数稳多了。

def adaptive_omega(dark_channel): mean_dark = dark_channel.mean() if mean_dark > 0.5: return 0.98 elif mean_dark > 0.3: return 0.95 else: return 0.90

这套暗通道先验的方法我从头到尾复现过不下十遍,每次都能发现新的细节。它看起来简单,但真正要做好,参数调整、边界处理、场景适配每一步都有讲究。如果你也在做去雾相关的项目,建议先把这套经典方法吃透,再去碰深度学习,你会发现很多网络的设计思路其实都能在经典方法里找到影子。

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

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

立即咨询