4、暗通道先验改进:导向滤波优化透射率、解决天空区域过曝问题、自适应参数调整

暗通道先验去雾,说白了就是利用「无雾图像至少有一个颜色通道值很低」这个统计规律。我最早接触这个算法时觉得挺神奇的——一张雾图,算个最小值,再抠个透射率,居然就能把雾去掉大半。

但用着用着就发现问题了。天空区域、白色物体、还有那些边缘光晕,处理得都不太理想。嗯,今天我们就来聊聊怎么把这些坑填上。

4.1 原始暗通道先验的三大痛点

先回顾一下经典暗通道去雾的流程:

  1. 计算暗通道图
  2. 估计大气光值 A
  3. 估计粗透射率 t粗
  4. 用软抠图或导向滤波细化 t
  5. 恢复无雾图像

我在项目中跑通这个流程后,发现三个明显的问题:

问题 表现 原因
天空区域过曝 天空变成惨白一片,细节全丢 天空不满足暗通道假设(亮度高、无暗通道)
边缘光晕 物体边缘出现白色晕影 粗透射率是块状的,直接插值导致边缘不连续
参数固定 不同雾浓度场景效果差异大 窗口大小、正则化参数 ω 都是手工调的

你想想看,一张图里既有浓雾区域又有薄雾区域,用同一个参数去处理,怎么可能都合适?

4.2 导向滤波:让透射率边缘更干净

解决边缘光晕最直接的办法,就是用导向滤波代替软抠图。软抠图计算量太大,而且对边缘的保持能力其实一般。

导向滤波的核心思想很简单:用一张引导图的结构来约束输出图。我们通常用原图的灰度图作为引导图,这样透射率的边缘就能和原图对齐。

关键公式:导向滤波假设输出 q 和引导 I 在局部窗口内是线性关系:

qi = ak Ii + bk,∀i ∈ ωk

其中 ak、bk 通过最小化窗口内的重建误差求得。

我个人习惯用半径为 40-60 的窗口,正则化参数 ε 取 0.01 左右。导向滤波的复杂度是 O(N),比软抠图的 O(N²) 快太多了。

import cv2
import numpy as np

def guided_filter(I, p, r, eps):
    """
    导向滤波实现
    I: 引导图像 (灰度图)
    p: 输入图像 (粗透射率)
    r: 窗口半径
    eps: 正则化参数
    """
    # 均值滤波
    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))
    mean_II = cv2.boxFilter(I * I, cv2.CV_64F, (r, r))

    # 计算协方差和方差
    cov_Ip = mean_Ip - mean_I * mean_p
    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

# 使用示例
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) / 255.0
t_coarse = ...  # 粗透射率
t_fine = guided_filter(gray, t_coarse, r=60, eps=0.01)

经验之谈:导向滤波的 r 值不要太小。r=20 时边缘保持好但去雾不彻底,r=80 时去雾干净但可能丢失细节。我一般从 r=40 开始调,看效果再微调。

4.3 天空区域过曝:加一个保护机制

天空区域过曝,本质上是暗通道先验失效了。天空的 RGB 三个通道值都很高,暗通道值不接近 0,导致估计出的透射率偏小,去雾时把天空拉得太亮。

我曾经在项目里处理一张海景图,天空部分直接变成了白色色块,客户看了直摇头。后来我加了一个天空检测步骤,问题就解决了。

解决方案分两步:

  1. 检测天空区域:用亮度阈值 + 梯度信息判断哪些像素属于天空
  2. 限制透射率下限:对天空区域的透射率设置一个下限 tmin,避免过度增强
def refine_t_with_sky(t, img, t_min=0.3, sky_thresh=0.85):
    """
    针对天空区域优化透射率
    t: 细化后的透射率图
    img: 原图 (归一化到 [0,1])
    t_min: 透射率下限
    sky_thresh: 天空判断阈值
    """
    # 计算亮度
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # 天空区域:亮度高 + 梯度小
    grad_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
    grad_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
    grad_mag = np.sqrt(grad_x**2 + grad_y**2)

    sky_mask = (gray > sky_thresh) & (grad_mag < 0.05)

    # 对天空区域设置透射率下限
    t_refined = t.copy()
    t_refined[sky_mask] = np.maximum(t_refined[sky_mask], t_min)

    return t_refined, sky_mask

注意:t_min 设得太小(如 0.1)起不到保护作用,设得太大(如 0.6)会导致天空区域去雾不彻底。我一般取 0.2~0.4,具体看雾的浓度。

4.4 自适应参数调整:让算法自己学会调参

固定参数的问题在于:一张雾图可能同时包含浓雾区和薄雾区。浓雾区需要小窗口、小 ω,薄雾区需要大窗口、大 ω。怎么办?

我的做法是:根据暗通道的统计特性,自适应地调整窗口大小和 ω 值。

暗通道均值 雾浓度 推荐窗口大小 推荐 ω
< 0.1 无雾/轻雾 15×15 0.85
0.1 ~ 0.3 中等雾 25×25 0.90
0.3 ~ 0.5 浓雾 35×35 0.95
> 0.5 极浓雾 45×45 0.98
def adaptive_params(dark_channel):
    """
    根据暗通道均值自适应调整参数
    """
    mean_dark = np.mean(dark_channel)

    if mean_dark < 0.1:
        win_size, omega = 15, 0.85
    elif mean_dark < 0.3:
        win_size, omega = 25, 0.90
    elif mean_dark < 0.5:
        win_size, omega = 35, 0.95
    else:
        win_size, omega = 45, 0.98

    return win_size, omega

进阶思路:更精细的做法是按图像块自适应。把图像分成 8×8 的块,每个块独立计算暗通道均值,然后逐块调整参数。这样浓雾区和薄雾区就能得到不同的处理强度。

4.5 完整流程与知识图谱

把上面三块整合起来,就得到了一个改进版的暗通道去雾流程:

改进暗通道先验去雾流程 输入雾图 计算暗通道图 估计大气光值 A 自适应参数调整 估计粗透射率 t粗 导向滤波细化透射率 天空区域保护 + 透射率下限 输出无雾图像 核心改进:自适应参数 + 导向滤波 + 天空保护,三步解决原始暗通道的三大痛点

整个流程看起来步骤多了,但每个环节都有明确的针对性。我在实际项目中用这套流程处理了上百张雾图,效果比原始暗通道稳定得多。

4.6 完整代码实现

最后,把上面所有模块拼成一个完整的函数:

def improved_dark_channel_dehaze(img, r=60, eps=0.01, t_min=0.3):
    """
    改进暗通道先验去雾
    img: 输入雾图 (BGR, uint8)
    r: 导向滤波窗口半径
    eps: 导向滤波正则化参数
    t_min: 透射率下限
    """
    img_f = img / 255.0
    h, w, c = img_f.shape

    # 1. 计算暗通道
    dark = np.min(img_f, axis=2)
    # 自适应参数
    win_size, omega = adaptive_params(dark)

    # 2. 估计大气光
    flat_dark = dark.ravel()
    flat_img = img_f.reshape(-1, 3)
    num_pixels = h * w
    top_k = int(num_pixels * 0.001)
    indices = np.argpartition(flat_dark, -top_k)[-top_k:]
    A = np.max(flat_img[indices], axis=0)

    # 3. 粗透射率
    t_coarse = 1 - omega * np.min(img_f / A, axis=2)
    t_coarse = np.clip(t_coarse, 0.1, 1.0)

    # 4. 导向滤波细化
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) / 255.0
    t_fine = guided_filter(gray, t_coarse, r, eps)

    # 5. 天空保护
    t_final, sky_mask = refine_t_with_sky(t_fine, img_f, t_min)

    # 6. 恢复无雾图像
    result = np.zeros_like(img_f)
    for i in range(3):
        result[:,:,i] = (img_f[:,:,i] - A[i]) / t_final + A[i]

    result = np.clip(result, 0, 1)
    return (result * 255).astype(np.uint8), sky_mask

使用建议:如果处理的是视频帧,可以把 r 和 eps 固定下来,只对第一帧做自适应参数计算,后续帧复用。这样能省掉不少计算量。

嗯,以上就是我对暗通道先验改进的全部经验。导向滤波解决边缘问题,天空保护解决过曝问题,自适应参数解决泛化问题。三管齐下,效果比原始算法好出一大截。