3、暗通道先验去雾实现:Python实现暗通道提取、大气光值估计、透射率计算、去雾图像恢复

暗通道先验(Dark Channel Prior, DCP)是去雾领域最经典的算法之一。何恺明在2009年提出这个方法时,我还在学校啃,当时就觉得——这思路真巧妙。说白了,它抓住了一个很朴素的观察:在无雾的户外图像里,大部分局部区域总有一些像素的某个颜色通道值特别低,接近0。雾天就不一样了,雾会把所有通道都抬亮。

这一章,我们就一步步把DCP拆开,用Python完整实现一遍。从暗通道提取开始,到大气光估计,再到透射率计算,最后恢复出清晰图像。我会把我在项目中踩过的坑也一并告诉你。

核心流程速览

输入有雾图像 → 提取暗通道 → 估计大气光值 → 计算粗透射率 → 引导滤波细化 → 图像恢复 → 输出去雾结果

暗通道先验去雾流程图 输入有雾图像 提取暗通道 估计大气光值 计算粗透射率 引导滤波细化 图像恢复 输出去雾结果 💡 关键说明 暗通道先验假设:无雾图像中,局部区域至少有一个颜色通道值极低(接近0)。 雾天会破坏这个规律,利用这个差异就能反推出雾的浓度(透射率)。 引导滤波用于解决粗透射率中的块状效应,让结果更自然。

3.1 暗通道提取——找到图像中最暗的区域

暗通道的定义其实很简单。对输入图像 I,取每个像素在RGB三个通道的最小值,然后在一个局部窗口(比如15×15)内再做一次最小值滤波。结果就是暗通道图。

数学表达:

J_dark(x) = min_{y ∈ Ω(x)} ( min_{c ∈ {r,g,b}} J_c(y) )

其中Ω(x)是以像素x为中心的窗口。我习惯用15×15的窗口,太大容易丢失细节,太小又不够鲁棒。

来看代码实现:

import cv2
import numpy as np

def dark_channel(image, patch_size=15):
    """
    提取暗通道
    :param image: 输入图像 (H, W, 3),值范围0-255
    :param patch_size: 窗口大小,默认15
    :return: 暗通道图 (H, W)
    """
    # 取每个像素RGB三通道的最小值
    min_channel = np.min(image, axis=2)
    
    # 最小值滤波(腐蚀操作)
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (patch_size, patch_size))
    dark = cv2.erode(min_channel, kernel)
    
    return dark

我的经验

窗口大小选15是个不错的起点。如果图像分辨率很高(比如4K),可以适当放大到25或31。我曾经在一个航拍去雾项目里用过7×7的窗口,结果暗通道里全是噪声,透射率估计一塌糊涂。后来改成15×15,效果立马好了。

3.2 大气光值估计——找到雾最浓的地方

大气光值A代表雾的颜色和强度。怎么估计?何恺明的方法很巧妙:

  1. 从暗通道图中选出最亮的0.1%像素(亮度最高的那些点)
  2. 在原图中找到这些像素对应的位置
  3. 取这些位置像素值的最大值作为A

为什么这么做?暗通道最亮的地方,说明雾最浓,那里的像素值最接近大气光。

def estimate_atmospheric_light(image, dark):
    """
    估计大气光值
    :param image: 原图 (H, W, 3)
    :param dark: 暗通道图 (H, W)
    :return: 大气光值 (3,) 数组
    """
    h, w = dark.shape
    num_pixels = h * w
    
    # 取最亮的0.1%像素
    num_bright = max(int(num_pixels * 0.001), 1)
    flat_dark = dark.ravel()
    indices = np.argpartition(flat_dark, -num_bright)[-num_bright:]
    
    # 在原图中找这些像素的最大值
    flat_image = image.reshape(-1, 3)
    A = np.max(flat_image[indices], axis=0)
    
    return A

注意

大气光值A是一个三维向量(R,G,B),不是标量。不同通道的大气光可能不同,尤其在偏色严重的雾天图像里。我遇到过一张黄昏时拍的雾景,A的R通道明显高于B通道,如果强行用标量,恢复出来的图像颜色就怪怪的。

3.3 透射率计算——雾有多浓?

有了暗通道和大气光,透射率就可以直接算出来了。根据雾天成像模型:

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

两边同时除以A,再取暗通道,整理后得到:

t(x) = 1 - ω * dark_channel(I / A)

其中ω是一个保留少量雾的参数,通常取0.95。为什么留一点雾?完全去雾会让图像看起来不自然,像塑料一样。我一般设0.9到0.95之间。

def estimate_transmission(image, A, patch_size=15, omega=0.95):
    """
    计算粗透射率
    :param image: 原图 (H, W, 3)
    :param A: 大气光值 (3,)
    :param patch_size: 窗口大小
    :param omega: 去雾强度,0.9-0.95
    :return: 粗透射率图 (H, W)
    """
    # 归一化图像
    normalized = image / A
    
    # 提取暗通道
    dark_normalized = dark_channel(normalized, patch_size)
    
    # 计算透射率
    transmission = 1 - omega * dark_normalized
    
    # 限制范围,防止过曝
    transmission = np.clip(transmission, 0.1, 1.0)
    
    return transmission

透射率的下界

代码里我把透射率限制在0.1以上。为什么?如果透射率接近0,恢复时J = (I - A)/t + A 会爆炸,产生大量噪声。设个下界,至少保留10%的雾,图像更稳定。

3.4 引导滤波细化——去掉块状效应

粗透射率有个明显的问题:块状效应。因为用了最小值滤波,透射率图看起来像马赛克。直接用它恢复图像,边缘会有光晕。

解决办法是用引导滤波(Guided Filter)对透射率做边缘保持平滑。引导图就用原图的灰度版本。

def guided_filter(I, p, r=60, eps=1e-6):
    """
    引导滤波
    :param I: 引导图像 (H, W),单通道
    :param p: 输入图像 (H, W),待滤波
    :param r: 窗口半径
    :param eps: 正则化参数
    :return: 滤波结果 (H, W)
    """
    mean_I = cv2.boxFilter(I, cv2.CV_64F, (r, r))
    mean_p = cv2.boxFilter(p, cv2.CV_64F, (r, r))
    mean_Ip = cv2.boxFilter(I * p, cv2.CV_64F, (r, r))
    cov_Ip = mean_Ip - mean_I * mean_p
    
    mean_II = cv2.boxFilter(I * I, cv2.CV_64F, (r, r))
    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, cv2.CV_64F, (r, r))
    mean_b = cv2.boxFilter(b, cv2.CV_64F, (r, r))
    
    q = mean_a * I + mean_b
    return q

参数调优

r=60, eps=1e-6是我常用的参数。r控制平滑范围,越大越平滑;eps控制边缘保留程度,越小边缘越锐利。如果图像噪声大,可以把eps调大到1e-3。记得有一次处理雾霾特别严重的城市街景,r调到80才把块状效应压下去。

3.5 图像恢复——去雾的最后一步

有了细化后的透射率t和大气光A,恢复公式很简单:

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

t0是透射率下界,防止除零。我设0.1。

def dehaze(image, t0=0.1):
    """
    完整去雾流程
    :param image: 输入有雾图像 (H, W, 3)
    :param t0: 透射率下界
    :return: 去雾后的图像 (H, W, 3)
    """
    image = image.astype(np.float64)
    
    # 1. 提取暗通道
    dark = dark_channel(image)
    
    # 2. 估计大气光
    A = estimate_atmospheric_light(image, dark)
    
    # 3. 计算粗透射率
    t_coarse = estimate_transmission(image, A)
    
    # 4. 引导滤波细化
    gray = cv2.cvtColor(image.astype(np.uint8), cv2.COLOR_BGR2GRAY)
    gray = gray.astype(np.float64) / 255.0
    t_refined = guided_filter(gray, t_coarse)
    
    # 5. 图像恢复
    t_refined = np.clip(t_refined, t0, 1.0)
    result = np.zeros_like(image)
    for c in range(3):
        result[:,:,c] = (image[:,:,c] - A[c]) / t_refined + A[c]
    
    # 6. 裁剪到有效范围
    result = np.clip(result, 0, 255).astype(np.uint8)
    
    return result

3.6 完整流程与参数对比

我把整个流程的关键参数整理成了一张表,方便你对照调参:

步骤 关键参数 推荐值 影响
暗通道提取 patch_size 15 越大暗通道越平滑,但细节丢失
大气光估计 top_percentage 0.1% 太小可能选到噪声点,太大A偏暗
透射率计算 omega 0.95 越大去雾越强,但可能过饱和
引导滤波 r / eps 60 / 1e-6 r控制平滑,eps控制边缘
图像恢复 t0 0.1 越小去雾越彻底,但噪声越大

常见坑点

我曾经在批量处理一批雾天监控视频时,发现有些帧恢复后出现严重偏色。排查了半天,原来是大气光估计时,如果图像中有大面积白色物体(比如白墙、天空),最亮的0.1%像素会被这些区域占据,导致A估计偏高。解决办法是在暗通道中排除天空区域,或者对A做多帧平滑。

3.7 效果评估与调优思路

去雾效果好不好,不能光靠眼睛看。我一般用三个指标:

  • 对比度提升:去雾后图像的对比度应该明显增加
  • 颜色自然度:不能出现偏色,尤其是天空区域
  • 边缘保持:去雾后边缘不能有光晕或模糊

如果发现去雾后图像偏暗,可以适当降低omega(比如0.85)。如果天空区域出现光晕,把引导滤波的r调大。如果图像有噪声放大,把t0从0.1提高到0.2。

一句话总结

暗通道先验去雾的核心就三步:找暗通道、算透射率、恢复图像。引导滤波是让结果变自然的关键。参数调优时,记住一个原则——去雾不是越彻底越好,保留一点雾感反而更真实。