资讯动态

图像去雾实战:暗通道先验与导向滤波的C++工程实现

发布时间:2026/10/21 13:19:55 来源:尧图企业网站定制
简介这是一份基于暗通道先验与导向滤波的图像去雾算法C实现资源面向计算机视觉开发者、图像处理初学者及OpenCV使用者可帮助理解大气散射模型、透射率估计与导向滤波在去雾任务中的具体落地。资源包共6个文件压缩包大小287KB主要包含C源代码文件、头文件、工程配置文件以及效果预览图其中.cpp与.h实现了完整的去雾流程.vcxproj等为Visual Studio工程配置png图片便于直观对比去雾前后效果。已有2894人学习下载适合需要参考实际工程代码、快速复现或改进去雾算法的读者。通过该资源可掌握暗通道提取、透射率迭代计算、导向滤波平滑等关键环节的编码方式并借助OpenCV函数完成图像读取、通道分离与结果输出对在雾天或低能见度场景下提升图像清晰度具有直接参考价值。1. 图像去雾的工程现实为什么暗通道加导向滤波仍是首选做过户外视觉项目的人都有同感雾天抓回来的图对比度像蒙了一层灰纱特征点检测数量直接腰斩模板匹配的评分掉到没法看。两年前我在某巡检项目中试过基于深度学习的去雾模型效果是不错但换成嵌入式设备部署时模型转换、量化、算子支持这些环节把工期拖了将近三周。回头对比经典路线——何恺明提出的暗通道先验配合导向滤波做透射率细化纯 C 加 OpenCV 实现白天雾图去雾质量完全够用单帧处理时间能压到几十毫秒。这篇笔记就围绕这个组合展开从公式落地到 C 实现细节涵盖参数标定、常见翻车现象和视频流实时化的具体做法。适合需要在实际项目里集成去雾能力、又不想引入重型依赖的从业者。2. 暗通道先验做基础公式推导与大气光估计2.1 暗通道先验为什么成立先回顾物理模型。雾天成像可以写成I(x) J(x) * t(x) A * (1 - t(x))其中 I(x) 是观测到的有雾图像J(x) 是清晰辐射图t(x) 是透射率A 是全局大气光。目标是已知 I 和估计出 A、t反解 J。问题是方程只有一个约束却要解两个未知量这是病态问题。暗通道先验的贡献在于给出一个强有力的统计约束对户外无雾图像在绝大多数非天空局部区域内总存在至少一个颜色通道的值很低接近零。用公式表达就是J_dark(x) min( min_c J_c(y) ), y ∈ Ω(x) → 0其中 c 遍历 RGB 三个通道Ω(x) 是以 x 为中心的局部窗口相当于对每个像素先取三通道最小值再做一次最小值滤波。物理上对应阴影、彩色物体或暗色表面的存在。把 J_dark 约等于 0 代回物理模型整理后可解出透射率t(x) 1 - w * min_c ( I_c(y) / A_c ), y ∈ Ω(x)w 是保留少量雾气的常数一般取 0.9~0.96避免彻底去雾后图像显得生硬、不自然。这个推导很干净但工程落地有两个重点暗通道怎么算得快且准大气光 A 怎么估计得稳。2.2 暗通道计算的 C 实现计算暗通道在 OpenCV 里可以拆成两步先对每个像素取 RGB 三通道最小值这一步用 split 之后逐个求 min再对单通道图做最小值滤波很多人会自己写双层循环但正确做法是直接用 erode 腐蚀操作核选矩形结构元即可。#include opencv2/opencv.hpp cv::Mat calcDarkChannel(const cv::Mat src, int patchSize 15) { std::vectorcv::Mat channels; cv::split(src, channels); // 拆出 BGR 三个单通道图 cv::Mat minRGB cv::min(cv::min(channels[0], channels[1]), channels[2]); cv::Mat kernel cv::getStructuringElement( cv::MORPH_RECT, cv::Size(patchSize, patchSize)); cv::Mat dark; cv::erode(minRGB, dark, kernel); return dark; }逻辑说明split 之后 channels[0] 是 B 通道channels[1] 是 Gchannels[2] 是 R。min 函数做逐像素比较两次嵌套即可得到三通道最小值。erode 在最小值图上求局部最小值等价于严格的暗通道定义而且底层实现经过优化比手写滑窗快很多。patchSize 是窗口边长论文默认值是 15实际项目中大于该值的雾纹能更好消除小于该值的细节保留更多怀疑有薄雾残留时优先查这个参数。大气光 A 的估计要绕一个弯。直接在原图里取最亮像素通常不准因为白色墙体或车灯会污染估计。稳定的做法是在暗通道图上找最亮的前 0.1% 像素位置再回到原图的对应位置找这些像素的最大强度值作为 A。原因是暗通道亮说明该区域雾气浓度高而非物体本身亮。cv::Vec3f estimateAtmosphericLight(const cv::Mat src, const cv::Mat dark) { int totalPixels dark.rows * dark.cols; int sampleCount std::max(1, totalPixels / 1000); cv::Mat flatDark dark.reshape(1, 1); cv::Mat flatSrc src.reshape(1, 1); cv::Mat indices; cv::sortIdx(flatDark, indices, cv::SORT_EVERY_ROW cv::SORT_DESCENDING); float maxVal 0.0f; cv::Vec3f A(0.0f, 0.0f, 0.0f); for (int i 0; i sampleCount; i) { int idx indices.atint(0, i); cv::Vec3b pixel flatSrc.atcv::Vec3b(0, idx); float intensity pixel[0] pixel[1] pixel[2]; if (intensity maxVal) { maxVal intensity; A pixel; } } return A; }逻辑说明reshape(1, 1) 将图像展平成单行矩阵便于用 sortIdx 做全局排序。sortIdx 按降序排列暗通道值得到暗通道最亮的前 sampleCount 个像素位置。遍历这些位置在原图中寻找三通道和最大的像素作为 A。参数 sampleCount 取总像素数的千分之一与论文中 0.1% 的比例一致。需要注意这里取的是暗通道最亮区域中亮度最大的像素点而不是所有样本的均值两种取法在结果上差异明显选均值会让去雾偏保守。另外如果图像中存在大面积高亮白色区域A 会偏大输出图亮度整体偏低这时可以用后续的自动白平衡补偿。2.3 先验失效的场景与补救思路暗通道先验并不普适。天空区域本身亮度高J_dark 不为零直接套公式会出现偏色常见表现是天空变成灰黑色或出现大块色斑。另一个失效场景是图像中存在大片白色物体比如白色货车、雾天雪地先验的假设在这些区域被破坏。补救手段不复杂一个是在计算透射率时引入下限约束比如让 t(x) 不低于 0.1更精细的做法是在亮度极高区域对透射率做抬升让这些区域保留少量雾不强行去到底。这些策略在第 4 章完整管线中直接落地暂不展开。3. 透射率细化导向滤波的前沿推导与两套实现3.1 软抠图为什幺被替代暗通道公式求出的透射率图带有明显块状效应因为最小值滤波的窗口是矩形的透射率在物体边缘会产生突变。早期论文用软抠图对透射率做精细化重建透射率与原始透射率的误差、并加入平滑约束。这一方案的问题在于软抠图需要求解一个大型稀疏线性方程组计算量极高一张 600x800 的图耗时达数十秒工程上基本不可用。导向滤波被选中替代软抠图是有道理的。它假设输出 q 与引导图 I 之间存在局部线性关系q_i a_k * I_i b_k在窗口 ω_k 内 a_k 和 b_k 是常数。优化目标是让 q 与输入 p 的差异最小同时约束线性模型只在小窗口内成立。求解得到的 a_k、b_k 有闭式解整个算法复杂度是 O(N)与窗口大小无关。这意味着即使用大窗口做平滑计算量也不会显著上升非常适合图像去雾场景。3.2 引导图选择与参数直觉在去雾管线里引导图通常取原图的灰度图透射率粗估计作为输入 p。灰度图保留了边缘结构透射率在这些边缘处会跟着跳变从而让去雾后的物体边缘不发白。半径 r 控制的是局部窗口半径建议取图像短边的 1/50 到 1/30比如 600x800 的图像对应 r16~24。规则化系数 eps 控制平滑程度eps 越大输出越平滑细节丢失越明显eps 太小导向滤波接近保边滤波块状效应残留。常用的起点是 r16、eps1e-3归一化到 0~1 域后测试。这里要提醒一个细节。导向滤波对输入数据的数值范围敏感。如果透射率图是 CV_8U 类型取值 0~255eps 仍按 1e-3 设置平滑效果会完全不对。应先将透射率图转成 CV_32F 并归一化到 0~1 域eps 在这个数值量级下调试才有意义。我在第一次实现时就吃过这个亏用了很保守的 eps 值却只输出了一张几乎模糊的图仔细排查才发现数值域不一致。3.3 使用 ximgproc 模块与自实现两种路径如果 OpenCV 安装的是包含 contrib 模块的版本可以直接调用现成的导向滤波非常简单#include opencv2/ximgproc.hpp cv::Mat refineTransmission(const cv::Mat gray, const cv::Mat tCoarse, int radius, double eps) { cv::Mat tFloat, tRefined; tCoarse.convertTo(tFloat, CV_32F, 1.0 / 255.0); cv::ximgproc::guidedFilter(gray, tFloat, tRefined, radius, eps); cv::normalize(tRefined, tRefined, 0.0, 1.0, cv::NORM_MINMAX); return tRefined; }但这个模块坑也不少。很多预编译的 OpenCV 版本不带 contrib运行时会报undefined symbol或直接编译失败。如果不想重新折腾环境自实现导向滤波是靠谱的后备方案代码量并不大。cv::Mat guidedFilterSelf(const cv::Mat I, const cv::Mat p, int radius, double eps) { cv::Mat I32F, p32F; I.convertTo(I32F, CV_32F, 1.0 / 255.0); p.convertTo(p32F, CV_32F, 1.0 / 255.0); cv::Mat meanI, meanP, meanIp, meanII; int winSize 2 * radius 1; cv::blur(I32F, meanI, cv::Size(winSize, winSize)); cv::blur(p32F, meanP, cv::Size(winSize, winSize)); cv::blur(I32F.mul(p32F), meanIp, cv::Size(winSize, winSize)); cv::blur(I32F.mul(I32F), meanII, cv::Size(winSize, winSize)); cv::Mat varI meanII - meanI.mul(meanI); cv::Mat covIp meanIp - meanI.mul(meanP); cv::Mat a covIp / (varI eps); cv::Mat b meanP - a.mul(meanI); cv::Mat meanA, meanB; cv::blur(a, meanA, cv::Size(winSize, winSize)); cv::blur(b, meanB, cv::Size(winSize, winSize)); return meanA.mul(I32F) meanB; }逻辑说明这是导向滤波标准的盒式滤波版本。先计算窗口内的均值、方差和协方差然后求出线性系数 a、b最后对 a、b 再求均值后应用到引导图上。盒式滤波在 OpenCV 里用 blur 完成窗口大小由 radius 决定。用自实现版本配合 OpenCV 的 core 功能就能跑通不依赖任何扩展模块交叉编译到嵌入式设备时省心很多。和 ximgproc 版本相比自实现与官方版本输出差异很小主要体现在边界处理方式上效费比很高我一般优先在项目里放自实现版本。4. 完整去雾管线从单帧图像到批量图片处理4.1 主流程代码与关键节点把前面几个函数组装起来就得到完整的去雾管线流程是读图 → 计算暗通道 → 估计大气光 → 计算粗透射率 → 导向滤波细化 → 恢复无雾图 → 可选的后处理与色彩校正。#include opencv2/opencv.hpp #include guided_filter.h cv::Mat dehazeImage(const cv::Mat src, int patchSize 15, int radius 16, double eps 1e-3, float w 0.95f, float tMin 0.1f) { CV_Assert(src.type() CV_8UC3); cv::Mat gray; cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); cv::Mat dark calcDarkChannel(src, patchSize); cv::Vec3f A estimateAtmosphericLight(src, dark); std::vectorcv::Mat channels; cv::split(src, channels); cv::Mat tCoarse(src.size(), CV_32FC1); for (int r 0; r src.rows; r) { for (int c 0; c src.cols; c) { float minRatio 1.0f; for (int ch 0; ch 3; ch) { float ratio channels[ch].atuchar(r, c) / std::max(A[ch], 1.0f); minRatio std::min(minRatio, ratio); } tCoarse.atfloat(r, c) 1.0f - w * minRatio; } } cv::Mat tRefined guidedFilterSelf(gray, tCoarse, radius, eps); cv::Mat tClamped; cv::threshold(tRefined, tClamped, tMin, tMin, cv::THRESH_TOZERO); cv::Mat result cv::Mat::zeros(src.size(), CV_32FC3); for (int r 0; r src.rows; r) { for (int c 0; c src.cols; c) { float t std::max(tClamped.atfloat(r, c), tMin); for (int ch 0; ch 3; ch) { float pix channels[ch].atuchar(r, c); float restored (pix - A[ch]) / t A[ch]; result.atcv::Vec3f(r, c)[ch] restored; } } } cv::Mat result8U; result.convertTo(result8U, CV_8UC3, 1.0); return result8U; }逻辑说明大气光 A 取的是原图像素值范围是 0~255因此像素比值 minRatio 的计算必须保持同一数值域这里 A[ch] 就是 0~255 的 uchar 值注意用 max 防止除零。粗透射率定义与公式一致减掉的是三个通道归一化比值的最小值。导向滤波输入直接把 tCoarse 传进去它会内部转成 0~1 域而不需要外部再归一化。重建时单个像素处 t 取 thresh 后值与 tMin 的较大者这是避免 t 趋近 0 时噪声被无限放大的关键。由于 t 的下限在 where 处被 clamp景深区域的恢复结果不会过曝但注意在 tClamped 这一步先做了一个 tozero重建时统一用 max 再钳制一次逻辑上更稳妥省掉了一次遍历也可以接受。4.2 自动白平衡后处理去雾后的图像常有色偏尤其是 A 估计偏暖色时整幅图蒙上一层黄或红色在视觉检测场景会影响颜色特征提取。此时需要做一次快速白平衡一般选择灰度世界法或白点法去雾场景下灰度世界法更稳因为它不依赖极端亮像素的判断。cv::Mat applyGrayWorldWB(const cv::Mat src) { std::vectorcv::Mat channels; cv::split(src, channels); double meanB cv::mean(channels[0])[0]; double meanG cv::mean(channels[1])[0]; double meanR cv::mean(channels[2])[0]; double meanAll (meanB meanG meanR) / 3.0; std::vectorcv::Mat resultChannels; channels[0] * (meanAll / std::max(meanB, 1e-6)); channels[1] * (meanAll / std::max(meanG, 1e-6)); channels[2] * (meanAll / std::max(meanR, 1e-6)); cv::Mat result; cv::merge(channels, result); result.convertTo(result, CV_8UC3); return result; }逻辑说明灰度世界法假设均衡光照下三通道均值相等把各通道拉伸到同一均值即可。像素计算用 floating point 乘法最后 convertTo 回 CV_8U 时 OpenCV 会自动饱和截断不会溢出回绕。有个经验是去雾后再做白平衡比先白平衡再去雾效果好因为去雾本身会改变全局亮度分布提前校正反而引入干扰。4.3 参数标定与组合策略参数不是越多越好。patchSize、radius、eps、w、tMin 这 5 个参数在具体场景中需要调节的优先级不同。patchSize 影响全局雾感消除程度radius 与 eps 影响边缘与去块效应w 影响整体去雾强度tMin 影响远景区域的亮度。我一般按这样的顺序调参先固定 patchSize15、w0.95、tMin0.1调 radius 与 eps 让边缘不出现白边再观察远景是否过暗或发灰最后回看 patchSize。批量调优时可以用远近距离两张图做基准避免被单张图的偶然效果带偏判断。5. 避坑指南五个常见问题与排查记录5.1 现象天空区域出现灰黑色斑块现象是去雾后天空区域产生大块灰黑色或蓝黑色斑块纹理完全丢失在蓝天与白云交界处尤其明显。原因有两层。暗通道先验对天空失效——天空本身亮度高J_dark 不为零t 被公式算小加上大气光 A 取的是暗通道最亮的前 0.1% 像素位置如果这些像素全部落在天空处A 偏大天空处透射率又被低估双重误差叠加后产生色斑。解决路径对亮度高于阈值 A 的区域进行透射率抬升。一个常见做法是引入容差机制让高光区域的 patchSize 自动下降另一种更工程化的手段是设置 tBaseLower min(t(x), tMaxForBrightArea)即对亮度超过大气光值的区域将 t 限制在 0.3~0.5 区间防止去雾过猛。我通常选用后者代码改动小、可解释性强。5.2 现象边缘出现白边或光晕效应物体深色边缘外侧出现白色轮廓类似抠图残留在树枝、建筑边缘很明显。原因是透射率在深度跳变处没有完全跟随边缘导向滤波的半径不足以覆盖透射率突变宽度或者 eps 过大导致透射率被过度平滑。透射率突变未恢复重建时边缘处补偿过度。解决路径先检查 guidedFilterSelf 的输入确认 gray 被正确归一化且 eps 是相对 0~1 域的值然后调小 eps从 1e-3 降到 1e-4 量级如果白边依旧再增大 radius从 16 提高到 24 以上让滤波的局部窗口覆盖透射率跳变的宽度。经验是 radius 增大后白边一般能被压下去代价是细节处会有轻微模糊需要平衡。5.3 现象编译提示找不到 guidedFilter 符号在项目里 include opencv2/ximgproc.hpp 之后编译或链接阶段报 undefined reference to ximgproc 相关符号。原因很简单。当前 OpenCV 安装版本不含 contrib 模块或者链接时忘了加 opencv_ximgproc 库。解决路径最可靠的方向是直接用自实现的 guidedFilterSelf 替换所有调用绕开 ximgproc。判断方法写一个只包含 guidedFilter 的最小测试程序编译如果仍报符号未定义基本可以确认是库缺失。如果坚持用 ximgproc就得重新编译安装带 contrib 的 OpenCVCMake 参数打开 OPENCV_ENABLE_NONFREE 和 BUILD_opencv_ximgproc。注意某些 3.x 版本的 contrib 分支与主版本号严格对应分支对不上也会出现编译错误。5.4 现象去雾后图像偏红或偏黄整体色偏向暖色白色物体呈现淡黄色或淡红色颜色特征提取异常。原因是大气光 A 估计收到暖色调物体的干扰。暗通道最亮的前 0.1% 像素若落在黄色墙面或红色车身上A 的 R 通道偏高重建时 R 通道补偿幅度过大。解决路径先做 A 估计的修正——不在单帧上取最大而是对暗通道前 0.1% 像素位置在原图的像素做三通道均值而不是三通道和最大。再叠加灰度世界白平衡做二次校正。两个手段配合后色偏问题基本能消失。如果仍未消掉检查输入的 JPEG 是否存在压缩色偏可以换成 PNG 图做对比实验。5.5 现象处理结果出现大量颗粒噪声暗区或天空过渡区的去雾结果有明显的颗粒状噪声类似照片 ISO 调高后的效果。原因是 t(x) 较小重建时公式中的 1/t 放大了噪声尤其是原图在暗部有传感器噪声时。导向滤波虽然平滑了透射率但噪声的放大发生在重建环节透射率再平滑也无法完全抑制。解决路径一是把 tMin 从 0.1 上调到 0.15~0.2限制噪声放大的程度二是在重建前对原图做一次轻微的快速去噪比如半径 2~3 的 bilateralFilter不要用高斯模糊否则边缘细节会糊掉三是检查相机是否开启了过高的 ISO传感器端的噪声会在去雾后成倍放大这个问题算法层缓解有限还是要在采集端解决问题。6. 进阶技巧把透射率帧复用做到视频实时去雾去雾处理单帧图在桌面 CPU 上做到 40~80ms 很容易但视频流需要 30fps 时就捉襟见肘。我常用的手段是缓存透射率在连续帧之间复用。雾的分布随时间变化远慢于物体内容的变化透射率主要取决于深度和雾浓度短时间间隔内变化很小。因此在视频流中每隔 N 帧计算一次暗通道和透射率其余帧直接复用最近的透射率只做重建的加减除操作计算量大幅下降。N 的取值取决于场景中雾的稳定性一般取 5~8 帧运动较快时取 3~5 帧重影和抖动会在移动目标边缘出现轻微闪烁。class ToneMapReuse { public: cv::Mat process(cv::Mat frame) { frameCounter; if (frameCounter % reuseInterval 1) { cv::Mat gray; cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY); cv::Mat dark calcDarkChannel(frame, 15); cv::Vec3f A estimateAtmosphericLight(frame, dark); cv::Mat t coarseTransmission(frame, A, 0.95f); tRefined guidedFilterSelf(gray, t, 16, 1e-3); } return reconstruct(frame, A, tRefined, 0.1f); } private: long frameCounter 0; int reuseInterval 5; cv::Mat tRefined; cv::Vec3f A; };逻辑说明tRefined 和 A 只在每第 reuseInterval 帧更新一次其余帧直接复用。这样每帧只做一次重建遍历和一次 convertTo实测性能从 60ms 降到约 15ms 左右。代价是目标快速穿过雾区时透射率更新滞后导致边缘处透射率错误极端情况下会出现短暂光晕但对车载、巡检等大多数场景来说这个权衡是划算的。调参时要注意一个细节reuseInterval 的取值不是越大越好。帧率 25fps 时间隔 5 帧等于透射率每 200ms 更新一次基本无感间隔 30 帧等于 1.2 秒才更新一次雾浓度骤变时会有一两秒的过曝或欠去雾画面会突然闪烁一下。为了保证体验稳定reuse 间隔取 5~10 之间最稳妥同时把重建的 tMin 适当调高至 0.15给误差留出缓冲空间。从那以后我每次做视频去雾都要把这个透射率缓存策略加进去CPU 占用立刻降下来对比同帧率下全量计算的方案画质差距肉眼几乎看不出来。如果在多路视频同时处理的场景中这个技巧节省的性能还能再翻倍。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑