简介:基于暗通道先验与导向滤波的图像去雾算法完整C++实现,面向需要处理雾霾图像、理解去雾原理的计算机视觉开发者与学生。压缩包共6个文件,包含核心头文件、C++源文件、VS工程配置、效果示例图等,整体约287KB,结构紧凑便于直接查看与复现。已有2892人学习下载。内容涵盖暗通道提取、大气散射模型、迭代透射率估计、导向滤波平滑及图像恢复等关键环节,借助OpenCV库完成通道分离、最小值滤波与导向滤波操作,帮助读者掌握从原理推导到工程落地的完整流程,尤其适合在能见度较低或光照复杂场景下提升图像清晰度的应用开发。 图像去雾这个方向,我最早是在做行车记录仪视频增强时接触到的。雾天拍的画面发白、对比度低,直接拿来做车道线检测,准确率掉得没法看。当时先试了直方图均衡和Retinex,效果怎么说呢——要么偏色,要么在景深突变的地方冒出黑边。后来换成何恺明那篇暗通道先验(Dark Channel Prior)的做法,再用导向滤波把透射率图细化,才算是把画质和性能都拉到了能用的水平。这篇文章就把我这个C++版本的实现思路、关键代码和踩过的坑完整写出来,给正在做图像增强或者要在边缘设备上跑视觉算法的朋友一个参考。
1. 算法的整体设计与原理拆解
先想明白一个问题:为什么雾天图像会发灰?光在大气中传播时,一部分被悬浮颗粒散射掉,再叠加环境光的照射,导致相机接收到的光强度是“物体反射光衰减后加大气光叠加”的结果。这就是经典的大气散射模型:
I(x) = J(x)t(x) + A(1 - t(x))
其中 I(x) 是观测到的有雾图像,J(x) 是我们要恢复的无雾图像,t(x) 是透射率(0到1之间),A 是全局大气光。
1.1 暗通道先验解决什么问题
何恺明的核心发现在于:对绝大多数户外无雾图像来说,在局部区域里总存在某个颜色通道的像素值非常低,接近0。这个统计规律就是暗通道先验:
J_dark(x) = min( min( J_c(y) ) ) → 0
内层min是取RGB三通道的最小值,外层min是在以x为中心的局部窗口内取最小值。有了这个先验,就能从单张图估算透射率,这是单图去雾能成立的根本前提。
如果不需要暗通道先验,那也可以通过其他方式估深度或透射率,比如多图融合、偏振光、深度传感器,但这些方案要么硬件成本高,要么场景受限。暗通道方案的优势是仅需单张RGB图,算法复杂度可控,非常适合工程落地。我把这个出发点先讲清楚,就是因为后面所有参数调节和边界情况处理,都是围绕这个统计规律在转。
1.2 导向滤波引入的动机
透射率如果直接按暗通道做最小值滤波,得到的是一个块状图——每个小方块内部数值相同、边缘突变,恢复出来的图像在深度不连续处会有明显的光晕伪影。导向滤波(Guided Filter)的解决办法是:用一个指导图(通常就是原图的灰度图)来引导透射率图,使透射率的边缘结构与原图保持一致,同时数值上保持平滑。
导向滤波的核心假设是:在一个局部窗口内,输出图像与引导图像之间存在线性关系:
q_i = a_k * G_i + b_k
a_k 和 b_k 是在窗口 k 内保持不变的系数。这样得到的输出既能保留引导图的边缘结构,又能在平坦区域平滑过渡。在去雾场景里,我实测下来,导向滤波的耗时比soft matting低一个数量级,而且效果几乎没差别,所以工程方案几乎都选它。
2. 工程架构与关键参数设计
2.1 整体流程分几步
整个去雾管线可以分为四步:读图与预处理、估算大气光、计算透射率并用导向滤波细化、按模型反演恢复无雾图像。代码结构我按照这四个步骤拆成独立函数,方便后续单独调参或替换模块。
#include <opencv2/opencv.hpp> #include <iostream> #include <vector> cv::Mat calcDarkChannel(const cv::Mat& src, int radius); cv::Mat estimateTransmission(const cv::Mat& dark, const cv::Mat& gray, float omega); cv::Mat guidedFilter(const cv::Mat& guide, const cv::Mat& src, int radius, float eps); void estimateAtmosphericLight(const cv::Mat& src, const cv::Mat& dark, cv::Scalar& A); int main(int argc, char** argv) { cv::Mat src = cv::imread(argv[1]); if (src.empty()) { std::cerr << "Failed to load image!" << std::endl; return -1; } // ... 依次调用各模块 }2.2 参数选择背后的权衡逻辑
窗口半径是第一个要调的参数。暗通道求最小值用的窗口半径 r 越大,暗通道越能覆盖到“某个颜色通道接近0”的像素,估算的透射率越可靠,但块效应也越明显。我用15×15窗口(半径7)作为默认值,在1080P图像上细节保留和整体亮度之间平衡得最好。
omega(去雾程度系数)的取值范围是0到1,一般取0.9到0.95。omega太小,去雾不彻底,画面还是灰蒙蒙的;omega取1.0又会过度增强,天空等区域容易出现色斑。我常用0.95,如果雾特别浓,会稍微降到0.9,保留一些空气透视感,画面反而更自然。
导向滤波的正则化参数 eps 也很关键。eps 太小,导向滤波退化成几乎不滤波,透射率图仍然很碎块化;eps 太大,细节全被抹平。我的经验是取 0.01 到 0.1 之间,具体看图像噪声水平,噪声高就调大一点。
3. 核心模块的C++实现细节
3.1 暗通道计算的两种加速方式
最直接的方式是用OpenCV的 erode 函数——灰度腐蚀本质上就是求局部最小值。先把原图每个像素取RGB三通道最小值得到单通道图,然后用矩形结构元素做腐蚀。结构元素尺寸和暗通道窗口对应即可。
cv::Mat calcDarkChannel(const cv::Mat& src, int radius) { cv::Mat minCh(src.size(), CV_8UC1); std::vector<cv::Mat> channels(3); cv::split(src, channels); cv::min(channels[0], channels[1], minCh); cv::min(minCh, channels[2], minCh); cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(2 * radius + 1, 2 * radius + 1)); cv::Mat dark; cv::erode(minCh, dark, kernel); return dark; }如果图像很大或者视频流连续处理,erode这种带优化的形态学操作已经够快。但我在做嵌入式移植时发现,有些库的erode实现不是真正可分离的,对超大核(比如31×31)耗时会明显上涨。这时候就自己写一个基于积分图的滑窗最小值算法:先对每行做滑窗取最小值得到中间结果,再对中间结果的每列做滑窗取最小值。时间复杂度从 O(NWH) 降到 O(NWH),N是窗口半径。这里的思路其实和滑动窗口最大值那个经典题一样,用双向队列维护单调队列。
3.2 导向滤波的完整推导落地
导向滤波的实现有现成函数 cv::ximgproc::guidedFilter,但OpenCV的contrib模块不一定被编译进你的环境。我自己实现了核心逻辑,基于boxFilter的组合运算,代码量不大,依赖少,也方便后续用SIMD指令优化。
实现的数学基础是窗口内的线性回归。设引导图为G,输入图为P,输出图为Q。在窗口w_k内,系数 a_k 和 b_k 的计算公式为:
a_k = ( cov(G,P) / (var(G) + eps) ) b_k = mean(P) - a_k * mean(G)
其中 cov(G,P) 是G和P在窗口内的协方差,var(G) 是G的方差。每个像素会被多个窗口覆盖,最终输出取所有覆盖它的窗口的系数平均值。
cv::Mat guidedFilter(const cv::Mat& guide, const cv::Mat& src, int radius, float eps) { cv::Mat guideF, srcF; guide.convertTo(guideF, CV_32F); src.convertTo(srcF, CV_32F); cv::Mat meanG, meanP, corrG, corrGP; cv::boxFilter(guideF, meanG, CV_32F, cv::Size(2 * radius + 1, 2 * radius + 1)); cv::boxFilter(srcF, meanP, CV_32F, cv::Size(2 * radius + 1, 2 * radius + 1)); cv::boxFilter(guideF.mul(guideF), corrG, CV_32F, cv::Size(2 * radius + 1, 2 * radius + 1)); cv::boxFilter(guideF.mul(srcF), corrGP, CV_32F, cv::Size(2 * radius + 1, 2 * radius + 1)); cv::Mat varG = corrG - meanG.mul(meanG); cv::Mat covGP = corrGP - meanG.mul(meanP); cv::Mat a = covGP / (varG + eps); cv::Mat b = meanP - a.mul(meanG); cv::Mat meanA, meanB; cv::boxFilter(a, meanA, CV_32F, cv::Size(2 * radius + 1, 2 * radius + 1)); cv::boxFilter(b, meanB, CV_32F, cv::Size(2 * radius + 1, 2 * radius + 1)); cv::Mat q; cv::Mat result = meanA.mul(guideF) + meanB; result.convertTo(q, src.type()); return q; }这里有个关键点:boxFilter 的窗口半径建议和暗通道窗口半径一致,否则透射率图会出现“两层滤波纹理叠加”的奇怪现象。我在实际测试中发现,如果半径不匹配,恢复出来的图像边缘会出现双重轮廓,非常明显。
3.3 大气光估计不能直接取最亮像素
教科书版本说取暗通道里最亮的前0.1%像素对应的原图亮度作为大气光。但是直接取最亮单点容易被白墙、车灯这类高亮物体干扰。稳妥的做法是:把暗通道前0.1%的像素坐标收集起来,然后在原图上取这些坐标位置的RGB平均值作为大气光。这样抗噪能力强很多,天空区域的估算也不会被孤立的亮噪声带偏。
void estimateAtmosphericLight(const cv::Mat& src, const cv::Mat& dark, cv::Scalar& A) { int numPixels = dark.rows * dark.cols; int numTop = std::max(1, numPixels / 1000); std::vector<int> idx(numPixels); for (int i = 0; i < numPixels; i++) idx[i] = i; std::partial_sort(idx.begin(), idx.begin() + numTop, idx.end(), [&](int i, int j) { return dark.at<uchar>(i / dark.cols, i % dark.cols) > dark.at<uchar>(j / dark.cols, j % dark.cols); }); double sumB = 0, sumG = 0, sumR = 0; for (int k = 0; k < numTop; k++) { int y = idx[k] / dark.cols; int x = idx[k] % dark.cols; cv::Vec3b p = src.at<cv::Vec3b>(y, x); sumB += p[0]; sumG += p[1]; sumR += p[2]; } A = cv::Scalar(sumB / numTop, sumG / numTop, sumR / numTop); }4. 常见问题与排查技巧实录
4.1 偏色问题:天空区域发紫或发蓝
如果大气光估计偏高,透射率会偏小,恢复时整体亮度过曝;如果大气光偏低,天空区域恢复过度,容易出现紫色或者青色的伪彩色。我的排查思路是先打印出A的三个通道数值,如果B通道明显高于R通道(差距超过30),就要怀疑大气光取值点落在了天空高亮区域。处理方式是加入一个掩膜:暗通道亮度低于A对应灰度的像素,透射率直接置为1,不参与恢复计算。
4.2 光晕伪影
光晕一般出现在物体边缘,比如树叶和天空的交界处。原因要么是导向滤波半径不够导致透射率图边缘没有对齐,要么是原图本身有压缩噪声。我先增加导向滤波半径到16,同时把eps调大到0.1,如果还不理想,就在计算透射率前对原图做一次快速双边滤波,边缘和纹理都会被保护得更好。
4.3 性能瓶颈
整条管线最耗时的两个环节依次是:暗通道的滑窗最小值和导向滤波中的多次boxFilter。实测在单核2.4GHz的CPU上处理1080P图像,总耗时约180毫秒,其中导向滤波占了约110毫秒。优化手段有三个方向:一是把boxFilter换成积分图实现,耗时跟窗口大小解耦;二是用并行执行计算每个颜色通道的恢复;三是在视频流里用上一帧的大气光作为当前帧的初始估计,跳过大气光排序那一步,能省掉将近30毫秒。
4.4 极端天气下的失效场景
暗通道先验在浓雾均匀覆盖且场景中不存在任何色彩丰富的物体时会失效。比如纯白雾中拍白墙,画面几乎没有任何暗像素,A的估计值会超过255,透射率趋近于0,恢复结果出现大量噪声。这个问题没有完美的解决办法,一个折中方案是限制最大透射率比例,防止过度放大噪声,或者检测到暗通道均值超过阈值(比如150)时直接融合亮度均衡方法。
5. 从单张图到视频流的工程扩展
单张图像的去雾能跑通之后,视频流处理还有几个工程细节要处理。一个是时间一致性:相邻帧的大气光突变会导致画面闪烁,所以要对大气光做指数滑动平均,让参数平缓过渡。另一个是ROI控制:车载场景里,天空只在画面上半部分,可以对下半部分单独计算暗通道来估算大气光,上半部分直接套用,减少无效计算区域。
再说一个小经验:不要直接在8位图上做浮点运算,先在CV_8U下把能算的都算完,只在透射率计算和最后恢复时切换到CV_32F。8位运算速度快,而且归一化后的结果在边缘上不会出现浮点截断造成的条纹。
最后给一个实用的调试小技巧:在估算透射率时,把中间结果 t(x) 保存成可视化的灰度图——透射率越小(雾越浓)显示越暗,越大显示越亮。用这种方式,你能肉眼快速判断是A估计错了还是透射率边缘对齐出了问题,比盯着终端里的数字效率高得多。
本文还有配套的精品资源,点击获取