第一次拿到水下拍摄的照片时我一度以为是镜头进水了——整张图蒙着一层墨绿色的雾气珊瑚礁的轮廓几乎消失在背景里。后来才明白这不是设备问题是水本身在做“滤镜”。水对光有选择性吸收红色成分在很浅的水深就被消耗干净蓝绿光却能走得更远悬浮颗粒又把剩余光线散射到各个方向成像设备最终只能记录下偏蓝的底色和灰蒙蒙的层次感。这个问题带来的直接后果是颜色没法用细节看不清。拿这种图去做目标检测或生物分类算法基本只能靠运气。我做的这个项目就是围绕水下图像增强展开的核心思路正是标题里那两条技术路线——先用波长补偿把被水体吸收掉的颜色通道拉回来再用去雾算法处理散射造成的模糊。文章会给出完整的Matlab实现过程、每个模块的设计理由以及我在实验过程中踩过的一些坑。1. 水下图像的病根光在水里衰减与散射有什么不同1.1 吸收主导的颜色失真为什么丢掉的总是红光水下图像的颜色问题根源在于水对可见光的选择性吸收。不同波长的光在水中的衰减系数差异很大红光600~700nm衰减最快绿光次之蓝光最慢。常说的“水体呈蓝色”就是因为蓝光穿透能力最强而红光的能量在几米水深内就基本耗尽。这个现象在增强算法里意味着两件事红色通道的信息是最稀缺的需要重点补偿蓝绿色通道信息相对完整但也会随着深度增加而逐渐衰减不能完全当作“正确”的基准。很多初学者以为水下图像偏蓝只是因为白平衡不对做个自动白平衡就能解决。我一开始也这么干过结果发现自动白平衡把整张图拉成了偏紫红色远处区域尤其严重。根源在于水下的场景根本不是“灰世界”蓝光是背景光的主体红光是信号最弱的成分简单的白平衡假设不成立必须针对通道衰减做差异化处理。1.2 散射主导的雾化对比度下降的来源除了吸收水中还充满了悬浮颗粒物包括泥沙、浮游生物和有机碎屑。这些颗粒对光的作用分两种前向散射物体反射光在到达相机的路上被颗粒偏折导致锐利的边缘变模糊后向散射环境光被颗粒反射进镜头形成一层没有纹理的“光幕”直接叠加在图像上。这就是水下图像发灰、发雾的直接原因。对比度下降不是因为物体本身反差小而是因为每个像素上都叠加了一层来自不同方向的背景散射光。去雾算法要做的事情说白了就是把这一层“光幕”估算出来、减掉再按距离补偿透射率衰减。1.3 两条技术路线的分工与关系把吸收和散射分开来看技术路线就很清晰了波长补偿处理的是“颜色失真”目标是恢复通道间的能量平衡去雾处理的是“对比度下降”目标是去除空间上不均匀的散射光幕。这两个问题不是孤立的。颜色越偏暗通道估计出来的大气光就越偏向蓝绿色去雾结果容易保留色偏反过来如果只做颜色补偿而不去雾画面会从“蓝灰色”变成“色彩更饱满但依然模糊”的图像。所以完整的水下增强流程应当把两者串起来做。2. 波长补偿怎么做从灰度世界到自适应红通道增益2.1 先补色再去雾顺序为什么几乎不能颠倒我在项目里最先试的顺序是“先去雾、再颜色补偿”理由是先去雾能提升对比度再做颜色校正似乎更自然。实验对比下来这个顺序确实能做但效果不太理想。原因在于去雾算法的第一步是估计大气光A。当图像严重偏蓝时暗通道中最亮的像素往往来自蓝绿色区域估算出的A_c三个分量本身就偏斜透射率图的估计也会跟着偏移。结果就是去雾后图像依然带着蓝绿色偏而且因为透射率估计不准容易出现局部光晕。反过来先做波长补偿先把三通道的统计特性拉得比较均衡再去估计大气光透射率图就稳定得多。从工程角度看调参也更容易颜色补偿的参数只有一个增益范围先去雾时颜色和透射率互相纠缠调起来很折磨人。2.2 灰度世界假设的Matlab实现波长补偿的实现方式很多最基础也最容易上手的是灰度世界假设。它认为自然场景中三个颜色通道的像素均值应该接近中性灰所以各通道的补偿增益就等于均值反比。function I_out wavelength_compensation(I_in) % 基于灰度世界假设的波长补偿 I im2double(I_in); R I(:,:,1); G I(:,:,2); B I(:,:,3); mean_r mean(R(:)); mean_g mean(G(:)); mean_b mean(B(:)); % 以绿色为基准计算红、蓝通道的增益 gain_r mean_g / (mean_r eps); gain_g 1.0; gain_b mean_g / (mean_b eps); % 增益限制防止过激补偿 gain_r min(gain_r, 1.8); gain_r max(gain_r, 0.8); gain_b min(gain_b, 1.5); gain_b max(gain_b, 0.7); I_out cat(3, R * gain_r, G * gain_g, B * gain_b); I_out min(I_out, 1.0); end这段代码背后有个很实际的考虑红通道的均值在严重偏蓝的图像里可能只有0.05左右按照灰度世界算出的增益会非常大乘上去之后所有偏红的区域都会溢出。所以必须把增益限制在合理范围宁可补不到位也不要补出满屏的红噪声。2.3 红通道增益要限制避免补出噪声我在早期版本里没有设计增益上限直接让我手头一张水箱测试图变成了一张偏红的图暗部全是紫色斑块。问题就出在红色通道的低灰度区域原本的信噪比就很低过度增益把这些噪声成倍放大。后来我改成了自适应方式不再只依赖全局均值还加入百分位拉伸function I_out red_channel_boost(I_in) I im2double(I_in); R I(:,:,1); % 以红通道的5%~95%百分位为低高端点线性拉伸 low_r prctile(R(:), 5); high_r prctile(R(:), 95); R_stretched (R - low_r) / (high_r - low_r eps); R_stretched min(max(R_stretched, 0), 1); % 再叠加上一步的全局增益 mean_r mean(R_stretched(:)); gain_r min(0.5 / (mean_r eps), 1.8); I_out I; I_out(:,:,1) R_stretched * gain_r; I_out(:,:,1) min(I_out(:,:,1), 1.0); end选择5%和95%分位数而不是0%和100%是为了避开极亮和极暗的离群像素。水下的高光区域往往来自鱼鳞反光、气泡或者白色珊瑚如果让这些点决定拉伸范围会导致整体亮度被压得很暗。2.4 极浑浊水体下的特殊处理还有一种情况值得单独说明非常浑浊的水体里红色通道可能几乎全是0均值接近零。此时无论灰度世界还是百分位拉伸都会因为分母过小而失效。我的处理方式是先判断红通道均值是否小于整体亮度均值的十分之一如果满足就直接把红色通道替换为绿色通道和蓝色通道的加权混合同时降低增益强度。这种做法的逻辑是反正红色信息已经丢失强行补偿带来的只有噪声不如用蓝绿通道推测一个“合理的红色通道”。实际效果虽然不能恢复真实颜色但至少能让画面不出现大面积紫色噪点。3. 去雾模块暗通道先验在水下的关键修正3.1 大气散射模型与暗通道先验去雾模块的理论基础是大气散射模型I(x) J(x) * t(x) A * (1 - t(x))其中I是观测到的图像J是清晰图像A是大气光t(x)是透射率。t(x)越接近1说明光线衰减越少画面越清晰越接近0说明场景被雾覆盖得越严重。暗通道先验的核心观察是在自然界的无雾图像中绝大多数局部区域内至少有一个颜色通道的值非常低、接近零。所以可以通过暗通道估算透射率J_dark(x) min(min(J_c(y)))y在x的邻域内估计透射率的近似公式为t(x) ≈ 1 - ω * min_c( I_c(y) / A_c )其中ω一般取0.95目的是保留一部分残余雾感避免图像被处理得过于锐利、不自然。3.2 大气光估计的水下修正不要只找最亮像素原有暗通道先验在处理普通雾天图像时会从暗通道图里挑前0.1%的最亮像素然后取这些像素在原始图中的最大值作为A。这个做法在水下场景里很容易翻车。原因是水下图像的最亮像素可能来自白色珊瑚、鱼鳞反光甚至气泡这些是高反射物体而不是水体散射导致的“大气光”。如果把它们当成A透射率的估计就会在物体边缘出现异常结果在反光区域周围形成一圈假轮廓。我做了一个小修正先按暗通道排序选出候选像素然后在这部分像素中不用最大值而是用99%分位数来估计每个通道的A。同时增加一个约束条件候选区域中蓝色通道亮度与整体亮度的比值不能超过1.2倍。如果超过说明该区域水体主导成分过强也会对A的估计造成干扰。function A estimate_atmospheric_light(I, Jdark) [h, w, ~] size(I); num_pixels h * w; num_candidates max(round(num_pixels * 0.001), 1); Jdark_vec Jdark(:); [~, idx] sort(Jdark_vec, descend); candidate_idx idx(1:num_candidates); A zeros(1, 3); for c 1:3 channel I(:,:,c); cand_vals channel(candidate_idx); % 用99%分位数代替最大值避免反光点干扰 A(c) prctile(cand_vals, 99); end end这个改动看起来很小但对结果影响很大。我第一次用原版估计A结果白色珊瑚附近的区域被去雾算法错误地增强成发暗的色块后来换了分位数估计这种假轮廓就消失了。3.3 透射率估计、导向滤波细化和下限保护透射率的计算思路和标准暗通道先验类似但在通道归一化时要用修正后的A。具体做法是每个通道除以对应的A_c然后取局部窗口内的最小值作为粗糙透射率。function t estimate_transmission(I, A, omega, patch_size) I_norm I ./ reshape(A, 1, 1, 3); dark_current min(I_norm, [], 3); % 用形态学腐蚀实现局部最小值 dark_local imerode(dark_current, strel(square, patch_size)); t 1 - omega * dark_local; t min(max(t, 0.1), 1); % 下限保护 end这里有两个容易忽略的细节。第一个是局部最小值窗口的选择窗口太小透射率图会出现大量空洞窗口太大透射率过度平滑边缘细节丢失。我测试下来patch_size取15是比较稳的起点针对大分辨率图像可以适度提高。第二个细节是透射率下限。t的下界我固定在0.1。原因从模型公式就能看出来在恢复清晰图像时有一步是(I - A) / t如果t趋近0这一项会爆炸式放大噪声会盖过所有细节。这也是很多水下增强结果看起来“脏脏的”主要原因。大气光的估计相对稳定透射率图却需要进一步细化。直接用局部最小值窗口会导致明显的块状伪影最常用的解决方式是导向滤波。Matlab内置的imguidedfilter可以直接调用引导图用原图的灰度版本即可保边效果好计算速度也能接受。gray rgb2gray(I); t_refined imguidedfilter(t, gray, NeighborhoodSize, 25, DegreeOfSmoothing, 0.1);3.4 清晰图像恢复得到细化的透射率后最终恢复公式为J_c(x) (I_c(x) - A_c) / max(t(x), t0) A_c在代码里我习惯把除法改成逐像素操作并且对G和B通道做一次轻微的Gamma校正让画面明暗更自然function J recover_scene(I, A, t_refined, t0) t_t max(t_refined, t0); J zeros(size(I)); for c 1:3 J(:,:,c) (I(:,:,c) - A(c)) ./ t_t A(c); end J imadjust(J, [0 0.01; 0.99 1], []); end4. 完整的增强流程与效果评价4.1 总处理流程与Matlab主函数把两个模块串起来的整体流程并不复杂读取图像 → 波长补偿 → 暗通道估计 → 大气光估计 → 透射率估计与细化 → 场景恢复 → 后处理。function result underwater_enhance(img_path) I imread(img_path); I_comp wavelength_compensation(I); % 计算暗通道 Jdark dark_channel(I_comp, 15); % 估计大气光 A estimate_atmospheric_light(I_comp, Jdark); % 估计透射率 t estimate_transmission(I_comp, A, 0.95, 15); t_refined imguidedfilter(t, rgb2gray(I_comp), ... NeighborhoodSize, 25, DegreeOfSmoothing, 0.1); % 恢复图像 J recover_scene(I_comp, A, t_refined, 0.1); result J; end这段主函数已经在我的测试集上跑通了大部分场景。需要提醒的是Matlab自身的uint8转换机制有时会让人踩坑如果I_comp是double类型但值域在0~1那么后续的颜色补偿和暗通道计算都没问题但如果中途不小心转回了uint8除法运算会自动取整亮暗层次会有损失。处理过程中尽量统一使用double和0~1范围。4.2 无参考评价指标怎么选水下图像增强的效果评价不能只靠肉眼。用PSNR和SSIM需要一张“无失真参考图”但真实水下场景根本没有标准答案只有受控的合成数据集才能算这些指标。实际项目中更常用的是无参考指标UCIQE综合色彩饱和度、色彩浓度和亮度对比度专门面向水下彩色图像UIQM基于色偏、清晰度和对比度的加权组合也是水下场景常用的。我在项目里以UCIQE为主因为它对颜色恢复程度更敏感。波长补偿做得好不好UCIQE的变化能直接反映出来而只看PSNR的话颜色轻微的偏斜有时候反而会得高分。4.3 我的测试结果与参数基线以我手头这批样本为例它们的来源混杂了实验室水箱、近岸浅水区和公开图集水质差异比较大。处理前的UCIQE大概在0.28到0.36之间经过波长补偿和去雾处理后普遍能到0.45以上视觉效果上最明显的变化是颜色不再发蓝、远处细节能看清轮廓。下面是给后续复现的人参考的参数基线参数推荐值作用patch_size15暗通道窗口大小控制透射率平滑程度omega0.95保留残余雾感数值越小去雾越强t00.1透射率下限防止噪声被放大红通道增益上限1.8防止红色通道补偿过度导向滤波窗口25透射率细化的平滑半径这套参数在轻度浑浊、中等浑浊的水下图像上表现稳定。如果遇到背景以深海蓝色为主的图像omega要适当降到0.90左右因为蓝色背景的暗通道值比自然场景更大默认参数去雾后会偏暗。5. 调试时踩过的坑和可扩展方向5.1 透射率下限调太低噪声直接炸开我最早为了追求“更明显的去雾效果”把t0调成了0.01。结果暗部区域出现了密密麻麻的彩色噪点特别是红色噪声因为红色通道经过波长补偿后信噪比最低一旦除以一个很小的透射率噪声就被放大成颗粒状色块。后来我认真看了透射率分布图才发现暗部区域的t往往在0.2上下低于0.1的像素占比很低。设置t0为0.1既能保护这些区域又不会明显影响整体去雾效果。这个参数不是固定不变的如果图幅很大、暗部占比高建议提高到0.15。5.2 蓝色主导场景里白平衡补红过头的问题灰度世界假设在水下并不是万能的。当场景本身就是大面积的蓝色海水比如深海环境几乎没有暖色物体灰度世界会认定“蓝色通道偏大”于是把蓝色通道压下去、红色通道拉上来。最后输出的图像变成了紫色调看起来很奇怪。针对这类场景我改用红色通道的自适应拉伸方案不再对蓝绿通道做大幅度调整同时把红通道增益上限从1.8降到1.4效果会自然很多。判断场景是否属于“蓝色主导”比较简单的方法是看颜色补偿前后的色相分布变化。如果补偿后的图像红色占比异常增高就说明这个场景不适合灰度世界假设。5.3 从静态图像扩展到视频的平滑处理静态图像的处理结果看起来不错但直接按帧处理视频的话问题就来了水体流动、相机抖动都会让暗通道和透射率估计逐帧跳动视频看起来会闪烁。解决思路是加入时域平滑。对透射率图做滑动平均t_smooth(t) α * t_now (1 - α) * t_prevα取0.2到0.4之间比较合适α太小会导致响应迟钝运动物体容易拖影α太大又失去了平滑效果。大气光A也应该做类似的时间低通处理否则水面的光线变化会让图像亮度周期性跳动。5.4 性能优化暗通道计算别用像素循环第一次写暗通道的时候我用的是三层for循环遍历窗口一张1080p图像跑了几十秒项目进度直接卡住。后来改用形态学腐蚀一次操作就完成了窗口最小值计算速度快了几个数量级。如果要在嵌入式设备或实时环境跑还可以先把图像降采样计算透射率再上采样回原分辨率用于恢复。透射率本来就是低频信息缩小到四分之一计算质量几乎不受影响。我的体会如果让我重做一遍这个项目我会先从一张最典型的偏蓝图像开始把波长补偿调出一个稳定的视觉结果再去动去雾参数。原因很简单去雾的透射率估计很大程度上依赖颜色补偿后的通道均衡状态先稳定了颜色后调透射率调试顺序本身就能避免很多鬼影和光晕问题。另外一个小技巧是调参时尽量用缩略图和实时预览窗口把中间结果暗通道、透射率、大气光可视化出来。只看最终图出了问题根本定位不到是大气光估计错了还是透射率下限不合适。把A和t图打开看一眼很多疑惑当场就能解开。