初看《雾里》这个题目,很容易让人联想到雾里看花、水中望月的朦胧意境。但放到开发场景里,它恰恰对应了一个非常经典的计算机视觉难题:有雾图像下的目标识别与图像恢复。雾天采集到的图像对比度低、色彩偏移严重,直接喂给检测模型或者 OCR 系统,效果往往会大打折扣。
本文就用一个完整的图像去雾实战来解读《雾里》这个主题。我们会从大气散射模型讲起,手写一个基于暗通道先验的去雾算法,再用导向滤波优化透射率图,覆盖原理、代码、效果评估和常见坑点。无论你是刚接触 OpenCV 的初学者,还是在做安防、车载、遥感图像预处理的老手,这套流程都能直接落地复用。
1. 背景与核心概念
1.1 有雾图像为什么需要处理
有雾场景下的成像退化,本质上不是相机对焦不准,而是光线在传播过程中被大气中的悬浮颗粒(雾滴、灰尘、气溶胶)散射和吸收。相机传感器接收到的辐射亮度由两部分组成:
- 衰减后的场景反射光:物体表面反射的光穿过雾层,能量随着距离指数衰减;
- 大气光( Airlight):环境光(太阳光、天空光)经颗粒散射进入相机,形成一层“白纱”。
这两部分叠加,最终导致图像整体发灰、发白,边缘细节丢失。对于后续的边缘检测、特征匹配、目标检测和语义分割任务来说,这种退化是致命的。比如自动驾驶在雾天识别红绿灯、安防系统在海边大雾天气抓拍车牌,如果不做图像去雾,很多算法的准确率会明显下降。
1.2 去雾算法的整体思路
目前主流去雾方案大致分三类:
| 类别 | 代表方法 | 特点 |
|---|---|---|
| 基于图像增强 | 直方图均衡化、Retinex | 不依赖物理模型,提升对比度,但容易过曝或失真 |
| 基于物理模型 | 暗通道先验(DCP)、颜色衰减先验 | 估计透射率和大气光,恢复物理上更合理 |
| 基于深度学习 | AOD-Net、DehazeNet、FFA-Net | 效果好,但需要数据集和训练资源 |
本文选择暗通道先验(Dark Channel Prior,DCP)作为核心,因为它在传统方法中效果稳定、原理清晰、代码量少,适合作为理解去雾原理的起点,同时也能对抗数据集不足的工程场景。
1.3 暗通道先验的核心观察
暗通道先验由何恺明等人提出,核心统计观察是:在绝大多数无雾图像的局部区域中,某些像素在至少一个颜色通道上的强度值非常低,趋近于 0。
天空区域或者白色物体区域除外,它们本身亮度很高,暗通道值不低。因此,如果一张图的暗通道整体偏亮,就说明图像被雾影响严重。这个先验为透射率估计提供了数学基础。
2. 环境准备与版本说明
为了把注意力放在算法本身,我们使用 Python 编写,依赖 OpenCV 和 NumPy。版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
2.1 推荐环境
- 操作系统:Windows 10/11、Ubuntu 20.04 或 macOS 均可
- Python:3.8 及以上
- OpenCV:4.5.x 或 4.8.x 均可
- NumPy:1.21 及以上
- IDE:PyCharm、VS Code 都可以
2.2 安装依赖
建议先创建虚拟环境,避免污染全局 Python:
python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate然后安装依赖:
pip install opencv-python opencv-contrib-python numpy matplotlib如果下载速度慢,可以添加清华镜像源:
pip install opencv-python opencv-contrib-python numpy matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple2.3 示例文件结构
本文的项目目录结构如下:
dehaze_project/ ├── dehaze.py # 去雾主函数 ├── guided_filter.py # 导向滤波实现 ├── demo.py # 运行示例脚本 ├── images/ │ └── foggy.jpg # 有雾输入图 └── output/ └── dehazed.jpg # 去雾输出图3. 核心原理拆解
3.1 大气散射模型
暗通道先验基于如下大气散射模型:
I(x) = J(x) * t(x) + A * (1 - t(x))其中:
I(x):观测到的有雾图像,即输入图;J(x):无雾场景辐射,即我们要求的清晰图;A:全局大气光;t(x):透射率,表示场景光经过雾层后到达相机的比例。
整理后得到恢复公式:
J(x) = (I(x) - A) / t(x) + A由于透射率过小时会产生噪声放大,通常给t(x)设一个下限t0(常见值为 0.1):
J(x) = (I(x) - A) / max(t(x), t0) + A3.2 暗通道计算
对于输入图像I,暗通道定义为:
J_dark(x) = min( min( I_c(y) ) ), y ∈ Ω(x), c ∈ {R, G, B}翻译成大白话就是:
- 对每个像素,在 R、G、B 三个通道取最小值,得到一张单通道最小值图;
- 对这张最小值图做最小值滤波(即形态学腐蚀),每个像素取邻域内的最小值。
窗口大小patch_size是关键参数。窗口越大,暗通道越“模糊”,透射率估计越平滑,但也容易丢失细节;窗口越小,细节保留好,但容易产生块状伪影。一般取 15 到 30 比较合适。
3.3 全局大气光估计
大气光 A 的估计常见两种方式:
- 简单方式:取暗通道中亮度最高的前 0.1% 像素,然后在原图中找这些位置对应像素的最大值。
- 快速方式:直接取暗通道最大值对应的原图像素。
第一种更接近原论文思路,抗噪能力更好。本文采用第一种方式。
需要特别注意的是,天空区域亮度很高,如果整幅图包含大面积天空,大气光可能被天空区域主导。这是暗通道先验在天空场景容易偏色的原因之一,后面常见问题会讨论应对策略。
3.4 透射率估计
由大气散射模型可以推导透射率:
t_estimate(x) = 1 - ω * normalize(I_dark(x) / A)其中:
ω是一个保留少量雾的参数,目的是让恢复结果更自然,默认取 0.95;- 除以 A 是为了归一化大气光的相对强度;
- 暗通道越亮,透射率越低,说明雾越浓。
由于暗通道计算得到的透射率图存在块状效应,通常还需要进一步优化。原始论文使用 soft matting,工程上更常用导向滤波来细化透射率图,计算快且效果好。
4. 完整实战案例
下面从零搭建一个可运行的图像去雾脚本。为了便于理解,我们分文件实现。
4.1 编写导向滤波工具
导向滤波的核心思想是:利用一张引导图(通常是有雾图像的灰度图)来指导透射率图的平滑,让透射率在边缘处保持,在平坦区域平滑。
# 文件路径:guided_filter.py import cv2 import numpy as np def box_filter(img, radius): """快速均值滤波,等价于窗口大小为 (2*radius+1) 的均值滤波器。""" kernel_size = 2 * radius + 1 return cv2.boxFilter(img, ddepth=-1, ksize=(kernel_size, kernel_size), normalize=True) def guided_filter(guide, src, radius, epsilon=1e-6): """ 导向滤波。 guide: 引导图,单通道,通常取有雾图像的灰度图 src: 待滤波图,单通道,通常为透射率图 radius: 滤波窗口半径 epsilon: 正则化参数,防止除零 """ guide = guide.astype(np.float32) / 255.0 src = src.astype(np.float32) mean_g = box_filter(guide, radius) mean_s = box_filter(src, radius) corr_g = box_filter(guide * guide, radius) corr_gs = box_filter(guide * src, radius) var_g = corr_g - mean_g * mean_g cov_gs = corr_gs - mean_g * mean_s a = cov_gs / (var_g + epsilon) b = mean_s - a * mean_g mean_a = box_filter(a, radius) mean_b = box_filter(b, radius) return mean_a * guide + mean_b说明:
- 这里把像素值归一化到 0~1,是为了保证滤波中数值稳定;
boxFilter使用 OpenCV 实现,速度比 Python 双层循环快非常多;epsilon控制边缘保持程度,取值太小容易产生光晕,太大会过度平滑。
4.2 编写去雾主算法
# 文件路径:dehaze.py import cv2 import numpy as np from guided_filter import guided_filter def get_dark_channel(img, patch_size=15): """ 计算暗通道。 img: HWC 顺序的 BGR 图像,值范围 0~255 patch_size: 最小值滤波窗口大小,通常为 15、20 或 30 """ border = patch_size // 2 dark = np.min(img, axis=2).astype(np.float32) # 使用最小值滤波来近似局部最小值 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (patch_size, patch_size)) dark = cv2.erode(dark, kernel) return dark def estimate_atmospheric_light(img, dark_channel, top_percent=0.001): """ 估计全局大气光 A。 取暗通道中亮度最高的前 top_percent 像素,用这些像素在原图中的最大强度作为 A。 """ h, w = dark_channel.shape num_pixels = max(1, int(h * w * top_percent)) flat_dark = dark_channel.flatten() indices = np.argpartition(flat_dark, -num_pixels)[-num_pixels:] max_intensity = 0 A = np.array([0, 0, 0], dtype=np.float32) for idx in indices: y = idx // w x = idx % w pixel = img[y, x] intensity = float(np.sum(pixel)) if intensity > max_intensity: max_intensity = intensity A = pixel.astype(np.float32) return A def estimate_transmission(img, A, patch_size=15, omega=0.95): """ 估计初始透射率图。 img: 输入有雾图 A: 全局大气光 patch_size: 暗通道窗口 omega: 保留雾量系数,0~1 """ img_normalized = img.astype(np.float32) / A dark_normalized = get_dark_channel(img_normalized, patch_size) transmission = 1.0 - omega * dark_normalized return np.clip(transmission, 0.0, 1.0) def recover_scene_radiance(img, transmission, A, t0=0.1): """ 根据透射率图和大气候恢复无雾图像。 t0: 透射率下限,防止噪声放大 """ transmission = np.clip(transmission, t0, 1.0) recovered = np.empty_like(img, dtype=np.float32) for i in range(3): recovered[:, :, i] = (img[:, :, i].astype(np.float32) - A[i]) / transmission + A[i] return np.clip(recovered, 0, 255).astype(np.uint8) def dehaze(image, patch_size=15, omega=0.95, t0=0.1, use_guided_filter=True, guide_radius=40): """ 去雾主流程。 image: BGR 输入图,值范围 0~255 """ img = image.astype(np.float32) dark_channel = get_dark_channel(img, patch_size) A = estimate_atmospheric_light(img, dark_channel) transmission = estimate_transmission(img, A, patch_size, omega) if use_guided_filter: # 引导图使用有雾图像的灰度图 gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) transmission = guided_filter(gray, transmission, guide_radius, epsilon=1e-6) recovered = recover_scene_radiance(img, transmission, A, t0) return recovered, transmission, A函数说明:
get_dark_channel:先取三通道最小值,再用腐蚀操作实现最小值滤波;estimate_atmospheric_light:使用np.argpartition高效挑选前 0.1% 高亮位置,避免全排序;estimate_transmission:用归一化暗通道估计透射率;recover_scene_radiance:按大气散射模型反向恢复场景辐射。
4.3 编写运行脚本
# 文件路径:demo.py import cv2 import numpy as np from dehaze import dehaze if __name__ == "__main__": input_path = "images/foggy.jpg" output_path = "output/dehazed.jpg" image = cv2.imread(input_path) if image is None: print("图片读取失败,请检查路径:", input_path) exit(1) # 如果图片过大,可以先缩小,方便调试参数 # scale = 0.5 # image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) result, transmission, A = dehaze( image, patch_size=15, omega=0.95, t0=0.1, use_guided_filter=True, guide_radius=40, ) cv2.imwrite(output_path, result) print("去雾结果已保存到:", output_path) print("估计的大气光 A:", A) # 展示效果 cv2.imshow("Origin", image) cv2.imshow("Dehazed", result) cv2.waitKey(0) cv2.destroyAllWindows()4.4 运行与验证
在项目根目录执行:
python demo.py预期输出为:
去雾结果已保存到: output/dehazed.jpg 估计的大气光 A: [205.6 197.3 185.2]如果图片路径没问题,屏幕上会弹出两张窗口,左边是有雾原图,右边是去雾结果。
4.5 结果说明与评估指标
去雾结果正确的表现是:
- 图像整体对比度提升,暗部细节可辨认;
- 色彩自然,不发灰,不过度饱和;
- 边缘处没有明显光晕;
- 天空区域没有严重偏色或噪点。
如果希望量化评估,可以计算以下指标:
| 指标 | 说明 |
|---|---|
| PSNR | 峰值信噪比,越高表示与原图越接近,适合有参考图的场合 |
| SSIM | 结构相似性,衡量亮度、对比度、结构三个维度的保留情况 |
| CIEDE2000 | 色彩差异,适合检测偏色问题 |
| 灰度方差 | 衡量对比度提升程度,无参考时可用 |
无参考环境下,最常用的是灰度方差(Variance)和灰度平均梯度(MG)。去雾后方差应明显提升,说明对比度增强。
5. 常见问题与排查思路
5.1 去雾后图像发灰发暗
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 结果整体偏暗 | 透射率估计过低,暗部被压暗 | 调低omega(例如 0.85~0.90),保留更多雾感 |
| 结果偏灰 | 大气光 A 估计偏低 | 检查estimate_atmospheric_light的采样策略,改用亮度最高像素而不是均值 |
| 局部区域过暗 | 输入图像本身暗部噪点多 | 先做轻度去噪,或对暗部区域做亮度拉伸 |
一个直观的调参技巧:omega越小,恢复结果越接近原图,保留的雾越多;omega越大,去雾越彻底,但噪点越明显。建议先固定patch_size=15、t0=0.1,然后每次调整omega0.05 一档,观察效果。
5.2 边缘出现光晕伪影
光晕通常出现在图像中亮度突变明显的区域,比如屋檐和天空的交界、深色建筑和明亮背景的交界。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 物体边缘有一圈白色光晕 | 透射率图在边缘处过渡太陡 | 使用导向滤波,适当增大guide_radius |
| 光晕依旧明显 | 暗通道窗口过大 | 减小patch_size,例如从 15 改成 11 |
| 边缘有黑色伪影 | 透射率下限t0设置过高 | 降低t0到 0.05~0.1 之间 |
在本文实现中,guided_filter的epsilon默认取1e-6,对边缘保持较好。如果希望更强平滑,可以增加到1e-3,但边缘细节会损失。
5.3 天空区域偏色或出现色块
暗通道先验的基础假设是“每个局部区域至少有一个通道亮度接近 0”,但天空区域不符合这个假设。因此天空区域透射率容易被低估,恢复后会明显偏蓝或出现带状色块。
解决思路:
- 对透射率图做天空区域检测,将天空部分透射率设置为一个较低值(例如 0.2~0.4)。
- 使用基于亮度分布的透射率修正,在天空区域降低去雾强度。
- 在恢复公式中对色彩通道分别做限制,避免颜色失衡。
# 简单天空修正示例:亮度较高的区域限制透射率下限 gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) bright_mask = (gray > 200).astype(np.float32) t_min_sky = 0.3 transmission = np.maximum(transmission, t_min_sky * bright_mask)这段代码的思路是:高亮区域(天空)的透射率不要低于 0.3,防止过度去雾导致偏蓝。实际项目中还需要结合具体图像亮度分布做调整。
5.4 去雾后噪点明显增多
透射率图越接近 0 的地方,恢复公式中1/t越大,噪声被放大得越明显。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 暗部区域噪点多 | 透射率过低 | 提升t0到 0.2 左右 |
| 整体噪点多 | 原图 ISO 过高或压缩严重 | 先做非局部均值去噪或双边滤波 |
| 局部过曝 | 大气光 A 过小 | 将 A 替换为原图高亮像素的均值而非最大值 |
如果时间允许,推荐先去噪再恢复:
denoised = cv2.fastNlMeansDenoisingColored(image, None, h=5, templateWindowSize=7, searchWindowSize=21) result, transmission, A = dehaze(denoised, patch_size=15)5.5 运行速度慢
暗通道的腐蚀操作在大尺寸图像上耗时明显,透射率的像素级操作也会拖慢整体速度。
优化建议:
- 对输入图先做下采样,去雾后再上采样恢复原尺寸;
- 使用 OpenCV 的
UMat加速; - 将透射率图缩放为原图的 1/4 进行导向滤波,减少计算量;
- 对视频流做去雾时,采用跳帧处理,间隔 2~3 帧做一次透射率估计,中间帧直接复用上一帧的透射率图。
# 下采样优化示例 height, width = image.shape[:2] small = cv2.resize(image, (width // 2, height // 2), interpolation=cv2.INTER_AREA) small_result, small_t, A = dehaze(small, patch_size=15) result = cv2.resize(small_result, (width, height), interpolation=cv2.INTER_LINEAR)6. 最佳实践与工程建议
6.1 参数调优思路
暗通道去雾算法参数不多,但每个参数对结果影响都很大。建议按以下顺序调参:
- 固定
patch_size=15、omega=0.95、t0=0.1,先跑通流程; - 观察是否偏暗、偏灰,微调
omega; - 观察边缘是否有光晕,调整
patch_size和导向滤波半径; - 观察天空区域是否偏色,引入天空掩膜修正;
- 最后结合项目实际做批量验证,记录每组参数在测试集上的 PSNR 或主观评分。
6.2 工程化部署建议
在实际项目中,去雾很少单独存在,通常作为图像预处理流水线的一环。
输入帧 -> 去雾 -> 去噪 -> 增强 -> 目标检测/分割/OCR -> 结果输出建议将去雾封装成独立服务或 Python 类,方便复用:
# 文件路径:dehaze_pipeline.py import cv2 from dehaze import dehaze class DehazeProcessor: def __init__(self, patch_size=15, omega=0.95, t0=0.1, use_guided_filter=True): self.patch_size = patch_size self.omega = omega self.t0 = t0 self.use_guided_filter = use_guided_filter self.transmission_cache = None def process(self, image): result, transmission, A = dehaze( image, patch_size=self.patch_size, omega=self.omega, t0=self.t0, use_guided_filter=self.use_guided_filter, ) return result6.3 针对视频流的处理策略
视频流去雾比单张图片更复杂,需要关注连续帧的稳定性。如果每一帧独立估计大气光,大气光 A 的抖动会导致画面闪烁。建议做法:
- 每隔一段时间(例如 30 帧)重新估计一次大气光 A,中间帧直接使用缓存值;
- 对透射率图做时间域平滑,例如当前帧透射率与前几帧透射率做加权平均;
- 如果场景变化剧烈,再强制更新。
alpha = 0.7 # 当前帧权重 smoothed_t = alpha * current_t + (1 - alpha) * previous_t6.4 与其他算法的联合使用
暗通道先验不是万能的。在浓雾、夜间雾、水下图像等场景,单独使用 DCP 效果会明显下降。工程上可以这样组合:
- 先使用 DCP 去雾,再用 CLAHE(限制对比度自适应直方图均衡化)增强局部细节;
- 对夜间有雾图像,先做亮度反转,再执行 DCP,最后再反转回来;
- 对水下图像,先做颜色校正(白平衡),再做去雾;
- 在算力允许的设备上,可以结合轻量级深度学习模型作为后处理修正。
# 示例:DCP + CLAHE 联合增强 result, _, _ = dehaze(image) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) lab = cv2.cvtColor(result, cv2.COLOR_BGR2LAB) lab[:, :, 0] = clahe.apply(lab[:, :, 0]) enhanced = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)6.5 数据安全与合规提醒
如果去雾算法被集成到安防、车载、医疗影像等生产系统中,需要注意:
- 处理个人图像数据时,遵循最小必要原则,不采集和存储无关用户数据;
- 对图像批处理操作做好权限管理,避免未授权访问;
- 涉及敏感区域图像时,处理流程应经过合规评审。
这些看似和算法无关,但一旦在生产环境出现问题,比算法效果差更麻烦。
7. 总结与学习路线
本文围绕《雾里》这个主题,完整实现了基于暗通道先验的图像去雾算法。核心收获包括:
- 理解大气散射模型
I(x) = J(x)*t(x) + A*(1-t(x)); - 知道暗通道先验的统计基础和适用边界;
- 能用 Python + OpenCV 从零实现暗通道计算、大气光估计、透射率估计和图像恢复;
- 知道导向滤波的作用和实现方式;
- 掌握常见问题(偏灰、光晕、天空偏色、噪点放大)的排查思路。
如果你的目标是做工业级去雾,下一步建议往这几个方向深入:
- 深度学习去雾:了解 AOD-Net、FFA-Net 等模型结构,用合成雾数据或真实雾数据训练,配合 DCP 作为先验约束;
- 真实雾数据集构建:DCP 在合成雾数据上效果好,但真实雾图往往存在非均匀雾、浓雾遮挡,需要针对性建数据集;
- 与其他视觉任务结合:把去雾模块接入目标检测、语义分割或车牌识别流程,从端到端指标评估收益;
- 硬件部署:将算法移植到 Jetson、瑞芯微等嵌入式平台,考虑 INT8 量化和算子融合。
实际项目中最优先关注的风险是天空区域偏色和视频闪烁。前者可以通过天空掩膜修正,后者需要合理利用透射率的时间平滑。建议先拿 3 到 5 张不同类型的雾图做参数验证,再逐步扩展到批量数据和视频流。
如果本文对你有帮助,可以收藏备用。后面再遇到浓雾、夜间雾、水下图像等更复杂的场景,也欢迎在评论区交流你的实战经验。