
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_channels128) # 分类分支 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%几乎可以忽略。通道注意力的加的位置也有讲究。我一开始把注意力模块加在压缩卷积之后、两个分支之前发现效果一般。后来调整到分类分支内因为回归任务对位置更敏感过度依赖全局通道统计反而可能造成小目标框抖动。这个改动让mAP0.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_kernel9): ksize random.choice([3,5,7,9]) kernel np.zeros((ksize, ksize), dtypenp.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个点。训练结束后我专门用测试集做了消融实验。结果如下模型mAP0.5mAP0.5:0.95推理耗时(ms)YOLOv5s 默认84.751.36.8YOLOv5s 解耦头86.253.17.0YOLOv5s 解耦头 SE注意力87.154.27.1YOLOv5s EfficientHead 完整版88.555.67.2完整版EfficientHead在mAP0.5上比默认提升了近3.8个百分点推理耗时只增加了0.4毫秒对实时视频分析来说非常值。3. 实操过程与核心环节实现3.1 环境搭建与源码结构准备我的环境版本供参考Ubuntu 20.04Python 3.8PyTorch 1.9CUDA 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, reduction16): super().__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Conv2d(channels, channels // reduction, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(channels // reduction, channels, 1, biasFalse), 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_size3): super().__init__() self.body nn.Sequential( nn.Conv2d(in_channels, in_channels, kernel_size, paddingkernel_size//2, groupsin_channels, biasFalse), nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels, out_channels, 1, biasFalse), ) def forward(self, x): return self.body(x) class EfficientHead(nn.Module): def __init__(self, in_channels_list, num_classes, anchors_per_grid3): 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, biasFalse)) 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后的mAP0.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)意思是让模型预测的宽高偏移带一个基础量加速收敛。这个操作让我在后续训练中mAP0.5又提了0.6个点。不过这个方法不是万能的具体补偿值还要根据你在自己数据上的分布来调。4.4 源码复用时的三个隐藏坑第一类是自定义算子导致的导出问题。如果要把模型导出成ONNX或者TensorRT深度可分离卷积里的groupsin_channels在旧版ONNX中导出为GroupConv部分推理引擎不支持。遇到这种情况可以在导出时把SepConv中的卷积改成普通3×3卷积或者换用新版本的onnxsimplifier。第二类是数据标注目录的路径问题。YOLOv5训练时如果数据集图片路径带中文部分老版本代码会报编码异常。务必把数据集路径设置为纯英文别给自己增加无谓的麻烦。第三类是自定义head后torch.jit.trace会提示动态尺寸不支持。因为检测头输出是列表而非一个固定形状的tensor。如果要用TorchScript部署最好写一个后处理函数把列表在forward里直接拼接好再返回这样trace才能通过。写在最后的调试心得把YOLOv5默认检测头替换成EfficientHead整体改动量不算大却能在游泳比赛这种目标尺度跨度大、遮挡多、反光严重的场景里拿到明显增益。对我个人来说最值得的学习是明白了分类和回归分支解耦后如何分别调节权重以及注意力模块放对位置比单纯堆模块更重要。如果你也要在类似体育场景里做检测不必纠结于追逐最新的模型框架先把基线跑通再针对数据的真实难点改造头部结构效果可能比自己换一个“更大”的模型更好。有一点要提醒模型上线前一定要在未被清洗过的原始比赛视频上测一段完整比赛而不只是测试集图片。很多问题在单张图片上根本暴露不出来只有连续帧运行时才能真正看出检测框的稳定性和推理速度。