水下图像颜色校正实战:白平衡、暗通道与UCIQE评价

发布时间:2026/10/3 8:55:42
水下图像颜色校正实战:白平衡、暗通道与UCIQE评价 简介这是一份面向水下图像处理学习者的仿真资源围绕暗通道先验实现颜色校正完整覆盖暗通道先验、折射率计算与颜色校正三个子模块并通过图像质量评价验证恢复效果可还原较自然准确的水下图像。压缩包共16个文件其中包含6个MATLAB源码脚本、5张测试图片、2张流程图、2份PDF文档和1份MD说明文件整体大小2.35MB内容组织清晰便于按模块研读。已有800人学习使用。使用者可获得可复现的仿真代码、算法流程图、测试图像及质量评价结果既能理解水下图像颜色失真的成因与暗通道参数作用也能直接运行脚本观察校正前后的直观对比并通过附带的说明文档快速理解算法框架。适合计算机视觉方向的学生、研究生以及水下图像处理爱好者参考实践。1. 水下照片为什么总是蓝绿色的一张图背后的三个退化叠加做水下图像处理underwaterimage的人第一眼看到的永远是偏蓝绿的照片。这不是白平衡没调好而是水体的物理特性决定的红色光在几米水深内就衰减大半蓝色光穿透力最强红通道信息大量丢失画面天然缺色。悬浮颗粒造成的光散射又让图像像蒙了一层雾对比度被压得很低。所谓水下颜色校正就是要同时处理颜色衰减、散射模糊和噪声放大这三件事。这个领域里有两条主流路线一条靠水下暗通道underwater dark channel物理模型估计透射率再恢复另一条靠统计白平衡和对比度增强逼近。不管走哪条最后都需要用图像质量评价指标量化效果否则校正前后有没有变好不是靠眼睛说了算的。2. 水下暗通道与颜色校正先拆退化模型再谈算法选型2.1 水下成像模型光线在水里是怎么一步步丢信息的水下相机拍到的图像本质上由三部分叠加而成物体反射的光直接传到传感器的直接分量、传播过程中被水分子和颗粒小角度散射改变方向的前向散射、以及环境光被颗粒反射进镜头形成的后向散射。后向散射就是画面里那层灰蒙蒙的雾前向散射让边缘细节发糊直接分量则因为水的吸收而严重偏色。用简化公式表示传感器上任一点的强度可以写成I_c(x) J_c(x) · t_c(x) B_c · (1 - t_c(x))其中 c 是 R、G、B 三通道J_c(x) 是场景原本的辐射t_c(x) exp(-β_c · d(x)) 是该点的透射率β_c 是水体对波长的衰减系数d(x) 是深度B_c 是背景光。红色波长长的光衰减最快β_R 最大蓝色光衰减最慢β_B 最小。这个波长相关性是水下图像处理underwaterimage区别于普通大气去雾的根本点。所以做水下颜色校正时不能照搬大气去雾里的单透射率模型。同一个深度下红通道的透射率可能已经降到 0.2蓝通道还有 0.8用同一个 t 去恢复红色通道就会被错误补偿造成新的色偏。这也是很多水下暗通道underwater dark channel方案要针对红色通道单独做估计的原因。实际项目中还有一类容易被忽略的退化深度越深可用光越少传感器增益自动拉高暗部噪声被同步放大。换句话说一张水底照片里通常混着色偏、雾化、噪声三种问题单独做任何一项都救不回来。这也是为什么水下颜色校正从来不是调个色那么简单。2.2 暗通道先验搬到水下会失效需要改成红色暗通道大气去雾的暗通道先验Dark Channel Prior核心观察是自然无雾图像里任意局部区域至少有一个通道的强度趋近 0于是把暗通道当作雾浓度的下界来估计透射率。这个先验在水下直接使用时有两个问题。第一水下图像整体偏蓝绿暗通道的暗被蓝色通道推高了。三通道最小值几乎处处不为零计算出来的透射率被系统低估恢复出的图像过亮而且颜色还是偏的。第二水下衰减系数与波长强相关RGB 三通道的透射率各不相同。标准暗通道把三通道套进同一个透射率等于把红通道的严重衰减强行平均掉恢复出来的红通道信号是错的。常见的水下一次修正法是红色暗通道Red Channel Prior既然红色衰减最严重就把红色通道当作需要恢复的对象用红通道与蓝绿通道的差值来估计透射率。具体做法是在局部窗口内找蓝绿通道最大值与红色通道的最大差异得到红色暗通道图。值大的区域说明红色缺失严重也就是水体衰减严重的深水区域透射率自然低。红色暗通道的计算只比标准暗通道多一步但能显著改善恢复后的颜色。除了这个思路另一种常见做法是先对蓝绿通道做补偿再把补偿后的图像送进标准 DCP。补偿量通常来自红通道全局均值与蓝绿通道均值的差本质上和灰度世界白平衡是同一件事的局部版本。我一般倾向先用红色暗通道因为它对深水图像的适应面更宽蓝绿补偿适合可见度尚可但整体偏色的中浅水两者可以都跑一遍看指标不用拍脑袋选。2.3 主流的四类水下颜色校正路线怎么选把常被提到的方案归一下类大概是下面四类路线代表做法优点缺点适合场景统计白平衡灰度世界、白补丁、灰色边缘实现简单、速度极快局部偏色时全局统计漂移视频流、实时预览直方图重映射CLAHE、直方图均衡对比度提升明显颜色恢复有限曝光不足的图物理模型水下暗通道、红色暗通道恢复自然、有物理支撑参数敏感、有伪影单张高质量恢复深度学习UnderwaterCorrection 类生成模型效果上限高依赖训练数据、部署重有批量数据和算力时我的一般选型顺序是先用灰度世界白平衡 CLAHE打底这套代码十几分钟就能跑通能覆盖一大半中浅水场景。如果对恢复深度不满意再跳到红色暗通道物理模型。深度学习放在最后不是因为它效果不好而是因为训练数据的采集成本和水质多样性问题会让交付周期明显拉长。真正做水下图像处理的项目时间大多花在数据脏、指标不稳、参数对不同水质不通用这些事上算法本身的代码反而是最容易的部分。2.4 为什么不建议一上来就上深度学习训练一个水下颜色校正模型需要成对数据或者强假设的弱监督方案。成对数据意味着同一场景要有水下退化版和清晰参考版采集成本很高。用 CycleGAN 这类无监督方案训练稳定性差两片水域的风格偏移稍微大一点就会输出假纹理。工程上还有一个实际问题模型是拿某一类水质和深度训练的换一片水就失效而物理模型至少还保留着透射率和背景光的可解释性出问题能定位是哪个参数不对。所以从落地角度看除非你已经积累了一批覆盖多种水质的视频并且目标明确否则别把宝押在深度学习上那会让你陷入调模型比调算法更深的坑。3. 用Python实现水下颜色校正从白平衡到暗通道恢复的可运行脚本3.1 最小可运行流程灰度世界白平衡 CLAHE先跑通第一版。灰度世界假设 RGB 三通道均值趋于一致把每个通道按比例拉齐。代码用 OpenCV 和 NumPy。import cv2 import numpy as np def gray_world_wb(image, gain_limit1.8): 灰度世界白平衡限制单通道增益防止过曝。 gain_limit: 单通道最大增益超过则压缩到该值。 b, g, r cv2.split(image.astype(np.float32)) b_avg, g_avg, r_avg np.mean(b), np.mean(g), np.mean(r) grand_avg (b_avg g_avg r_avg) / 3.0 gains [ min(grand_avg / b_avg, gain_limit), min(grand_avg / g_avg, gain_limit), min(grand_avg / r_avg, gain_limit), ] b_out np.clip(b * gains[0], 0, 255).astype(np.uint8) g_out np.clip(g * gains[1], 0, 255).astype(np.uint8) r_out np.clip(r * gains[2], 0, 255).astype(np.uint8) return cv2.merge([b_out, g_out, r_out])这段代码的逻辑很朴素如果图片偏蓝b_avg 远大于 g_avg 和 r_avg算出的 grand_avg / b_avg 小于 1蓝色通道被压低红色通道的增益大于 1被拉高。三通道均值对齐后颜色回到中性。clip(0,255) 是必须的因为红色通道增益可能超过 1.5不截断高光区域会整体泛白。gain_limit 是防止过曝的关键偏色越重的图这个值越要压得保守。接着做 CLAHE 增强。注意只在 Lab 空间的亮度通道上做不动颜色通道。def clahe_on_lab(image, clip_limit2.0, tile_grid(8, 8)): CLAHE 只在亮度通道做避免增强对比度时放大原有色偏。 lab cv2.cvtColor(image, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) clahe cv2.createCLAHE(clipLimitclip_limit, tileGridSizetile_grid) l_enhanced clahe.apply(l) merged cv2.merge([l_enhanced, a, b]) return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)CLIP 限制在 1.5~3.0 之间2.0 是常见起点。tile_grid 设成 8x8数值越小局部对比度越强但也越容易出现块状伪影。如果发现水下视频里帧间亮度抖动厉害可以把 tile_grid 降到 6x6用一点对比度换稳定性。之前有同事直接在 BGR 三通道上各自做一遍 CLAHE结果偏蓝的图增强后更蓝了。原因很简单蓝色通道的直方图拉伸幅度比红通道大色偏被进一步放大。所以记住一句对比度增强尽量只在亮度通道上做。3.2 水下暗通道透射率估计与恢复红色暗通道实现第二版走物理模型。核心步骤是估计背景光、估计透射率、用引导滤波细化、最后逆变换恢复。def red_dark_channel(image, patch_size15): 红色暗通道窗口内 max(B, G) - R 的最大值。 红通道衰减越严重这个值越大。 b, g, r cv2.split(image.astype(np.float32) / 255.0) diff np.maximum(np.maximum(b, g) - r, 0) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (patch_size, patch_size)) dark cv2.dilate(diff, kernel) # 窗口内取最大值 return dark这里没有用标准暗通道的窗口内最小值而是取蓝绿与红的差异的窗口内最大值。差异越大说明红色衰减越严重透射率应该越低。dilate 就是局部最大值滤波等价于取窗口内最严重的衰减值。背景光估计沿用暗通道先验的做法取暗通道最亮 0.1% 的像素在原图里找这些位置的最大通道值作为背景光。def estimate_background(image, dark_map): 背景光估计暗通道最亮 0.1% 的像素在原图中的最大强度。 flat dark_map.flatten() total flat.size top_count max(1, int(total * 0.001)) idx np.argpartition(flat, -top_count)[-top_count:] candidate image.reshape(-1, 3)[idx] background np.max(candidate, axis0).astype(np.float32) return background用 argpartition 而不是 argsort是因为这里只关心 top-k不需要全排序速度快很多。背景光估计的质量直接决定恢复图的色调如果背景光里有残留的蓝绿主导恢复后的图会偏向另一边所以这一步值得多做几次对比。透射率估计def transmission_from_red_dark(image, background, patch_size15, omega0.9, t_min0.15): 由红色暗通道估计透射率omega 控制去雾强度t_min 限制最小透射率。 image_f image.astype(np.float32) normalized np.empty_like(image_f) for c in range(3): normalized[..., c] image_f[..., c] / background[c] dark_norm red_dark_channel(normalized, patch_size) transmission 1 - omega * dark_norm return np.clip(transmission, t_min, 1.0)omega 设 0.9 表示保留 10% 的雾感完全按 1.0 去雾远处区域会发暗。t_min 是防止透射率过低导致除零和噪声放大深水图建议保持 0.15 以上。透射率图直接用会比较粗糙边缘处容易产生光晕。引导滤波可以把它细化到和原图边缘对齐。def guided_refine(transmission, guide, radius60, eps1e-4): 引导滤波细化透射率。需要 opencv-contrib-python缺失时用双边滤波兜底。 try: from cv2.ximgproc import guidedFilter return guidedFilter(guide / 255.0, transmission.astype(np.float32), radius, eps) except ImportError: return cv2.bilateralFilter(transmission.astype(np.float32), -1, 0.1, radius)引导滤波的 radius 和 eps 决定平滑程度。60 和 1e-4 是常用组合radius 太大边缘会被磨掉太小保留噪声。如果没有 contrib 包双边滤波 sigmaColor 设 0.1 是因为透射率图的范围就是 0 到 10.1 以内的强度差异算同质区域。最后是逆变换恢复def recover_image(image, background, transmission): 逆变换恢复I J*t B(1-t) J (I - B)/t B t np.expand_dims(transmission, axis-1) result (image.astype(np.float32) - background) / t background return np.clip(result, 0, 255).astype(np.uint8)这套代码是简化版所有通道复用同一个透射率。实际水下场景里红通道和蓝绿通道的透射率差异很大严格做法是分开估计。但对于大多数工程演示和前期验证这个版本已经能看出明显效果。真要交付时可以在 transmission_from_red_dark 里分别对 RGB 三通道用不同的 omega 和 t_min 系数。3.3 整个流程怎么串入口函数与参数表把两套流程合并成一个入口方便批量测图。def underwater_pipeline(image, modefast): 统一入口。 modefast: 灰度世界白平衡 CLAHE modequality: 红色暗通道物理模型恢复 if mode fast: return clahe_on_lab(gray_world_wb(image, gain_limit1.8)) dark red_dark_channel(image, 15) bg estimate_background(image, dark) t transmission_from_red_dark(image, bg, 15, 0.9, 0.15) guide cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) t_refined guided_refine(t, guide) return recover_image(image, bg, t_refined)跑通后先别急着调参把下面几个核心参数记在笔记里再动手参数位置默认值调参方向patch_sizered_dark_channel15小则边缘保留多但噪声多大则雾感残留但边缘丢失omegatransmission_from_red_dark0.9接近 1 去雾更彻底0.8 更保守t_mintransmission_from_red_dark0.15降低会放大暗部噪声提高则保留更多雾感clip_limitclahe_on_lab2.0偏色重或噪声大的图降到 1.5gain_limitgray_world_wb1.8过曝明显时降到 1.5调参有个经验不要五个参数一起动。先固定 patch_size 和 omega只动 t_min 观察暗部再固定 t_min动 patch_size 观察边缘。每次改一个参数用后面的质量评价指标对比比肉眼看更靠谱。4. 水下图像质量评价用UCIQE和UIQM替代人眼的量化方法4.1 为什么需要客观评价指标UCIQE与UIQM的基本逻辑水下颜色校正做完后最常被问的一句话是提升多少。人眼打分费时且不稳定同一张图白天和晚上看可能给出不同结论。所以水下图像处理从业者基本都会引入无参考质量评价指标UCIQE 和 UIQM 是使用率最高的两个。UCIQEUnderwater Color Image Quality Evaluation的思路是把颜色偏置、对比度和饱和度三个分量加权组合。原始形式是UCIQE c1·σ_chroma c2·con_luma c3·μ_saturationσ_chroma 是色度标准差反映颜色丰富度con_luma 是亮度对比度μ_saturation 是饱和度均值。原论文把系数拟合为 c10.4680, c20.2745, c30.2576。注意这个系数是在特定实验集上拟合的不同复现版本会有微小差别横向对比论文结果时先确认对方用的系数。UIQMUnderwater Image Quality Measure更复杂把评价拆成色彩测量、清晰度测量和对比度测量三部分每部分内部又有独立计算最后加权求和。UIQM 的绝对数值没有统一上限不同实现跑出来的分数差异很大所以它更适合在同一条 pipeline 内做相对比较别拿两个不同仓库的分数直接比大小。4.2 UCIQE的代码实现三个分量与计算细节UCIQE 的 Python 实现不算难三个分量加起来就行。def calculate_uciqe(image): 计算 UCIQE 指标。 分量色度标准差 亮度对比度 饱和度均值 lab cv2.cvtColor(image, cv2.COLOR_BGR2LAB).astype(np.float32) l, a, b cv2.split(lab) chroma np.sqrt(a * a b * b) sigma_chroma np.std(chroma) # 色度标准差 contrast_luma np.std(l) # 亮度标准差近似对比度 with np.errstate(divideignore, invalidignore): saturation chroma / np.maximum(l, 1e-3) mean_saturation np.mean(saturation) # 饱和度均值 c1, c2, c3 0.4680, 0.2745, 0.2576 uciqe c1 * sigma_chroma c2 * contrast_luma c3 * mean_saturation return uciqe这里有两处需要解释。第一原论文的亮度对比度不是简单标准差而是基于局部差值计算但大量复现都采用标准差近似趋势一致且速度快。如果你要做严格的论文对比需要回到原论文的局部对比度定义。第二饱和度用色度除以亮度来近似是为了让暗部区域的饱和度不被整体拉高否则一张整体偏暗的图会得到虚高的 UCIQE。跑批量数据时我一般会同时打印三个分项而不是只看总分。原因在 4.3 里详述。UIQM 的完整实现较长涉及色彩、清晰度、对比度三个子测量这里给一个简化版本做 pipeline 内的相对比较def calculate_uiqm_simple(image): UIQM 简化版色彩 清晰度 对比度。 非官方实现只用于同一条 pipeline 内的相对比较。 r, g, b cv2.split(image.astype(np.float32)) rg r - g yb 0.5 * (r g) - b color_score np.sqrt(np.std(rg) ** 2 np.std(yb) ** 2) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float32) laplacian cv2.Laplacian(gray, cv2.CV_32F) clarity_score np.mean(np.abs(laplacian)) contrast_score np.std(gray) return 0.1 * color_score 0.5 * clarity_score 0.4 * contrast_score这套简化的权重是我自己定的不代表官方 UIQM。要求严谨的场合需要跑权威实现。但对于这版算法比上一版是否更好这个工程问题它够用。4.3 把指标接到pipeline里让数据帮你调参指标的作用不是替代人眼而是把好不好变成一个可以自动比较的数字。我习惯的做法是对一组测试图跑完校正后把校正前后的 UCIQE 和简化版 UIQM 打到一个表里先看总分再看分项。import glob test_images glob.glob(test//*.png) for path in test_images: image cv2.imread(path) for mode in [fast, quality]: out underwater_pipeline(image, mode) uciqe calculate_uciqe(out) uiqm calculate_uiqm_simple(out) print(f{path} | {mode} | UCIQE{uciqe:.4f} | UIQM{uiqm:.4f})这里有一个非常重要的经验不要只看 UCIQE 是否升高要看升高来自哪个分项。很多情况下白平衡拉高了色度标准差同时压低了亮度对比度总分看起来不变但图像实际变均匀了。正确的判断标准是色度标准差、亮度对比度、饱和度均值三个分项至少有两项改善另一项不明显变差这个改动才值得保留。说到质量评价还有一句难听的话要说任何指标都能被刷出来包括 UCIQE。比如把图整体调暗饱和度均值会上升分数变高但人眼看着明显更差。所以我的习惯是批量指标跑完再人工挑分数最高和最低的各十张图确认一遍确认指标方向和人的感知一致之后才敢让指标去指导自动化调参。这一步叫给指标建立信任不是玄学是避免被数字骗。5. 水下颜色校正的六个常见坑现象、原因、解法5.1 校正后整体偏红高光过曝现象灰度世界白平衡跑完原本蓝绿的图变成红通通一片高光区域泛白。原因水下红通道均值很小grand_avg / r_avg 可能到 2.2 甚至更高红色增益过大把红通道整体推到 255。解决给增益设上限gain_limit 压到 1.5~1.8。如果还偏红改用白补丁白平衡取图像最亮的 1% 像素按通道平均作为光源估计比灰度世界更抗极端偏色。5.2 暗通道恢复后物体边缘出现白色光晕现象鱼、礁石、绳索边缘有一圈白色亮边看着像抠图没抠干净。原因透射率图和图像边缘没有对齐。patch_size 过大或跳过了细化步骤时前景和背景交界处的透射率平滑过渡逆变换在这个过渡带上放大了差异。解决先跑 guided_refine 把透射率细化再进入恢复函数。如果边缘光晕还明显把 patch_size 从 15 降到 10代价是透射率图噪声增多但配合引导滤波通常能压住。5.3 UCIQE分数升高了人眼看着反而更差现象指标说这版更好但图像灰蒙蒙或者噪声明显肉眼明显更差。原因UCIQE 的饱和度分项用色度除以亮度近似亮度偏低时会虚高。纹理丰富的噪声区域也会拉高色度标准差导致指标被噪声骗了。解决指标分项拆开看再人工抽查分数最高最低的图。批量实验里建立指标与人眼的相关性确认一次后续才能放心用自动调参。这个环节省不掉。5.4 视频流里逐帧校正画面亮度闪烁现象每一帧单独看没问题连成视频后一会儿亮一会儿暗尤其有鱼游过或水面波动时明显。原因灰度世界和背景光都是全局统计量每一帧的内容变化会改变全局均值校正参数随之抖动。解决对统计量做时间平滑用指数滑动平均保存前一帧的增益alpha 取 0.3~0.5。另一种更稳的方案是下水前用标准色卡标定一次后续固定增益不再更新代价是对深度变化不敏感适合固定深度的监控场景。5.5 深水区域校正后出现密集彩色噪点现象暗部出现红红绿绿的小颗粒色彩看着像噪声被放大了。原因深水区可用光少暗部信噪比本来就低校正时乘以大增益把噪声同步放大。物理模型里透射率接近下限时也会放大噪声。解决先降噪再校正用 OpenCV 自带的 fastNlMeansDenoisingColored 或双边滤波。同时把 t_min 从 0.15 提到 0.2让深水区的透射率不至于压到过低保留一点雾感换干净的画面。5.6 浅水区图像不需要校正也被强行校了现象浅水或有人工光源的图本来颜色正常跑完白平衡反而偏冷红色被压掉。原因全局统计方法不区分水质造成的偏色和场景本身的颜色。浅水红光衰减不严重红通道均值已经不低但灰度世界仍会把红色往全局均值上拉。解决在 pipeline 入口加一个判断算红通道均值与蓝绿通道均值的比值如果接近 1说明偏色不明显直接跳过白平衡只做 CLAHE 增强。这个门槛我用的是 0.85低于它才启用颜色校正。给管线留一条不做处理的旁路省很多麻烦。6. 进阶把校正写进视频流用UCIQE做自动调参视频流处理的关键不是单帧效果而是帧间稳定性。把 3.3 里的灰度世界流程接进视频循环加上时间平滑就够一个实时预览版本。cap cv2.VideoCapture(underwater.mp4) alpha 0.4 prev_gain None while True: ret, frame cap.read() if not ret: break gains [float(np.mean(c)) for c in cv2.split(frame.astype(np.float32))] if prev_gain is None: prev_gain gains else: gains [alpha * x (1 - alpha) * p for x, p in zip(gains, prev_gain)] prev_gain gains avg np.mean(gains) b, g, r cv2.split(frame.astype(np.float32)) b_out np.clip(b * (avg / gains[0]), 0, 255).astype(np.uint8) g_out np.clip(g * (avg / gains[1]), 0, 255).astype(np.uint8) r_out np.clip(r * (avg / gains[2]), 0, 255).astype(np.uint8) corrected cv2.merge([b_out, g_out, r_out])alpha 是平滑系数alpha 越大新帧增益的影响越大响应快但抖动明显alpha 越小画面越稳但突然进入深水区时会感到颜色恢复滞后。0.3~0.5 之间是比较好的折中。自动调参的思路是每 30 帧算一次 UCIQE如果连续两段都比上一段低说明场景在变深或变暗当前这套参数不再合适。此时可以自动调大 patch_size或者调小 clip_limit降低增强强度让画面回到人眼可接受范围。这个闭环不用写复杂逻辑把 3.3 的参数表变成一个可调节的字典就行。我个人的习惯是交付前永远做一次校正前后的对比拼接图或对比视频让人眼最终确认一遍。指标再高也替不了真实用户看一眼的感受。另外给整套流程留一个跳过校正的开关这个开关看起来不起眼但在浅水和人工光源场景里它是你的后悔药。希望帮到你。本文还有配套的精品资源点击获取