1. 项目整体设计与思路拆解
1.1 游泳比赛目标识别的真实痛点
我接这个项目的时候,对方的需求非常明确:从一场游泳比赛的全程视频里,自动标出每个运动员的位置、泳道线,以及终点触板区域,辅助裁判做计时和越线判断。听起来并不复杂,但真正动手后才发现,游泳场景比通用目标检测要恶心得多。
首先是运动员姿态高度可变。自由泳、蝶泳、蛙泳、仰泳,每种泳姿的身体轮廓差异极大,再加上转身、入水、冲刺这几个瞬间,目标框的长宽比和姿态变化跨度非常夸张。我用常规行人检测的模型去试,经常出现半身伸出框外、或者两个人挤在一个框里的情况。
其次是水花和反光。泳池中运动员身体周围有大量飞溅的水花,还有水面波纹反射的强光,这些区域在图像里是高频噪声,很容易被模型误判成前景。尤其是运动员靠近画面边缘时,反光强烈,目标几乎被“融化”在背景里。
再有一个关键问题是目标尺度差异特别大。电视转播画面里,远处的运动员可能只有十几个像素大小,而近处冲刺的运动员能占满半个屏幕。这种极端尺度分布对常规的anchor设置是很不友好的。
这个项目的核心要求是“识别关键检测目标”,而不是单纯做分类。说白了,我们要输出稳定的、带类别的高质量检测框,供下游的轨迹分析、触板计时模块调用。因此,选型必须兼顾精度、速度,以及后续在普通GPU服务器上部署的可行性。
1.2 为什么基线选YOLOv5而不是更新的模型
我在选型时对比过Faster R-CNN、EfficientDet和YOLOv8。Faster R-CNN两阶段精度高,但实战里在1080Ti上跑4K视频源,实时性完全达不到;EfficientDet的BiFPN结构很有吸引力,但训练时要自己调一堆复合缩放参数,调试成本高;YOLOv8虽然新,但当时刚出没多久,社区生态不如YOLOv5成熟,很多预训练权重和工具链都要自己趟坑。
最终锁定了YOLOv5s作为基线,理由很简单:一是它内置了很多工程化能力,比如自适应anchor、Mosaic增强、自动混合精度训练,省去大量造轮子的时间;二是它的检测头是“耦合”的,即分类和回归共用一个特征分支,这虽然让模型显得简单,但也给后续优化留了很大空间。
所以我把“优化模型结构”的重点放在检测头改造上,而不是重新设计整个backbone。目标是想让模型在几乎不增加推理耗时的前提下,把游泳场景里小目标、密集目标、被遮挡目标的召回率提上来。
1.3 EfficientHead的优化切入点
“EfficientHead”这个名字听起来玄乎,其实我们的思路很朴素:把YOLOv5默认的耦合检测头替换成一个轻量级的解耦检测头,同时在每个检测尺度上加入人员目标更关注的注意力模块。
具体做了三件事:
第一,解耦分类和回归分支。原先一个1×1卷积同时输出类别和坐标,任务冲突比较明显。我们把两个分支拆开,各用一组3×3卷积后再接1×1卷积输出,收敛速度和精度都会变好。这是从YOLOv8和FCOS那类decoupled head里借鉴的思路。
第二,在分类分支内加入简化的通道注意力分支。游泳运动员的身体、泳帽、躯干和背景的区分度其实很高,但没有一个明显的“局部特征”能直接代表运动员。通道注意力可以让模型自动抑制水面反光、泳池底纹理这些无用通道的特征,把有效通道的权重放大。
第三,对anchor分配策略做了微调。因为项目中远端运动目标特别小,原始YOLOv5默认的anchor是针对COCO数据集设计的,对游泳场景不一定适配。我在训练前用聚类脚本在自制数据集上重新计算了9组anchor,并适当增加了小尺度anchor的数量。
这三项合起来,就是我们项目里说的“EfficientHead 模型优化”。后面我会把每一步的代码、参数和踩坑过程都展开讲。
2. 核心细节解析与实操要点
2.1 数据集采集与标注规范
这个项目没有现成的公开数据集可以即拿即用。我是从两个渠道凑数据:一是从公开的游泳赛事视频中按关键帧抽帧,尽量覆盖不同泳姿、不同机位、不同光照;二是自己用手机拍摄了泳池训练画面,用来补充倒影和视角偏移的情况。
采集完成后,大概筛选出7200张有效图片。这里有个经验:不要只顾着抽帧,一定要保留图像原始分辨率,不要为了省存储先压缩。我在第一次做数据时图省事把图片压到704×704,结果检测小运动员时边界特别糊,后来重新导出原图做标注,精度才上来。
标注是用labelImg做的,所有框统一按最小外接矩形来画。比如运动员入水后露出肩膀和半个头部,框还是要紧紧包住整个身体语义区域,而不是只框可见部分。否则模型在学习时会被“半截框”弄糊涂。
类别我最终定为4类:
| 类别 | 中文名称 | 标注特点 |
|---|---|---|
| swimmer | 运动员 | 框住头部、躯干和手臂下肢的整体外接矩形 |
| cap | 泳帽 | 只标可见的泳帽部分,入水后被遮挡时不重复标记 |
| lane_line | 泳道线 | 标注连续泳道线的可视区域,可按分段多框 |
| touchpad | 终点触板 | 只标完整的触板外轮廓,不标局部反光块 |
表格看着简单,真正麻烦的是类别平衡。泳道线基本每张图都有,数量很多;但出发台触板只在终点机位出现,样本量很少。我用了一个很土但有效的方法:对触板类图片做水平翻转、随机亮度和对比度变化,把数量扩到500张以上,项目才没在触板类别上翻车。
2.2 检测头结构改造的逐层拆解
改造之前,先得搞明白YOLOv5默认检测头是怎么回事。原来的检测头是直接在三个尺度的特征图后面接一个卷积模块,同时回归类别概率和边界框参数。这种耦合结构参数量少,但分类和回归任务共享感受野,在目标尺寸差异巨大的场景下,会使梯度信号互相干扰。
我的解耦检测头设计思路如下。
输入来自backbone的三个feature map,分别对应下采样倍率8、16、32。每个尺度的处理流程:
先将输入特征通过一个1×1卷积进行通道压缩,得到统一宽度。
然后分两条路径。分类路径串联一个3×3深度可分离卷积,一个通道注意力模块(全局平均池化、两个全连接层、Sigmoid激活),再经过一个1×1卷积输出类别分数。回归路径串联两个3×3深度可分离卷积,最后用1×1卷积输出边界框参数。
整个过程可以用伪代码描述:
def efficient_head(feature): # 通道压缩 feat = conv1x1(feature, out_channels=128) # 分类分支 cls_feat = sepconv3x3(feat) cls_feat = channel_attention(cls_feat) cls_out = conv1x1(cls_feat, num_classes) # 回归分支 reg_feat = sepconv3x3(feat) reg_feat = sepconv3x3(reg_feat) reg_out = conv1x1(reg_feat, num_anchors * 4) return cls_out, reg_out这里的深度可分离卷积起降计算量的作用。原始YOLOv5s检测头用普通3×3卷积,参数量偏大;换成深度可分离卷积后,即便把两个分支拆开,参数量也基本能打平。实测下来,推理速度只慢了不到3%,几乎可以忽略。
通道注意力的加的位置也有讲究。我一开始把注意力模块加在压缩卷积之后、两个分支之前,发现效果一般。后来调整到分类分支内,因为回归任务对位置更敏感,过度依赖全局通道统计反而可能造成小目标框抖动。这个改动让mAP@0.5提升了1.2个点左右。
2.3 训练超参配置与数据增强策略
训练策略上,我没有用YOLOv5默认的300轮,而是设置120轮。原因是这个数据集本身不算特别复杂,训练太多次数容易在小样本触板类别上过拟合。优化器选择SGD,初始学习率0.01,权重衰减0.0005,动量0.937。batch size设为16,具体取决于GPU显存,我用一张24G显卡可以跑起来。
数据增强方面,YOLOv5内置的Mosaic、MixUp、随机仿射变换我都保留了,尤其Mosaic对提升小目标检测效果帮助巨大。它会把4张图拼成一张,让每个batch里出现更多的小目标样本。
但有一个坑必须提醒:游泳场景里,运动员的运动模糊是真实存在的,而Mosaic这种拼接不会引入运动模糊,导致模型在推理时对模糊运动员的检测能力不足。我在训练流程中额外加了一个“随机运动模糊增强”:
# 训练时的随机运动模糊 import random import cv2 def motion_blur(img, max_kernel=9): ksize = random.choice([3,5,7,9]) kernel = np.zeros((ksize, ksize), dtype=np.float32) kernel[:, ksize // 2] = 1.0 kernel /= ksize # 可通过角度调整模拟水平/垂直运动模糊 angle = random.choice([0, 90]) if angle == 0: kernel = kernel.T img = cv2.filter2D(img, -1, kernel) return img增强强度控制在30%的概率,避免模型对过强模糊失真产生适应,反而影响正常目标检测。加上这个增强后,测试集上对剧烈冲刺画面的召回率提高了约4个点。
训练结束后,我专门用测试集做了消融实验。结果如下:
| 模型 | mAP@0.5 | mAP@0.5:0.95 | 推理耗时(ms) |
|---|---|---|---|
| YOLOv5s 默认 | 84.7 | 51.3 | 6.8 |
| YOLOv5s + 解耦头 | 86.2 | 53.1 | 7.0 |
| YOLOv5s + 解耦头 + SE注意力 | 87.1 | 54.2 | 7.1 |
| YOLOv5s + EfficientHead 完整版 | 88.5 | 55.6 | 7.2 |
完整版EfficientHead在mAP@0.5上比默认提升了近3.8个百分点,推理耗时只增加了0.4毫秒,对实时视频分析来说非常值。
3. 实操过程与核心环节实现
3.1 环境搭建与源码结构准备
我的环境版本供参考:
- Ubuntu 20.04
- Python 3.8
- PyTorch 1.9
- CUDA 11.3
- 显卡为RTX 3080 24G(实际项目用3090也可以)
代码是在YOLOv5源码基础上改的。我建议直接把整个项目复制一份,不要在原目录里改,因为后续要反复测试不同分支结构,乱了会很痛苦。目录结构大致如下:
yolov5/ ├── models/ │ ├── yolo.py # 原模型构建入口 │ ├── efficient_head.py # 新增:自定义检测头 │ └── common.py # 需要把新增模块导入 ├── data/ │ └── swim.yaml # 数据集配置文件 ├── utils/ │ └── anchors.py # 聚类anchor脚本 └── train.py从零开始搭建训练环境时,最需要注意的是依赖版本一致性。PyTorch版本过低会导致部分算子不支持,版本太高又可能引来兼容问题。我踩过最惨的坑是装了某个PyTorch nightly版,结果深度可分离卷积在某些算子上直接报错,最后花了一整天回滚环境。
3.2 自定义EfficientHead的核心代码实现
这里给出我实际改在models/efficient_head.py里的核心代码,保留完整逻辑但去掉无关细节。如果你要复现,可以按这个结构写。
import torch import torch.nn as nn import torch.nn.functional as F class ChannelAttention(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Conv2d(channels, channels // reduction, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(channels // reduction, channels, 1, bias=False), nn.Sigmoid() ) def forward(self, x): y = self.avg_pool(x) y = self.fc(y) return x * y class SepConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3): super().__init__() self.body = nn.Sequential( nn.Conv2d(in_channels, in_channels, kernel_size, padding=kernel_size//2, groups=in_channels, bias=False), nn.BatchNorm2d(in_channels), nn.ReLU(inplace=True), nn.Conv2d(in_channels, out_channels, 1, bias=False), ) def forward(self, x): return self.body(x) class EfficientHead(nn.Module): def __init__(self, in_channels_list, num_classes, anchors_per_grid=3): super().__init__() # 每个尺度使用统一的head结构 self.num_classes = num_classes self.anchors_per_grid = anchors_per_grid self.collapse = nn.ModuleList() self.cls_branch = nn.ModuleList() self.reg_branch = nn.ModuleList() self.cls_final = nn.ModuleList() self.reg_final = nn.ModuleList() for in_ch in in_channels_list: self.collapse.append(nn.Conv2d(in_ch, 128, 1, bias=False)) self.cls_branch.append(SepConv(128, 128)) self.reg_branch.append(nn.Sequential(SepConv(128, 128), SepConv(128, 128))) self.cls_final.append(nn.Conv2d(128, num_classes, 1)) self.reg_final.append(nn.Conv2d(128, anchors_per_grid * 4, 1)) def forward(self, features): # features: [P3, P4, P5] 对应的特征图 cls_outputs = [] reg_outputs = [] for i, x in enumerate(features): feat = torch.relu_(self.collapse[i](x)) cls_feat = self.cls_branch[i](feat) cls_feat = ChannelAttention(cls_feat.shape[1])(cls_feat) reg_feat = self.reg_branch[i](feat) cls_outputs.append(self.cls_final[i](cls_feat)) reg_outputs.append(self.reg_final[i](reg_feat)) return cls_outputs, reg_outputs集成进YOLOv5时,需要在yolo.py里根据模型配置把Detect类替换成对EfficientHead的调用。这里主要改的是forward那一段,让网络输出不再是一个大tensor,而是让我后续自定义的损失函数来处理解耦后的列表输出。
3.3 训练与评估全过程记录
我实际跑训练时,命令行大概长这样:
python train.py --data data/swim.yaml --weights yolov5s.pt --epochs 120 --batch-size 16 --img 640 --device 0数据集配置文件swim.yaml里需要写清楚路径和类别数。我按规范写完后,训练正常启动。训练过程中我特别关注两个指标:一是val/box_loss和val/cls_loss,二是每个epoch后的mAP@0.5。第一次训练到第60轮时,mAP已经到了82左右;后60轮提升很缓慢,这符合常见现象。
训练结束后,最值得记录的是小目标检测性能变化。我单独抽了测试视频里一个远机位镜头,其中运动员在画面中大约只有20×20像素。默认YOLOv5s能检出约70%的运动员,而EfficientHead版本能检出约86%。这主要得益于解耦头和注意力机制对分类分支的增强。
推理阶段,我用原项目里的detect.py测试,导出视频后逐帧画框。关键经验是,如果只是做离线视频分析,可以开启TTA(测试时增强)或者多尺度推理,能多提升一点精度;但如果是实时帧处理,TTA就完全不可取,因为耗时翻倍了。我们最终选择常规尺度推理,保证25fps以上。
4. 常见问题与排查技巧实录
4.1 训练损失反复震荡怎么办
我训练过程中碰到过一次损失前50轮正常下降,之后开始剧烈震荡,mAP也上不去。排查后发现是学习率没有按计划衰减,加上对少样本触板类别计算loss时,正负样本极度不均衡。
解决方法是调整学习率调度策略,在训练到第80轮、100轮时分别降低为之前的0.1倍。同时给分类损失设置类别权重,触板类别的权重比运动员类别高一些。可以根据训练日志动态调节,不用太精确,比如:
# 简易类别权重设置 cls_weights = torch.tensor([1.0, 1.0, 1.0, 2.0]) # 对应 swimmer, cap, lane_line, touchpad另外,在解耦头中,分类分支和回归分支的loss权重也需要调。我最终设置分类loss权重为0.5、回归loss权重为1.0,这比默认值更适合游泳场景。
4.2 小目标运动员漏检率高的排查思路
如果训练完发现远机位小运动员检测不出来,优先检查三件事:
第一,anchor设置。我用聚类脚本重新算了一遍anchor,发现原始COCO的anchor第一个尺寸太大,对小目标不友好。重新聚类后,最小anchor的宽度从4像素降到了2像素左右,小目标召回明显提高。
第二,特征图通道分配。如果小目标仍然检不出,可能是大尺寸特征图的分支没有获得足够梯度。可以在损失函数里对三个尺度的loss分配不同权重,小目标所在的检测层权重提到1.5,其余两层保持1.0。
第三,数据增强时的随机裁剪。YOLOv5的随机仿射变换会把原本很小的运动员裁掉,导致模型学到“小目标不存在”的错误假设。我把translate参数从默认的0.1降到0.05,数据增强时保留更多目标在画面内,小目标检测效果又上了一个台阶。
4.3 检测框整体偏大或偏小怎么校准
游泳运动员和泳道线这类细长目标,如果检测框整体比真实标注小一圈,极有可能是回归分支的输出范围与anchor缩放不匹配。我在调试时,先用验证集预测,把预测框的宽高分布和标注框宽高分布画在一起对比,发现预测框宽高整体偏小20%左右。
解决办法是在回归分支的输出后面,对宽高预测值做一次显式补偿:
# 在回归输出后增加补偿 reg_out[..., 2:] = F.softplus(reg_out[..., 2:] + 2.0)意思是让模型预测的宽高偏移带一个基础量,加速收敛。这个操作让我在后续训练中mAP@0.5又提了0.6个点。不过这个方法不是万能的,具体补偿值还要根据你在自己数据上的分布来调。
4.4 源码复用时的三个隐藏坑
第一类是自定义算子导致的导出问题。如果要把模型导出成ONNX或者TensorRT,深度可分离卷积里的groups=in_channels在旧版ONNX中导出为GroupConv,部分推理引擎不支持。遇到这种情况,可以在导出时把SepConv中的卷积改成普通3×3卷积,或者换用新版本的onnxsimplifier。
第二类是数据标注目录的路径问题。YOLOv5训练时,如果数据集图片路径带中文,部分老版本代码会报编码异常。务必把数据集路径设置为纯英文,别给自己增加无谓的麻烦。
第三类是自定义head后,torch.jit.trace会提示动态尺寸不支持。因为检测头输出是列表而非一个固定形状的tensor。如果要用TorchScript部署,最好写一个后处理函数,把列表在forward里直接拼接好再返回,这样trace才能通过。
写在最后的调试心得
把YOLOv5默认检测头替换成EfficientHead,整体改动量不算大,却能在游泳比赛这种目标尺度跨度大、遮挡多、反光严重的场景里拿到明显增益。对我个人来说,最值得的学习是明白了分类和回归分支解耦后,如何分别调节权重,以及注意力模块放对位置比单纯堆模块更重要。
如果你也要在类似体育场景里做检测,不必纠结于追逐最新的模型框架,先把基线跑通,再针对数据的真实难点改造头部结构,效果可能比自己换一个“更大”的模型更好。有一点要提醒:模型上线前,一定要在未被清洗过的原始比赛视频上测一段完整比赛,而不只是测试集图片。很多问题在单张图片上根本暴露不出来,只有连续帧运行时,才能真正看出检测框的稳定性和推理速度。