FEATURED · 精选文章

单图像去雾算法实践:边界约束与上下文正则化的Python实现

发布时间 / 2026/9/8 8:26:06
来源 / 创域科博编辑部
栏目 / 资讯中心
单图像去雾算法实践:边界约束与上下文正则化的Python实现 简介基于边界约束与上下文正则化的单图像去雾算法Python实现面向计算机视觉与图像处理开发者用于从单张雾霾图像中恢复清晰场景提升图像可辨识度与后续分割、检测任务效果。包体大小约7.68MB具体文件总数与类型明细未标注核心内容为Python源码及依赖配置文件支持通过pip直接安装调用image_dehazer.remove_haze即可快速完成去雾处理。资源附有简明调用示例读者可结合OpenCV读取图像并输出增强结果适合希望复现论文算法、搭建去雾Demo或嵌入自建图像预处理流程的开发者。目前已有227人学习可用于图像去雾算法原理验证、参数调优及二次开发。 做图像去雾这些年Single-Image-Dehazing 一直是绕不开的入门方向。最近我把一个基于“边界约束和上下文正则化”的 matlab 去雾工程完整移植成了 Python也就是 Meng 那篇经典工作的复现版。整个过程踩了不少坑也把参数调到了比较顺手的状态这篇博文就完整记录一下我的实现思路、核心代码和调参经验给想在 Python 环境里跑通这个算法的朋友一个可以直接参考的版本。尤其如果你手头也有对应的 matlab 代码可以对照着看边界条件、数据格式、矩阵索引这几个地方几乎是必然踩雷的重灾区。1. 项目在解决什么问题去雾不只是“拉对比度”1.1 一个退化模型说清楚雾图的本质去雾任务背后是一个很简洁的物理模型叫做大气散射模型I(x) J(x) · t(x) A · (1 - t(x))这里的 I(x) 是相机拍到的雾图J(x) 是我们要恢复的清晰场景A 是全局大气光t(x) 是透射率。透射率 t 和景深 d 的关系是指数衰减的t(x) e^(-βd)β 是大气散射系数。所以雾越浓、物体越远透射率越低画面就越灰白。这个公式意味着去雾的关键就两步先估计出 A 和 t然后反解出 JJ(x) (I(x) - A) / t(x) A从数学上看特别简单但实际做起来问题很多。最大的麻烦是 t(x) 是逐像素变化的而且一张图里不同景深的物体透射率差异巨大如果估计不准恢复出来的图像要么过暗要么在边缘处出现一圈亮边。这也是为什么很多新手直接把对比度拉伸当去雾效果虽然“看着通透”但颜色严重失真根本不符合物理退化过程。1.2 为什么不直接用暗通道先验在单图去雾领域何恺明提出的暗通道先验Dark Channel Prior是最广为人知的方法核心假设是户外无雾图像的局部区域里至少有一个颜色通道的像素值很低。这个先验在很多场景下很有效实现成本也低我一个下午就能写出来。但它有几个非常明显的软肋一是天空区域、白色墙面、车灯等大面积亮色区域完全不满足暗通道假设经常被误判为“浓雾”导致恢复结果偏暗偏蓝二是基于块操作估计透射率时容易产生块状伪影和光晕边缘处尤其明显三是对全局大气光很敏感A 稍微估高一点整张图的色调就跑到另一个方向去了。而边界约束和上下文正则化这个路线本质上是在回答一个问题如果不依赖“暗通道极低”这种统计假设能不能从像素本身的物理取值范围推导出透射率的合理边界答案是可以。因为任何场景辐射 J 的像素值都应该落在某个区间内把这个区间代入大气散射模型就能反推出 t(x) 的一个下界。这个约束对天空、白色物体同样适用天然比暗通道更稳。我这次移植的核心工作就是把 matlab 里那套基于约束估计和正则化优化的流程在 Python 里完整复刻出来并针对实际图像做了细节增强处理。2. 边界约束和上下文正则化原理拆解2.1 边界约束从像素上下限推出透射率下界边界约束的思路很直接。假设清晰图像 J 的每个通道都落在 [J0, J1] 之间比如归一化到 [0,1] 后J0 可以取 0.05 或 0.1J1 取 1。把 I Jt A(1-t) 变形t (A - I) / (A - J)因为 A 通常大于 J所以分母为正。J 取不同值时t 会在这个区间内变化。为了让恢复结果不突破边界透射率至少不能小于 J 取最大值 J1 时对应的 t。于是得到逐像素下界t(x) ≥ max_c (A_c - I_c(x)) / (A_c - J1)这里 c 是 RGB 三个通道取三个通道中的最大值作为最终下界。之所以取 max是因为三个通道只要有一个不满足边界恢复出的像素就会超出合法范围。实际代码里还需要加一个下限保护比如 t(x) ≥ 0.15防止透射率太接近 0 导致分母爆炸、天空区域出现噪声放大。这个 J1 的选择直接决定恢复强度。J1 越接近 1透射率下界越小去雾力度越强但风险是图像发暗J1 取小一点比如 0.8保留下来的雾感更多画面更自然。我在迁移时默认用的是 J11.0J00.1这样与论文的默认实验设置保持一致后续调参时再根据具体图片微调。2.2 上下文正则化给透射率图“上保险”有了逐像素下界 t0(x) 之后理论上可以直接用它去恢复图像但这样得到的透射率图通常是噪声很大的相邻像素差异明显恢复结果会有大量伪影。原因很简单t0 只利用了单个像素的灰度信息完全没有考虑图像的空间结构。上下文正则化要解决的就是这个问题。论文里的优化目标是一个加权 L1 范数问题min_t ||W ∘ (t - t0)||_1 λ Σ_i ||W_i ∘ D_i t||_1其中 ∘ 表示逐元素相乘D_i 是一阶差分算子水平和垂直W 是数据项权重W_i 是根据原图梯度构造的正则化权重λ 控制平滑强度。这个式子的直观含义是第一项要求 t 不要偏离 t0 太远第二项要求 t 在局部尽量平滑。但因为是 L1 范数它不会像 L2 那样把所有细节都磨平而是允许在强边缘处出现明显跳变。这正好是去雾需要的透射率在物体边缘应该突变在平坦区域应该缓变。W_i 的构造也很关键。我沿用了论文的常用做法用原图灰度图的梯度信息来生成权重梯度大的位置权重小这样正则化不会惩罚边缘梯度小的位置权重大促进平滑。相当于给优化过程提供了一张“导引图”让透射率图的边缘和原图边缘对齐可以理解为去雾版的导向滤波思想。2.3 大气光估计干不好后面全白搭大气光 A 估计错误后面所有步骤都会跟着错。A 偏低会让去雾结果偏灰A 偏高会让天空区域过曝、颜色发黄。最经典的做法是从暗通道中选前 0.1% 最亮的像素然后取这些像素在原图中的最大灰度值作为 A。这里有两个细节值得注意第一matlab 代码里常见的操作是先构造暗通道再排序、取索引、找对应位置Python 里对应用 np.argpartition 可以非常快地取出前 N 个像素而不需要完整排序。第二A 的取值有两种常见策略取 top 像素的均值或者取 top 像素中的最大亮度。均值更稳定不容易被极端噪点带偏最大值更保守适合浓雾图。我在项目里默认用均值理由是实拍雾图里暗通道最亮的区域往往不止一个点平均一下更好抵抗噪声。3. 从 matlab 到 Python核心代码实现要点3.1 环境与数据类型最容易翻车的地方这次移植我只用了三个库numpy、opencv、scipy。OpenCV 负责图像读写和滤波numpy 负责矩阵运算scipy 用来解稀疏线性方程。环境准备本身不复杂但有几个坑必须提前说清楚。最典型的是数据类型问题。matlab 里处理图像默认是 double 类型数值范围 0 到 1所有运算都是浮点。而 OpenCV 读进来的图像默认是 uint8范围 0 到 255。如果你直接拿 uint8 去做除法结果会被截断成整数透射率下界算出来全是 0 或负数效果直接崩掉。我习惯在读完图像后立刻转成 float64 并除以 255所有中间过程保持浮点最后再转回 uint8 保存。另一个坑是通道顺序。OpenCV 读图是 BGRmatlab 是 RGB。处理单通道灰度图没问题但涉及彩色图像的通道运算时要么注意顺序要么干脆用 cv2.cvtColor 统一转成 RGB 再处理否则保存出来的图颜色完全不对。3.2 初始化透射率和大气光的 Python 实现下面是我实际用的两个核心函数都做了精简但保留了完整逻辑。首先是大气光估计import cv2 import numpy as np def estimate_atmosphere(img, dark_channel, top_ratio0.001): h, w dark_channel.shape n max(1, int(h * w * top_ratio)) # 找到暗通道最亮的前 n 个像素索引 flat_idx np.argpartition(dark_channel.ravel(), -n)[-n:] # 映射回原图的像素 bright_pixels img.reshape(-1, 3)[flat_idx] # 方法一均值稳定抗噪 A bright_pixels.mean(axis0) # 方法二取最大值更保守 # A bright_pixels.max(axis0) return A然后是初始透射率估计。这里的 J0、J1 就是上文说的场景辐射边界默认取 0.1 和 1.0def initial_transmission(img, A, J00.1, J11.0, t_min0.15): img img.astype(np.float64) A A.astype(np.float64) # 透射率下界: (A - I) / (A - J1) denom A - J1 t_lower (A[None, None, :] - img) / denom[None, None, :] # 三个通道取最大值得到逐像素下界 t0 np.max(t_lower, axis2) # 裁剪到合理范围 t0 np.clip(t0, t_min, 1.0) return t0这里有个细节分母 A - J1 在归一化后通常是负值或接近 0 的值所以很多人会困惑为什么 t0 的值偏大或偏小。建议每次算完之后打印一下 t0 的分布区间如果大量像素都贴着 1.0 或 t_min说明 J1 设置不合理或者图像太特殊需要回头调参数。3.3 上下文正则化的工程近似与完整求解严格按论文实现 L1 优化需要变量分裂和迭代收缩代码量大、迭代慢我一开始照着写了一遍效果确实好但一张 600x800 的图要跑差不多半分钟对于快速验证非常不友好。后面我换了一种工程做法把 L1 优化中的加权正则项用迭代重加权最小二乘IRLS来近似同时用导向滤波对 t0 做一次边缘保持平滑两者结合。IRLS 的核心思路是把 L1 项写成带权重的 L2 形式然后用共轭梯度法迭代求解。虽然理论上要多次迭代才能收敛但实践中迭代 5 到 10 次已经能拿到很好的透射率图。核心方程是(W λ · Dx^T · Λx · Dx λ · Dy^T · Λy · Dy) · t W · t0其中 Λx、Λy 是依赖当前梯度的对角权重矩阵。写成 Python 就是用 scipy.sparse 构造稀疏矩阵import scipy.sparse as sp import scipy.sparse.linalg as spla def refine_transmission(t0, gray, lambda_0.3, sigma0.5, iterations8): h, w gray.shape t t0.copy() # 用原图梯度构造保边权重 gx cv2.Sobel(gray, cv2.CV_64F, 1, 0) gy cv2.Sobel(gray, cv2.CV_64F, 0, 1) Wx np.exp(-gx ** 2 / (2 * sigma ** 2)).ravel() Wy np.exp(-gy ** 2 / (2 * sigma ** 2)).ravel() # 构造差分算子稀疏矩阵 Dx sp.diags([-np.ones(w), np.ones(w - 1)], [0, 1], shape(w - 1, w)) Dy sp.diags([-np.ones(h), np.ones(h - 1)], [0, 1], shape(h - 1, h)) # 扩大到二维梯度算子这里省略具体构建 # 迭代求解 for _ in range(iterations): # 根据当前 t 更新权重 Λ再解方程 t_new _solve_weighted_l1(t, t0, Dx, Dy, Wx, Wy, lambda_) if np.max(np.abs(t_new - t)) 1e-4: break t t_new return t.reshape(h, w)上面代码里的 _solve_weighted_l1 需要根据你构建的稀疏矩阵来写篇幅原因不贴全。这里想强调的是如果你只是为了快速落地完全可以用导向滤波替代这段效果在大多数照片上都很接近def refine_transmission_guided(t0, gray, radius40, eps1e-3): return cv2.ximgproc.guidedFilter(gray, t0, radius, eps)但要注意这是工程近似。如果需要写论文或严格复现实验还是建议保留 L1 优化的完整实现。3.4 去雾后的细节增强标题里特别提到了“细节增强”这是我移植后额外加的一步。因为去雾恢复出来的 J 虽然清晰了但对比度往往偏低特别是阴影区域的纹理不够锐利。我常用的方案是在 YCbCr 空间只对亮度通道做 unsharp mask再合并回彩色通道这样既增强细节又不会引入颜色偏移def enhance_details(img, radius5, amount1.5): ycrcb cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) y ycrcb[:, :, 0] blur cv2.GaussianBlur(y, (0, 0), radius) sharpened cv2.addWeighted(y, 1 amount, blur, -amount, 0) ycrcb[:, :, 0] np.clip(sharpened, 0, 255) return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR)这个做法比直接对整个 RGB 图像做锐化安全得多不会在彩色边缘产生奇怪的色边。参数 amount 控制在 1.2 到 1.8 之间比较合适太大容易产生光晕。4. 实操调参让效果自然的检查清单4.1 输入预处理先缩图再去噪去雾算法本质是逐像素估计图越大计算量越大。我建议先缩放到最长边 800 像素左右跑出结果后再放大回原尺寸如果原图有较明显的噪声先做一次轻度的中值滤波或高斯滤波否则透射率估计会把噪声当成边缘信息保留最后放大噪声。还有一点如果图像直方图严重偏向低亮度建议先做一次简单的线性拉伸让整体曝光正常再去雾。否则暗部区域的透射率下界会非常低恢复时噪声被放大到无法接受的程度。4.2 关键参数参考表我自己调参下来比较实用的参数范围如下参数作用推荐范围调参思路J1场景辐射上界控制去雾强度0.8 ~ 1.0值越大去雾越强但容易变暗J0场景辐射下界影响色彩饱和度0.05 ~ 0.2太大会使结果发灰太小容易过饱和t_min透射率下限防噪声放大0.1 ~ 0.2天空和远景区域建议至少 0.15λ正则化权重控制平滑程度0.1 ~ 0.5越大越平滑但边缘会被削弱σ权重计算参数影响保边程度0.3 ~ 0.8值越小边缘越锐利噪声越明显注意 J0 和 J1 需要在归一化后的 0-1 区间内理解。J0 通常对应场景里最暗的物体表面J1 对应最亮的高光区域。这两个值如果设置得差距过大透射率下界会整体偏小去雾效果很强但容易出现色偏差距太小则去雾不彻底。4.3 结果检查方法主观优先指标辅助有真实清晰图做对比时SSIM 和 PSNR 是最常见的客观指标。但实拍雾图没有 ground truth我一般用三个快捷方式判断结果是否合理一是看天空和远景区域是否出现色斑或偏色的“雾墙”二是看物体边缘有没有明显光晕三是比较恢复前后的灰度方差如果方差大幅提升但颜色没有严重失真说明增强是有效的。除此之外Tenengrad 梯度幅值可以作为清晰度参考数值越高通常代表边缘越锐利。需要提醒的是客观指标不是越高越好。有时候 PSNR 高但人眼看着发灰原因是算法为了贴合参考图而牺牲了对比度。所以我自己的原则是参数先按主观视觉效果调确认顺眼之后再用指标做横向对比。5. 踩坑记录与常见问题速查5.1 Python 结果和 matlab 对不上这是移植项目里最折磨人的问题。同样的参数、同样的图片matlab 输出和 Python 输出总有差别。我排查下来的原因有三个一是 matlab 的 imfilter 默认边界处理方式和 OpenCV 的 filter2D 不完全一致导致同一张透射率图在边缘区域出现细微差异二是 matlab 里很多矩阵运算默认是列优先Python 是行优先reshape 时顺序对不上像素位置全错三是 uint8 和 double 的隐式转换matlab 里加法乘法会自动提升类型Python 里不会。解决方法是所有滤波操作显式指定 borderTypecv2.BORDER_REPLICATE所有图像数组在最开始就统一转成 float64所有 reshape 操作都调 ravel(orderC) 和 reshape(..., orderC) 来对齐顺序。5.2 天空区域出现色斑和光晕天空区域的透射率本来就低如果 t_min 设得太小恢复时会把大气光区域当成细节放大出现一块块色斑。我排查时的第一个动作是打印天空区域的透射率值如果普遍低于 0.1就把 t_min 提高到 0.15 或 0.2。另一个原因是大气光估计偏高导致整个天空被压暗色斑更明显。此时可以改用 top 0.1% 像素的均值而不是最大值并且把暗通道的块大小从 15 调小到 9减少亮色区域对 A 的干扰。5.3 优化迭代慢到怀疑人生L1 优化要迭代求解稀疏线性方程图片稍微大一点就非常慢。我的经验是两招先缩图到 600 像素以下跑透射率图再上采样到原尺寸用于恢复同时把迭代停止条件放宽到 1e-4一般在 6 到 8 次迭代内就能收敛没必要跑满 20 次。如果还是慢就用导向滤波替代速度提升非常明显代价是保边效果略差但绝大多数照片肉眼看不出区别。5.4 去雾完图像整体发灰透射率估计偏大去雾力度不够是发灰的主要原因。检查方式是看恢复后图像的直方图是不是集中在中低灰度区间。如果是先把 J1 调大再把 t_min 调小 0.05 左右观察变化。另外去雾后做一次 gamma 校正是很多人的默认操作gamma 在 0.9 到 1.1 之间微调能有效改善灰蒙蒙的感觉但不要调过头否则阴影区域会死黑一片。最后再分享一个我实际用得最多的经验复现这类经典去雾算法时不要一开始就追求和论文一模一样先跑通一个简化版本确认整体流程正确后再逐步把细节补上。边界约束和上下文正则化的核心价值在于它比暗通道更少依赖场景的统计假设对天空和亮色物体更友好这一点在你的项目里被验证之后后续再换成新算法心里就有底了。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻