做视觉的同行应该都有过这种体会:你在晴天、能见度极好的条件下把模型调得漂漂亮亮,一放到雾天场景,精度几乎集体跳水。这不是小概率事件,而是海事类视觉任务里最家常便饭的翻车现场。雾的本质是压低远景亮度、抹平边缘细节,让船身和天空、水面的分界线全部糊成一团,再叠加上水面的反光和波浪纹理,检测框和分割掩码全都开始飘。
我这次记录的项目,就是做一套基于YOLO11-seg的雾中船只检测与识别流程,目标是在大雾、小雨、多云、黄昏这类低对比度环境下,既稳定输出船只的检测框,又输出像素级分割掩码。这套方案放在内河航道、近岸水域的视频监控、无人船避碰、港口辅助瞭望这些场景里都适用,有目标检测基础、想往特种场景落地的同行可以直接参考。
1. 项目整体设计与技术选型思路
1.1 为什么是YOLO11-seg而不是普通检测模型
先回答一个问题:检测船只到底用检测框就够了,还是必须上实例分割?
如果只是统计航道里过了多少条船,普通目标检测完全够用。但实际情况往往没这么简单——船与船靠泊、多船并行、桥下过船,这些场景里检测框互相重叠是常态。两条并排的船在图像上可能只相差几个像素,一个方方正正的bbox根本分不清边界,更别说用它去计算安全距离、判断船只朝向、评估航道占用率。
分割掩码能直接给到船体的实际轮廓,避碰系统拿到轮廓之后,就可以计算两船之间的最小净距、判断船只吃水线位置、估算船体投影面积。这些都是纯bbox方案给不了的。我一开始也想过用Mask R-CNN这类两阶段实例分割模型,精度确实高,但单张推理时间在实时视频流里完全扛不住——海事监控通常同时挂着好几路摄像头,速度不够就等于不可用。
YOLO11-seg属于单阶段方案,检测头和解码头共用主干特征,一条前向推理同时出检测框和掩码。实测下来,在相同的硬件条件下,它的推理速度比两阶段方案快两到三倍,精度损失基本可以接受。对实时性敏感的岸基监控、船载辅助瞭望来说,这种平衡性是决定性的。
1.2 雾天场景的三个核心难点
雾天做船只检测,难点并不只是“图像变模糊”这么简单。
第一是能见度退化。雾天图像的退化规律可以用经典的大气散射模型描述:观测到的图像强度等于场景辐射经过透射衰减后加上大气光的散射叠加。问题在于这种衰减不是均匀的——距离越远,透射率越低。所以同一张画面里,近处的船和远处的船呈现出完全不同的退化程度,模型必须同时适应两种差异很大的视觉特征。
第二是小目标问题。海事摄像头的安装位置普遍比较高,无论是岸基杆塔还是船载桅杆,拍到的远距离船只往往只有几十个像素甚至几个像素。雾天再抹掉一部分边缘和纹理细节,有效像素进一步缩水。小目标检测本来就是视觉任务的老大难,加雾之后难度直接翻倍。
第三是背景干扰。水面反光、波浪纹理、浮标、水花在低对比度环境下,和船体的灰度纹理高度相似。雾天里船身和高亮水面的区分度变低,模型很容易把浪花和浮标当成船,误检率急剧上升。
这三个难点决定了项目不能直接套用常规的目标检测训练流程,数据、模型、部署每个环节都要针对雾天做专门的适配。
1.3 两条技术路线的权衡取舍
针对雾天检测,行业里主要有两条技术路线。
路线A是“先去雾,再检测”。典型做法是用暗通道先验(DCP)或者专门训练的去雾网络,先把雾天图像恢复成近似无雾图,再喂给普通检测模型。这个路线的好处是可以复用成熟的无雾模型权重,思路直观。但问题也很明显:多了一个预处理环节,推理耗时增加;而且去雾算法在天空区域和水面高光区域容易产生色偏和伪影,有时候去完雾反而比原图更难检测。
路线B是“不做显式去雾,用雾天数据增强训练”。通过在训练阶段大量引入雾天合成样本和真实雾图,让模型自己去学习雾天条件下的特征表达,推理时原图直接进网络,不增加任何额外的预处理模块。
我实测下来,路线B在绝大多数场景下更稳。原因在于:显式去雾本质上是把一张退化图先恢复成另一张图,这个恢复过程本身是有损的,恢复时引入的错误会直接传导给检测器。而路线B让检测器直接学习从退化图到目标的映射,少了中间环节,少了误差传导,推理也更快。
| 对比维度 | 先去雾再检测 | 雾增强训练 |
|---|---|---|
| 推理流程 | 预处理+检测,链路长 | 单模型直出,链路短 |
| 中低雾精度 | 一般,去雾伪影影响检测 | 较优,特征学习更直接 |
| 浓雾适应 | 有一定帮助 | 需配合大量浓雾训练样本 |
| 工程复杂度 | 多模块维护成本高 | 模型训练一次,部署简单 |
当然,路线B不是万能的。在极端浓雾、目标又极小的情况下,显式去雾仍然能带来额外几个点的提升。我现在的做法是做一个雾浓度判断分支,只在浓雾场景下启用轻量去雾模块,中低雾直接走原图。
2. 数据准备与雾天数据集构造
2.1 数据来源与标注规范
做任何视觉项目,数据永远是最耗时间的环节,雾天船只检测尤其如此。
数据来源我分了三块:真实晴天/多云/薄雾场景的船只图像、公开海事船舶数据集里能拿到的图片、以及自己用视频抽帧得到的水域监控画面。这里有个很重要的权衡:公开数据集里干净的晴天样本很多,真实雾天样本相对稀缺,所以要靠合成增强去补足中雾和浓雾样本的缺口。
标注规范这块,我踩过不少坑。船只的分割标注,一定要沿船体可见部分勾轮廓。倒影不算、浪花不算、船体溅起的水花不算,这些区域边缘的灰度特征和船体高度相似,一旦标进去,模型就会学到错误的掩码边界。
还有一个硬规矩:远处能见度极低、只能隐约判断“那是一条船”的目标,宁可不标也不要错标。雾天场景下模型本身就容易混淆目标类别,如果训练集里再混入大量“大概是个船”的粗糙标注,模型学到的先验会越来越歪。高质量标注的比例远比标签总数重要,这一点在低对比度场景里尤其突出。
2.2 基于大气散射模型的雾天增强
合成雾的数据增强,核心就是应用大气散射模型:
[ I(x) = J(x) \cdot t(x) + A \cdot (1 - t(x)) ]
其中 (I) 是合成雾图,(J) 是清晰图,(t) 是透射率图,(A) 是大气光,(x) 是像素位置。透射率 (t(x) = e^{-\beta \cdot d(x)}),这里的 (\beta) 是大气散射系数,(d) 是场景深度。
写一个可以直接用的合成函数(基于OpenCV和NumPy):
import numpy as np import cv2 def synthesize_fog(image, depth=None, beta=1.0, A=None): """ 基于大气散射模型合成雾图 image: 清晰图 (H, W, 3),RGB,uint8 beta: 散射系数,越大雾越浓 """ img = image.astype(np.float32) / 255.0 h, w = img.shape[:2] # 没有深度图时,随机生成一个平滑的深度图 if depth is None: x = np.linspace(0.3, 1.0, w).reshape(1, -1) y = np.linspace(0.3, 1.0, h).reshape(-1, 1) depth = x * y # 近处暗,远处亮,模拟水平方向的深度变化 # 透射率图 t = np.exp(-beta * depth) t = np.clip(t, 0.05, 1.0) # 大气光,默认取偏亮的灰度值 if A is None: A = np.random.uniform(0.7, 1.0, (1, 1, 3)) # 合成雾图 foggy = img * t[..., None] + A * (1 - t[..., None]) return (np.clip(foggy, 0, 1) * 255).astype(np.uint8), t这个函数是简化版本,实际工程里深度图不会这么粗糙,但核心思路是一样的。参数 (\beta) 的选择直接对应雾的浓度等级:0.5到1.0是轻雾,1.0到1.8是中雾,1.8到2.5就是浓雾。训练集里这些档位最好都覆盖,比例可以控制在轻雾3成、中雾4成、浓雾3成,避免模型只对某一种雾浓度敏感。
2.3 合成雾的几个隐藏坑
用大气散射模型合成雾图,看起来简单,实际操作里有几个坑必须注意。
第一个坑是天空区域。天空在物理上没有明确的深度,如果直接套随机深度图,合成出来的天空会出现奇怪的偏色斑块,看起来像一块脏玻璃。处理办法是先用语义分割或者颜色阈值把天空区域抠出来,给一个固定的较大深度值,让天空区域的雾看起来均匀自然。
第二个坑是深度图平滑度。如果深度图有明显的突变边缘,合成出来的雾会呈现不自然的块状断层,在船体和背景交界处尤其明显。深度图一定要经过高斯平滑,过渡要缓,这样合成雾才接近真实雾气的连续衰减效果。
第三个坑是雾浓度单一。只加一种浓度的雾,模型学到的就是单一退化特征的映射,换一个场景浓度稍变就不准。我建议在每个训练批次里随机抽取β值,让模型在每一轮都看到不同浓度的雾,逼着它学到尺度不变的船体特征。
第四个坑是合成雾永远替代不了真实雾。合成雾遵循的是数学公式,真实雾还包含水汽颗粒的漫反射、不均匀的团雾流动、光照方向变化这些复杂因素。训练集里必须有真实雾天样本参与,我一般按合成雾六成、真实雾四成的比例混着用。这里还有一个容易忽略的细节:验证集一定要保留真实雾图,不能用合成雾去评估,否则测出来的成绩是虚高的。
3. 模型训练与参数配置实践
3.1 训练环境与数据组织
整个训练流程是在单张24GB显存的GPU上完成的(RTX 3090级别就够用),软件环境是Ubuntu 20.04、Python 3.10、PyTorch 2.1、CUDA 11.8,训练框架直接用的Ultralytics的YOLO11系列。
数据目录结构建议这样组织:
datasets/ foggy_water/ images/ train/ val/ labels/ train/ val/对应的数据配置文件里,类别设置这块我需要单独说明。很多公开数据集把船分成ship和boat两类,但这两个词在不同场景下的定义非常混乱,给模型造成了额外的分类负担。我更推荐统一类别或者按实际用途拆,比如“货船”“渔船”“客船”,或者干脆就一类“船”,把主要精力放到分割掩码的质量上。对雾天场景来讲,类别粒度越细,类间混淆越严重,先保证召回率往往是更务实的选择。
3.2 核心训练参数与逐项解释
这是项目里最值得反复调的部分。直接贴我当时用的训练命令:
yolo segment train \ data=foggy_water.yaml \ model=yolo11s-seg.pt \ epochs=150 \ imgsz=1280 \ batch=8 \ device=0 \ optimizer='SGD' \ lr0=0.01 \ mosaic=1.0 \ close_mosaic=10 \ fliplr=0.5 \ flipud=0.1 \ weight_decay=0.0005逐项讲一下为什么这么设。
imgsz=1280是整条训练里最关键的一项。前面说过雾天船只是典型的小目标,输入分辨率直接决定小目标在特征图上的有效像素数量。640输入下,一条远处20像素宽的船,下采样到40×40的特征图上只剩几个像素的点;1280输入直接让有效特征翻倍。代价是显存占用和训练时间上涨,但对精度的影响非常明显。实测从640涨到1280,mAP50大概能提升8到10个点。
batch=8是单卡显存限制下的安全值。optimizer='SGD'用的Ultralytics默认范式,配合lr0=0.01和weight_decay=0.0005,这类检测任务的收敛稳定性和泛化性都比重启AdamW更稳。
mosaic=1.0表示全程开启马赛克增强,把四张图拼在一起训练。对小目标提升显著,因为它相当于把小目标在拼接图里的尺度压缩了——模型被迫去学习更小尺度的特征。但马赛克增强也会在最后阶段引入一些不自然的分布,所以close_mosaic=10表示最后10个epoch关闭,让模型从平滑的真实分布里做精修。
flipud=0.1这里我要特别提醒。水域场景里,船的上下翻转会生成大量“船底朝上”的假样本,这在物理上几乎不会出现,模型学了反而会混淆。普通检测任务里flipud开0.5是常态,但水域场景建议降到0到0.1,让模型专注于学习正确的船体姿态。
3.3 训练过程观察与损失信号
训练过程中分阶段的损失变化,可以作为判断模型状态的参照。我实践里比较典型的训练曲线是这样的:前20个epoch,box loss和cls loss快速下降,mAP50能从0.2左右爬到0.6;mask loss的下降速度明显慢于box loss,一直到第80个epoch,精细掩码才开始逐轮廓稳定下来。
这里有两个信号要重点盯。
第一个是mask_loss。掩码损失不降,大概率是标注问题——标注轮廓里混入了倒影、浪花这类噪声边界,模型学不出一个“干净的船体应该长什么样”。这时候优先回头清理标注,而不是盲目加训练轮数。
第二个是mAP50-95和mAP50的差距。如果这两个指标相差过大,说明模型虽然能把船框住,但框的定位不够准、掩码和真实轮廓的贴合度差。这通常指向输入分辨率不够或者雾天小目标特征丢失的问题。
3.4 评估结果怎么算才算数
评估指标只看最终mAP数字远远不够,尤其是雾天这种环境依赖度极高的场景。我当时做了三个层次的分析。
第一层是标准COCO指标,mAP50、mAP50-95、mask mAP,这些指标做横向对比是有意义的,但单看一个平均值容易掩盖问题。
第二层是按距离分层统计。把验证集按船只在画面中的像素宽度分成近景、中景、远景三档,分别计算召回率。雾天场景里远景小目标的召回率往往比近景低一大截,如果不分层统计,模型“只看近景不看远景”的问题会被平均值掩盖掉。
第三层是整个项目最关键的——真实雾天视频验证。直接把模型跑到真实雾天监控视频上,观察每帧的检测稳定性、漏检出现的频率、误检主要集中在画面什么位置。这一步能发现很多离线指标看不出来的问题,比如某个特定焦距下模型反复漏检、某个时间段的光线方向让误检率飙升。
我用s/m/l三个规格的模型做过一次完整的对比:
| 模型 | mAP50 | mAP50-95 | 掩码mAP | 推理耗时(ms) |
|---|---|---|---|---|
| YOLO11s-seg | 0.76 | 0.54 | 0.51 | 3.2 |
| YOLO11m-seg | 0.81 | 0.59 | 0.56 | 6.5 |
| YOLO11l-seg | 0.84 | 0.62 | 0.60 | 11.8 |
表格里的推理耗时是在单张RTX 3090、FP16精度下测的。一眼就能看出问题:l模型只比s模型高8个点的mAP50,耗时却是3倍多。对实时性要求高的场景,这个性价比并不划算。所以后面我在部署环节花了不少精力做轻量化优化,尽量把s模型的精度往上拉。
4. 推理部署与性能优化
4.1 推理时要不要去雾
我在推理阶段专门做了一组对照实验:不去雾、DCP暗通道去雾、轻量级学习式去雾,三种前置处理在中雾和高雾两类样本上的对比。
结果很有参考价值。中雾场景下,不去雾的模型mAP50是最高的,加了DCP去雾反而掉了3到5个点。原因前面说过,DCP在天空和水面区域容易产生伪影,这些伪影对检测器来说就是一种新的干扰。而且DCP本身有块效应,局部区域的透射率估计经常出现阶梯状突变,恰好破坏了船体轮廓这种需要连续边缘信息的结构。
高雾场景下情况不同。能见度极低的时候,目标在原始图像里几乎只剩一个模糊的灰度印记,这时轻量去雾模块确实能把部分轮廓信息捞回来,mAP50提升2到3个点。但代价是推理耗时从3.2毫秒涨到11毫秒左右,而且去雾效果高度依赖大气光估计的准确度。
所以我最终的方案是一个可切换的雾浓度判断路由:对输入帧先做一个快速的雾浓度估计(用暗通道均值就能判断),低于阈值直接进检测模型,高于阈值才启动轻量去雾。中低雾本来就是大多数水域场景的常态,这样能在保证绝大多数帧速度的同时保留极端情况下的兜底能力。
4.2 TensorRT加速与量化踩坑
部署优化的第一步是转TensorRT。YOLO11-seg导出TensorRT FP16之后,推理耗时大约能比PyTorch原生推理快1.5到2倍,精度几乎无损。这一步比较成熟,直接导出就行。
真正坑的是INT8量化。除了检测头,分割掩码分支在量化之后特别容易崩。我实测的其中一个版本,INT8量化后检测框mAP只掉了1个点,掩码mAP却掉了将近8个点,表现为掩码大面积缺失或者边缘出现明显的锯齿状断层。原因是mask分支的特征分布和检测分支不完全一致,固定比例缩放对mask分支的扰动更大。
如果要做INT8,校准集的选择很关键。校准集里必须覆盖不同雾浓度、不同船型的样本,不能只拿几百张晴天图去校准。校准集不充分,量化后的精度衰减会在雾天样本上被进一步放大。我现在一般优先推荐FP16部署,只有在计算资源极其紧张、且实测INT8掩码掉点可控的前提下才上INT8。
4.3 蒸馏让轻量模型追平重量级
s模型速度快但精度离l模型有差距,所以我做了一组蒸馏实验:用精度最高的YOLO11l-seg当教师模型,YOLO11s-seg当学生模型。除了常规的检测头logit蒸馏,还额外加了mask分支的蒸馏,让学生模型直接学教师模型的掩码输出分布。
蒸馏之后的结果比较理想:s模型mAP50从0.76涨到0.79,掩码mAP从0.51涨到0.55,推理耗时保持3.2毫秒不变。虽然没有完全追平l模型,但性价比已经拉到了最高。如果算力进一步受限,还可以把s模型再蒸馏到更小的n模型,不过那个档位的精度掉得就比较明显了,一般不建议。
4.4 输入尺寸与切片推理的取舍
1280绝对分辨率对小目标友好,但对低算力设备来说负担不小。如果部署目标是一块边缘计算板卡而不是服务器GPU,我建议做切片推理:以更低分辨率作为输入,同时把原图切成若干重叠的瓦片并行推理,最后用NMS把瓦片结果合并回原图坐标。
切片尺寸和重叠率的选取需要实测。我当时测的参数是切片尺寸960×608、重叠率0.2,远景小目标的召回率提升明显,基本达到了1280全图直出的水平,而单帧总耗时控制在合理范围内。
不过切片推理不是免费的,重叠区域会导致同一个目标被多个瓦片反复检测,NMS合并的逻辑要调好。如果场景里远处目标本来就不多,可以只在整图帧率不够的时候才启用切片;近景为主、目标偏大的场景,低分辨率直出就够了,盲目上切片只会白白增加计算量。
5. 常见问题与排查技巧实录
| 现象 | 可能原因 | 排查思路 | 解决建议 |
|---|---|---|---|
| 雾天远距离目标大面积漏检 | 输入分辨率不足,小目标特征丢失 | 查看漏检目标在原始图像里的像素宽度 | 换1280输入或启用切片推理 |
| 浪花、浮标被误检成船只 | 训练集缺少反例样本;背景与船体灰度混淆 | 把误检样本截图,分析目标视觉特征 | 补充含浮标、浪花、水花的负样本;提高正样本的掩码标注精度 |
| 分割掩码边缘粗糙、锯齿明显 | mask分支特征分辨率不足;标注边界不干净 | 对比掩码输出和真实轮廓边界 | 开启mask分支输出更高分辨率的特征;清理训练集中的粗糙标注 |
| 训练到一半loss不再下降 | 学习率过大导致震荡;数据增强过猛 | 观察loss具体是box/cls/mask哪一项停滞 | 降低lr0或改用warmup策略;调低mosaic比例;检查合成雾是否产生异常样本 |
| INT8量化后掩码精度崩盘 | mask分支对量化范围敏感 | 对比检测框mAP和掩码mAP的衰减幅度 | 在最终部署更稳妥的情况下优先FP16,INT8须用覆盖雾天场景的完整校准集 |
| 模型在某个特定时段漏检严重 | 低角度阳光在水面产生强反光;雾浓度骤变 | 记录漏检的时间段和对应画面特征 | 在训练集中补充对应光照方向的样本;必要时启用雾浓度路由增强预处理 |
这里面最容易被忽略的,是误检问题里的“反例样本”逻辑。我一开始也以为误检高就是模型不好,后来逐个截了误检样本才发现,很多是浪花拍岸时溅起的水雾团,在画面里确实长得像一个模糊的椭圆形目标。问题不在模型,在于训练集里几乎没有“像船但不是船”的负样本。这不只是数据量的问题,而是样本结构不完整的问题。
另外一个排查经验是分问题的优先次序。漏检和误检要放在一起权衡,不能只看单独的召回率或精确率。水域场景落地的真实需求是“能及时发现船只”,漏检一辆远距离货船的代价远高于多报一个浪花的代价。所以我在实际调整时,会把目标的检测置信度阈值调到一个相对宽松的值,宁可多一些误检交给后续的帧间跟踪和逻辑判断去过滤,也不牺牲召回。
整套项目跑下来,我自己最深的感受是:雾天船只检测的瓶颈不在模型结构,而在数据和工程细节。YOLO11-seg本身的能力是够用的,真正拉开差距的是有没有把雾天数据的分布构造好、有没有把部署时的预处理策略想清楚、有没有针对自己场景做充分的分层评估。
最后再分享一个小技巧。如果你也是从零开始做类似的水域场景项目,可以先拿现成的晴天模型跑通整个数据标注、训练、部署的链路,确认每个环节都通顺了,再加入雾天数据和增强策略。这样每一步的增益都是可控、可见的,出了问题也知道是哪个环节引入的。直接一步到位上雾天数据,万一效果不好,排查起来会非常痛苦。