简介:面向水下图像处理与计算机视觉研究者的MATLAB算法仿真资源,聚焦基于暗通道先验的颜色校正方案。资源完整覆盖暗通道先验参数估计、折射率计算、颜色校正三大核心模块,并附有图像质量评价环节,可直接运行观察恢复效果,适合需要处理水下图像色偏失真、开展暗通道算法验证或复现实验的初学者与研究人员。压缩包共16个文件,2.35MB,其中6个M脚本实现主算法与测试流程,5张JPG为输入输出样图,2个PNG为算法流程图,2个PDF含原理说明与参考文献,另有1个Markdown笔记便于快速上手。已有800人学习。整体内容结构清晰,从原理文档到仿真代码再到结果对比一应俱全,可帮助读者快速掌握暗通道先验在水下颜色校正中的应用,减少自行搭建实验的弯路。
1. 水下拍出来的照片为什么会变成蓝绿一片:这个方向在解决什么
“水下拍出来全是蓝绿色”,几乎每个接触水下摄影或水下机器人视觉的工程师都有过这种困惑:5米以下红色已经衰减殆尽,10米以下画面只剩蓝绿两个通道,悬浮颗粒还把对比度吃掉了。这个领域的技术标签就是水下图像处理(underwater image processing)中的水下颜色校正,核心估计工具是水下暗通道,最终效果靠 UCIQE、UIQM 这类无参考图像质量评价指标验收。先给结论:把大气暗通道去雾的参数搬到水下,大概率会修出偏红又灰蒙的假图;水下要先把通道衰减模型立住,再做分通道透射率估计。这篇文章面向需要实际落地水下视觉系统的人,从原理到可运行代码,把做法和坑一次讲清。
2. 水下暗通道的物理模型:为什么不能把大气去雾直接搬来用
2.1 波长衰减、前向散射与后向散射:水下图像为什么又偏色又模糊
水下成像的光学退化来自两个物理过程:吸收和散射。吸收可以用比尔-朗伯定律描述,光强随传播距离按指数衰减,但衰减系数对波长的选择性非常强。红光波长长的部分最容易与水分子发生共振吸收,在清澈海水中的典型衰减系数是 0.4~0.6 /m;蓝光衰减最小,约 0.02~0.05 /m;绿光居中。这意味着光从拍摄对象传到镜头,每多走一米,红光的能量要乘上 e^{-0.5}≈0.6,走三米就只剩 20% 出头。所以深海画面天然偏蓝,不是相机白平衡错乱,而是红色光子真的没到传感器。这个偏色程度与深度和成像距离是严格相关的,这正是后来用暗通道反推深度信息的物理基础。
散射则分前向散射与后向散射两种。前向散射让物体边缘的光线方向发生偏移,产生类似“光晕”的细节损失;后向散射把水体中悬浮颗粒反射的环境光送进镜头,像一个可变的灰色遮罩叠在画面上。后向散射对暗部影响尤其明显,它会抬高原本接近零的暗通道数值,让整幅图像的对比度塌下去。不同水体的吸收和散射强度差异极大,直接决定算法参数的初始值。这里给一张常用经验值表,帮你在拿到一段水下素材时快速判断应该偏重颜色补偿还是对比度增强。
| 水域类型 | 红光衰减系数 /m | 绿光衰减系数 /m | 蓝光衰减系数 /m | 图像典型表现 |
|---|---|---|---|---|
| 清澈海水 | 0.4~0.6 | 0.15 | 0.05 | 偏蓝,远距离偏蓝绿 |
| 近岸/港口 | 0.8~1.2 | 0.3~0.5 | 0.1~0.2 | 偏绿,能见度下降 |
| 浑浊淡水 | 1.5~2.5 | 0.8~1.5 | 0.5~0.9 | 黄绿浑浊,对比度极低 |
这张表在做工程预判时很管用。比如拿到一段明显偏绿浑浊的画面,就不该照搬清澈海水那套小声调白平衡参数,而是要把 red_gain 提高、把暗通道窗口缩小。很多翻车案例都是拿清水参数硬套浑水场景,结果红通道被拉到溢出。
2.2 每个颜色通道的透射率不同:水下暗通道的退化模型推导
大气去雾模型里只有一个透射率 t(x),因为大气对可见光的波长选择性很弱,近似成立。水下不行,严格的退化模型应该写成逐通道形式:
I_c(x) = J_c(x) · t_c(x) + A_c · (1 - t_c(x)),其中 t_c(x) = e^{−η_c · d(x)}
c 遍历 R/G/B 三个通道,η_c 是该通道的总衰减系数,等于吸收率加散射率,d(x) 是目标到相机的距离。红色通道的 η_R 最大,意味着远处物体的 t_R 趋近于 0,I_R(x) 里几乎只剩大气光 A_R,这就是水下照片偏蓝的数值根源。
如果把三通道强行共用一个 t,相当于假设 η_R=η_G=η_B,这就违反了水下物理。所以原始暗通道先验直接搬到水下会产生两个连锁错误:第一,透射率被系统性低估。因为求暗通道时 min 操作总会落在衰减最狠的红色通道上,min 值过低,按公式 1−ω·min(I/A) 算出来的 t 就偏小,去雾强度过头。第二,大气光估计错位。暗通道里最亮的像素通常是远处的水体本身,不是“天空”,于是 A 被选到蓝绿通道的高强度区域,恢复出来的背景颜色发生漂移。
因此水下暗通道的工程定义一般会改成三条约束:
- 暗通道取蓝绿通道的最小值,让红色通道不再主导统计结果;
- 红色通道不参与暗通道的 min 操作,而是单独算一份 t_R;
- 最终透射率按场景加权融合,比如 t = α·t_GB + (1−α)·t_R,α 取 0.6~0.8。
这套做法比“调 omega、调 win_size”更贴近水体物理,后面第三部分的代码就按这个思路组织。
2.3 把“水上暗通道”参数搬到水下为什么翻车:三个伪装成参数怪的问题
网上很多工程片段是直接把大气暗通道去雾改几个参数就当作水下颜色校正交付,浅水素材可能看不出大问题,深度一超过 6 米,问题集中爆发在三个点。
第一,窗口大小。大气暗通道常用 15×15 窗口做局部最小值滤波,但水下红通道衰减不均,暗通道的局部最小很容易落在蓝绿背景而不是物体表面,导致透射率图出现大块“果冻状”伪影。水下更稳的做法是把窗口缩到 7×7 或 9×9,再用引导滤波平滑,而不是靠大窗口硬撑。
第二,omega 取多少。大气去雾常取 0.95,保留少量雾感避免过增强。水下因为存在后向散射的“浮尘感”,需要把 omega 抬到 0.97 甚至 0.99 才够,但抬高 omega 之后红色通道往往先被拉穿,出现偏红。所以不能只调 omega,必须配合红色通道的增益上限和透射率下界一起约束。
第三,t0 下界。大气去雾习惯把透射率下限设成 0.1,水下由于衰减快,t0 低于 0.2 时近处物体都会被当成“很远”而过度增强,像被漂白。所以水下实现普遍把 t0 抬到 0.2~0.3。这相当于承认水下本来就没有绝对清晰的远景,去雾只能去一部分。
这三个“参数怪”本质上是在用参数去弥补模型失配。如果你已经陷入“怎么调 omega 都不舒服”的循环,我建议直接按 2.2 的分通道透射率改模型,而不是继续试参,那样只会越调越玄学。
3. 用暗通道做水下颜色校正的最小可跑实现:白平衡、透射率、细化与后处理
3.1 第一步白平衡:灰度世界补偿红色通道
处理顺序有讲究。我的惯常做法是先做“以绿色通道为参考的部分灰度世界”,再做暗通道估计。原因很简单:如果直接上去做暗通道,计算机会把红色通道的整体缺失当成“雾”的一部分,后续透射率恢复会连真实的物体颜色一起抹掉。先补偿红色通道,可以让暗通道统计更接近陆上清晰图像的假设。
下面这个函数就是第一步白平衡的典型实现。它不追求完全灰度世界,而是留一部分水色特征给后面的暗通道估计去用。
import cv2 import numpy as np def partial_gray_world(img, red_gain=0.7, blue_gain=0.5): """以绿色通道为参考的部分灰度世界白平衡。 输入是 BGR 格式的 float32 图像,范围 0~1。 red_gain 控制红色通道补偿比例,blue_gain 控制蓝色通道压缩比例。 """ b, g, r = img[:, :, 0], img[:, :, 1], img[:, :, 2] mean_r, mean_g, mean_b = r.mean(), g.mean(), b.mean() # 红色增益:red_gain=0 表示不补偿,1 表示完全灰度世界 gain_r = (mean_g / (mean_r + 1e-6)) * red_gain + (1 - red_gain) # 蓝色通道只压一部分,避免背景紫化 gain_b = (mean_g / (mean_b + 1e-6)) * blue_gain + (1 - blue_gain) r_c = np.clip(r * gain_r, 0, 1) b_c = np.clip(b * gain_b, 0, 1) return np.stack([b_c, g, r_c], axis=2)这里的核心参数是 red_gain。取 0.7 表示只补偿 70% 的通道均值差,保留 30% 的水色特征。浅水下红色残留较多,red_gain 可以降到 0.5;深水或浑水场景需要提到 0.8~0.9,但超过 0.9 时暗部发红的概率急剧上升。blue_gain 我一般固定 0.5,它只是防止蓝通道被压太狠而偏紫,对最终暗通道结果影响较小,不需要频繁调。
3.2 暗通道与大气光估计:核心代码与两个关键参数
白平衡之后进入暗通道估计。先求暗通道,再估计大气光,最后计算分通道透射率。这一段是三段式代码:estimate_a 负责取大气光,underwater_dark_t 负责按水下规则计算透射率。
def estimate_a(img, dark, percent=0.001): """估计大气光 A。取暗通道最亮的前 percent 像素,在原图上求各通道均值。 percent 越大,A 越平滑,但可能被水体背景带偏。 """ h, w = dark.shape n_pix = max(int(h * w * percent), 10) flat_idx = np.argsort(dark.ravel())[-n_pix:] A = np.zeros(3, dtype=np.float32) for c in range(3): A[c] = np.mean(img[:, :, c].ravel()[flat_idx]) return A def dark_channel(img, size=15): """暗通道:先对通道维取 min,再做空间最小值滤波。 size 控制局部块尺度,水下建议 7~15,图像越大取值越大。 """ min_ch = np.min(img, axis=2) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (size, size)) return cv2.erode(min_ch, kernel) def underwater_t_est(img, dark_gb, A, omega=0.97, t0=0.2, t_r_weight=0.3): """分通道透射率估计。 dark_gb 是蓝绿通道暗通道,约等于深度参考图。 t_r 由红色通道单独估计,t_r_weight 是红色估计的融合权重。 """ t_gb = 1 - omega * (dark_gb / (A[1] + 1e-6)) t_r = 1 - omega * (img[:, :, 2] / (A[2] + 1e-6)) t_r = np.clip(t_r, t0, 1.0) t = (1 - t_r_weight) * np.clip(t_gb, 0.05, 1.0) + t_r_weight * t_r return np.clip(t, t0, 1.0)A 的估计这里取暗通道最亮前 0.1% 像素的均值,而不是全局最大点。全局最大点容易被镜头眩光或近处高光污染,单个像素不稳定。水下我一般会把 percent 放宽到 0.005~0.01,因为水下暗通道里的高亮像素往往是水体散射区域而非真正的“天空”,取多一点能平滑掉亮点噪声。
underwater_t_est 的核心是分通道。t_gb 用蓝绿通道暗通道估计深度,t_r 用红色通道单独估计。两者之差本质上反映了“距离信息”和“红色衰减”之间的 gap。t_r_weight 取 0.3 是经验值:水质越浊、深度越大,红色通道信息越弱,t_r 的权重应该降得越低。十米以上深水场景,红色通道已经几乎无信号,我会直接把这个权重降到 0.1,甚至完全不用 t_r。
3.3 透射率细化与图像恢复:抑制光晕、恢复饱和
暗通道直接求出来的透射率图是分块状的,直接恢复会在物体边缘留下光晕。常见做法是用引导滤波细化透射率,OpenCV 需要 opencv-contrib-python 才能调用 ximgproc 模块;如果没有这个库,可以退回双边滤波。下面是恢复函数和细化函数一起给出的小管线。
def refine_t(t, guide, radius=30, eps=1e-3): """引导滤波细化透射率。 guide 用原图灰度图,边缘信息能保留在 t 上。 """ t_ref = cv2.ximgproc.guidedFilter(guide, t, radius, eps) return np.clip(t_ref, 0.2, 1.0) def restore(img, t, A): """按退化模型反解清晰图 J = (I-A)/t + A。""" out = np.zeros_like(img, dtype=np.float32) for c in range(3): out[:, :, c] = (img[:, :, c] - A[c]) / np.clip(t, 0.2, 1.0) + A[c] return np.clip(out, 0, 1) # 完整调用示例 def run_pipeline(img_bgr, red_gain=0.7): img = img_bgr.astype(np.float32) / 255.0 img_wb = partial_gray_world(img, red_gain=red_gain) # 蓝绿通道暗通道 dark_gb = dark_channel(img_wb[:, :, :2], size=9) A = estimate_a(img_wb, dark_gb, percent=0.005) t = underwater_t_est(img_wb, dark_gb, A) guide = cv2.cvtColor((img_wb * 255).astype(np.uint8), cv2.COLOR_BGR2GRAY) t_ref = refine_t(t, guide.astype(np.float32) / 255.0) out = restore(img_wb, t_ref, A) return outrefine_t 里的 radius 和 eps 是两个值得下功夫的参数。radius 控制引导滤波的空间半径,1920×1080 的图取 30~60,小图取 15~30;eps 是边缘保持阈值,默认 1e-3 会让透射率相对平滑,如果光晕没压住,把 eps 降到 5e-4 或 1e-4,它能保留更多边缘细节。需要注意这里不是“光晕越大半径越该加”,而是“光晕来自透射率阶跃,eps 越小越能贴住边缘”。我见过不少人反向调参,把 radius 拉到 100,结果整张图的深度感被抹平。
4. 水下图像质量评价怎么做:UCIQE、UIQM 与 PSNR 的分工和代码
4.1 全参考与无参考指标的分工:什么时候能用 PSNR,什么时候只能靠 UCIQE
水下颜色校正的验收比普通图像增强麻烦,因为真实水下场景不存在“原图”。没有参考图时,PSNR、SSIM 这类全参考指标完全失效,只能做合成实验时用:你先有一张清晰陆上图像,再按水下退化模型合成一张退化图,跑完算法和原图对比,算 PSNR/SSIM。真实水下素材的质检要用另一类指标,常见的是 UCIQE 和 UIQM。
| 指标 | 类型 | 解决的问题 | 主要局限 |
|---|---|---|---|
| PSNR | 全参考 | 整体像素误差,合成实验必用 | 与主观感知相关性弱 |
| SSIM | 全参考 | 结构相似性,看纹理保真 | 对整体偏色不敏感 |
| UCIQE | 无参考 | 水下图像色彩、对比度、饱和度综合分 | 不惩罚色偏方向,偏红也可能高分 |
| UIQM | 无参考 | 色彩自然度、清晰度、对比度三个子模型 | 权重依赖训练用数据集,绝对分数参考意义有限 |
| PCQI | 无参考 | 补块对比度质量 | 与水下任务的针对性不如前两者 |
工程里我一般把 UCIQE 和 UIQM 同时记录,再额外算一个色偏因子做辅助。只报一个指标很容易被算法“刷分”,下面代码会解释为什么。
4.2 UCIQE 的 Python 实现:三个分量怎么算
UCIQE 的原始定义是把图像转到 LAB 空间,取三个统计量做线性加权:L 通道标准差作为对比度项,ab 平面的饱和度标准差作为色彩丰富度项,ab 平面的色度均值作为色偏项。公开论文里的权重系数是 0.4680、0.2745、0.2576,很多复现版本都沿用这个组合。实现起来 30 行内能搞定。
def uciqe(img_bgr): """无参考水下彩色图像质量评价。 输入 BGR 图像,取值范围 0~255。 返回综合分数,分数越高表示对比度与色彩越丰富。 """ lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) L = lab[:, :, 0] / 255.0 a = lab[:, :, 1] - 128.0 b = lab[:, :, 2] - 128.0 # 对比度项:亮度通道标准差 con = np.std(L) # 饱和度项:每个像素饱和度的标准差 sat = np.std(np.sqrt(a**2 + b**2)) # 色度项:饱和度均值 chr_mean = np.mean(np.sqrt(a**2 + b**2)) score = 0.4680 * con + 0.2745 * sat + 0.2576 * chr_mean return score这段代码有两个容易踩的细节。第一,输入必须是 0~255 的 uint8 图像,因为 OpenCV 的 LAB 转换内部会按这个范围处理;如果你把 float 图直接传进去,a、b 通道的范围会乱。第二,LAB 空间里 a、b 的原点理论上是 128,我在减 128 之前必须把通道转成 float,否则负数会被截断成 0。这两个错误会让 UCIQE 分数在图像处理前后产生虚假的暴涨或暴跌。
如果要批量验证,写一个循环遍历文件夹,把处理前后的 UCIQE 打成一列,比一张张肉眼判断靠谱得多:
import glob import csv def batch_evaluate(folder_in, folder_out, csv_path): rows = [] for p in glob.glob(folder_in + "/*.jpg"): name = p.split("/")[-1] raw = cv2.imread(p) corrected = cv2.imread(folder_out + "/" + name) if raw is None or corrected is None: continue rows.append([name, round(uciqe(raw), 4), round(uciqe(corrected), 4)]) with open(csv_path, "w", newline="") as f: writer = csv.writer(f) writer.writerow(["file", "uciqe_raw", "uciqe_corrected"]) writer.writerows(rows)这个批量脚本是我做方案对比时的固定动作。参数改一轮,几十张图一起算分,比单张调参节省大量时间。
4.3 指标暴涨但图更难看?评价指标的分裂与联合判断
UCIQE 有个反直觉的特点:它只统计饱和度和对比度的“量”,不惩罚色调“歪不歪”。一个方案把红通道增益拉到天上,得到整体偏红但饱和度方差很高的图像,UCIQE 分数照样上涨。UIQM 里的 UICM 分量专门检测色彩自然度,这时候会下降。所以两者经常打架,这是很正常的现象,不代表代码写错。
我的处理方式是用三个值联合验收:UCIQE、UIQM,再加一个简单色偏因子,比如图像在 LAB 空间的 a、b 通道均值离 0 的欧氏距离。偏红或偏绿明显时,ab 均值会严重偏离圆心。同一个方案,只有 UCIQE 涨、UIQM 不降、ab 均值偏移不扩大,才会进入下一轮人工主观检查。单纯追一个指标的最高分,在调参时很容易把算法带进“指标好看图不能看”的死胡同。
5. 水下颜色校正避坑清单:五个最常见的翻车现场与参数修正
5.1 大面积纯色水体的“暗通道坍塌”
现象:处理之后整片水体背景变成紫红色或洋红色,靠近画面的边缘区域尤其明显。
原因:暗通道先验的核心假设是“局部区域至少有一个颜色通道趋近于零”,但这个假设在没有任何物体的大面积水体背景里不成立。水体背景本身就是一个巨大的蓝绿光源,暗通道数值不低,透射率被估计成极小值,背景被当成“很远的雾”强行去除,颜色自然漂移。
解决:把大气光估计的像素范围从暗通道最亮前 0.1% 扩大到前 1%,同时透射率估计对背景区域设下界 0.4~0.5。更稳的做法是先检测暗通道中远处水体区域,对那一块直接跳过恢复,只做轻度颜色补偿。判断条件一般用暗通道数值和高通滤波后的平坦度组合,背景区域暗通道高且梯度小。
5.2 红色通道补偿过度的连锁反应
现象:暗部大面积发红,高光区域直接溢出变白,画面上像蒙了一层粉色滤镜。
原因:red_gain 设置过高,或者是在浑浊水体中对红色通道做了接近 100% 的灰度世界补偿。浑浊水体本身黄色成分重,红色通道的“缺失”有一部分其实是悬浮颗粒造成的黄褐衰减,线性补偿把它全部换算成红色增益,必然过头。
解决:给红色通道补偿加一个物理上限,例如补偿后红色均值不得超过绿色均值的 1.2 倍。另一个常见做法是转到 LAB 空间只对 L 通道做增强,不动 a、b 通道,这能保证色调不偏移。我习惯在 pipeline 里设置这样一条约束,防止后续调 omega 或 t0 时牵一发动全身。
5.3 处理后的肤色与珊瑚色失真
现象:偏色是校正了,但潜水员的皮肤、珊瑚、橙色的鱼身都变成诡异的青灰或品红,像是换了色调映射。
原因:通道级线性拉伸只能改变每个通道的强度比例,无法改变色彩向量的方向。也就是说,RGB 三通道同时乘不同系数,色相会移动,皮肤这种对色相极其敏感的对象马上就露馅。
解决:采用色度保持的后处理。具体做法是把 RGB 转到 LAB,L 通道做对比度增强(比如 CLAHE),a、b 通道用饱和度因子控制在 1.0~1.3 倍以内。饱和度因子超过 1.3,色相的主观感受就会明显变化。这个方法牺牲一点饱和度提升空间,换来的是皮肤不出戏。
5.4 透射率块状化导致边缘光晕与视频闪烁
现象:静态单张图看,物体边缘一圈白边或黑边;视频上更明显,逐帧之间边缘的“光圈”亮度在跳。
原因:暗通道的最小值滤波窗口是逐帧独立估计的,透射率图在边缘处出现阶跃,加上引导滤波参数不匹配,相位光晕被保留甚至放大。视频场景里,水体波动导致每帧暗通道的高亮像素位置变化,A 值轻微抖动,于是透射率整体在浮动。
解决:静态图先把暗通道窗口缩到 7×7,再配合 eps 更小的引导滤波。视频另加一个时间域平滑,对 t 做指数滑动平均,例如 t_smooth = alpha * t_now + (1 - alpha) * t_prev,alpha 取 0.3 左右。这样牺牲少量帧间灵敏度,换来逐帧稳定,ROV 视觉的后续目标检测不会被处理过的抖动骗到。
5.5 同一套参数换个水域直接翻车
现象:算法在水族馆小水体里调试得很完美,一到自然水域就整幅图发灰发绿,怎么调都不对。
原因:衰减系数 η_c 是水体属性,不是通用常量。清澈海水和浑浊河流的 η_R 可以差三倍以上,同一套 red_gain、omega 不可能两头通吃。水下颜色校正本来就是一个“场景相关”算法,参数要在新场景重新标定。
解决:采集时带一张红绿蓝三色卡放到底部附近,通过色卡上各通道的实测均值与标准值的差值估算 η_R、η_G、η_B,再把这三个系数代回到透射率公式 t_c = e^{−η_c·d} 里,后续所有参数都会自动适配。没有色卡时,退一步用红色通道均值占绿色通道均值的比例大概估计水质,比例越高水体越浑,red_gain 要相应提高。
6. 把校正做成有据可查的管线:先标定再批处理,用指标说话
单张图跑通只是第一步,真正能交付的是一条管线:输入视频帧或图像序列,先判断水体类型,再按标定参数走白平衡,分通道暗通道估计透射率,引导滤波细化,最后做色度保持的后处理,同一时间把处理前后的 UCIQE、UIQM 和 ab 均值色偏因子记录下来。我现在的习惯是每次改完参数,先跑一批测试图,把三个指标打在同一张表里,确认没有“UCIQE 涨但 UIQM 跌”的分裂,再拿真实视频做逐帧稳定性检查。视频里尤其要盯住远处水体背景,那一块最容易在帧间闪。
验证合成实验时,自己生成真值是很有用的手段:选一张清晰陆上图像,按 2.2 的分通道衰减模型合成水下退化图,然后跑校正并与原图算 PSNR/SSIM。这套流程能验证的“物理参数”是标定出来的衰减系数,而不是某个 opencv 函数的默认值。水下图像处理这个方向,参数有很强的场景耦合性,没有一套万能数值;但只要能标定衰减系数、留好质量评价记录、按场景重新跑一遍标记参数,这个管线基本就能支撑实际项目交付。我被“这张图效果很好”坑过太多次,后来发现只要把处理前后的指标和调色板截图并排摆出来,很多争议当场就消失了。希望帮到你。
本文还有配套的精品资源,点击获取