☰
YOLOv10n结合SPDConv的蝴蝶鱼细粒度识别与小目标检测实战
2026/10/19 10:06:38 网站建设 项目流程

如果你在珊瑚礁里潜过水,多半见过蝴蝶鱼——体侧扁、游姿飘飘悠悠,身上带着黑色或黄色条纹。可真要让你对着照片说出是哪一种,事情就没那么轻松了。这次项目我做了蝴蝶鱼种类识别与分类,底子选了YOLOv10n,又加入SPDConv做了网络结构改进,目标是让模型在真实水下照片里同时定位并区分多个蝴蝶鱼物种。文章不会只给结论,我会把选型、改模型、训练、踩坑的全过程拆开讲,适合正在做细粒度检测、小目标识别,或者刚接触YOLO系列想动手改网络结构的开发者参考。

1. 蝴蝶鱼种类识别与分类:为什么这个任务值得做个检测项目

1.1 蝴蝶鱼识别的难点不在"鱼"而在"看起来都一样"

蝴蝶鱼科是一个相当庞大的家族,已知的物种超过一百二十个。和普通物体识别不同,蝴蝶鱼在整体轮廓上高度一致:都是侧扁的椭圆形身体,都有一条穿过眼睛的暗带,尾柄处通常带黑斑。不同种类之间的关键判别特征,往往只在体侧条纹的走向、宽度、颜色,以及背鳍末端有没有额外的斑块。

现在想象一种情况:一张水下照片里蹲着一只蝴蝶鱼,背鳍后方有黑斑,但照片是从侧面偏后的角度拍的,黑斑被鳍挡住一半。肉眼判断都容易犯迷糊,更不用说卷积网络。幼鱼和成鱼的体纹差异也很大,有些种类幼鱼体色接近透明,等长到成体才会出现明显的黄色区域和纵带。这种类内变化大、类间差异小的数据分布,是典型的细粒度识别(fine-grained recognition)问题。模型如果没有能力捕捉高频的条纹细节,几乎必然会混淆相似种。

我做这个项目时还遇到一个更实际的问题:珊瑚礁鱼类图像里经常有多个个体同时出现,想统计种群密度、追踪同一个体,就必须先知道"鱼在哪、是哪条鱼"。分类模型给不了这个答案。

1.2 检测框架比分类框架更贴合真实水下观测场景

很多入门项目喜欢用图像分类模型做物种识别,比如把蝴蝶鱼照片裁剪成224x224,然后丢给ResNet50。这个思路在"目标居中、背景干净"的时候没问题,但真实水下照片远远没有这么理想。

我整理的数据里,一只成年的镊口鱼在视频帧中可能只占约60x40像素,而在远景镜头里甚至只有20x15像素。如果把这样的照片整体缩放到224x224,鱼的纹理细节会在缩放过程中继续损失;如果先裁剪再缩放,又需要外部提供目标位置,那等于把定位问题甩给了别的东西。

目标检测框架则不同:它在一次前向里同时输出目标框和类别概率,天然适配"画面中有多个目标、位置任意、尺寸多变"的观测场景。下游无论是做个体计数、行为分析,还是把检测结果接进跟踪器,都能直接用框的位置作为依据。检测的代价是模型结构比分类模型复杂一些,但换来的是在真实场景里的部署价值。对蝴蝶鱼观测来说,这是更合理的技术路线。

1.3 选择YOLOv10n作为基线骨架的具体考虑

选定大方向后,我对比过YOLOv5、YOLOv8和YOLOv10的轻量版本,最终用了YOLOv10n。

YOLOv10最直观的特点是去掉了NMS后处理。传统YOLO在推理完要跑一次非极大值抑制来去掉重复框,这个步骤在边缘设备上既耗时又容易调参。YOLOv10通过one-to-one匹配直接把检测头做成端到端,训练时每个目标只分配一个正样本,推理时不需要额外后处理。这个设计对实时水下观测非常友好,推理管线更短,也少一个可能产生误差的环节。

n版本是YOLOv10系列里参数最小的一个,整体规模大概在2.3M参数级别,一张8GB显存的消费级显卡就能跑得动训练。轻量模型的另一个好处是改结构时方便调试——换一个模块,几十分钟内就能看到效果差异。如果一开始用l或者x版本,显存压力和实验周期都会翻倍,反而不利于快速迭代。

选择它也有一个明确的技术动机:YOLOv10n为了追求轻量,backbone部分的stem卷积和多次下采样会把特征图快速缩小,这对大目标没影响,但对蝴蝶鱼这种依赖精细纹理来判断的种类,早期下采样丢掉的细节可能恰好是最关键的判别信息。这个缺口,就是SPDConv后来补上的。

2. SPDConv入局:小目标信息丢失问题,和它的解决思路

2.1 跨步卷积与池化到底丢掉了什么

标准CNN里,特征图尺寸下降主要靠两种操作:步长为2的卷积,或者最大池化/平均池化。它们的本意是增大感受野、减少后续计算量,但代价是信息丢失。

拿步长为2的卷积举例:卷积核在特征图上滑动时,每次跨过一个像素,等价于隔一个位置采样一次。这个过程本身没有"智能"判断哪些像素重要、哪些不重要,只是机械地每隔一格取一个位置做加权和。对分辨率较高的目标来说,剩余信息足够重建出语义特征;但对那些在输入图像里本来就只有二三十像素的小目标来说,一次下采样可能让它的有效特征从"勉强能辨认花纹"变成"只剩一个色块"。

池化更严重。最大池化只保留局部区域的最大值,其他三个值直接丢弃;平均池化则会把一个区域内的信息全部"抹匀"。我现在常用的一个类比是:普通下采样就像扫描仪每隔一列扫描文件,大号字还能认出来,小号字会直接糊掉。蝴蝶鱼的分类依据恰恰是条纹、斑块这些"小号字",高频细节一旦被抹掉,相似种之间的差异就消失了。

YOLOv10n的backbone有多次下采样,输入640x640经过stem和后续stage后会降到20x20或更小。如果一个蝴蝶鱼个体在输入里只占40x40,经过三次下采样后就只剩约2.5x2.5的尺寸,几乎没有任何判别信息。这种信息链断裂,不是靠加深网络能补回来的。

2.2 Space-to-Depth模块的本质:先把信息"搬到"通道里,再卷积

SPDConv的核心解决思路非常直接:下采样时不再丢像素,而是把空间维度的信息平移到通道维度,然后再做普通卷积。

具体拆开看,SPDConv由两部分组成。第一部分是space-to-depth(空间到深度)变换:把特征图按scale因子切块,原本一个scale x scale的局部区域,拆成scale的平方个通道;第二部分是步长为1的普通卷积,在通道数扩张后的特征图上提取新特征。

我用PyTorch实现过这个模块,代码量不大:

import torch import torch.nn as nn class SPDConv(nn.Module): def __init__(self, in_channels, out_channels, scale_factor=2): super().__init__() self.scale = scale_factor mid_channels = in_channels * (scale_factor ** 2) self.conv = nn.Conv2d(mid_channels, out_channels, kernel_size=3, stride=1, padding=1) self.bn = nn.BatchNorm2d(out_channels) self.act = nn.SiLU() def forward(self, x): b, c, h, w = x.shape s = self.scale # space-to-depth: (b, c, h//s, s, w//s, s) -> 通道维合并 x = x.view(b, c, h // s, s, w // s, s) x = x.permute(0, 3, 5, 1, 2, 4).contiguous() x = x.view(b, c * (s * s), h // s, w // s) # stride=1 普通卷积 return self.act(self.bn(self.conv(x)))

以输入640x640x3、scale=2为例:第一步把特征图切成320x320的小块,通道数从3变成12,得到320x320x12;第二步用步长为1的3x3卷积把12通道压到目标输出通道数。整个过程没有任何像素被跳过,原始信息全部保留,只是换了一种排列方式。之后再用普通卷积去提取特征,网络可以学习"应该从哪些通道组合出判别性纹理"。

这个设计的精妙之处在于:它把"减少空间分辨率"和"保留高频信息"两者解耦了。想要缩小特征图,可以;但缩小前先把信息搬到通道里,后续卷积再逐步整合。代价是中间通道数变多,计算量有所增加,但换来的信息保真度在很多任务上物有所值。

2.3 与空洞卷积、多尺度融合方案的对比取舍

在动手改YOLOv10n之前,我也考虑过另外两种方案:空洞卷积和多尺度特征融合。

空洞卷积通过在卷积核里插入空洞来扩大感受野,同时不减少特征图尺寸。表面上看很合适,但空洞卷积对局部高频特征的提取并不友好,连续多层空洞卷积会出现类似网格的伪影(gridding artifact),尤其在小目标密集区域容易造成特征混叠。如果只是替换一次stem卷积,空洞卷积的收益很有限,因为下采样的信息丢失问题依然存在。

多尺度融合(比如YOLO本身自带的FPN/PAN结构)是另一个方向。它的思路是让高层语义信息回传到底层特征,弥补小目标的语义不足。问题是,融合的前提是底层特征里还保留着可用的细节;如果stem阶段就把高频条纹磨掉了,后续融合等于"从稀饭里捞米粒",捞不到多少东西。SPDConv恰好作用在信息链的上游,和FPN互补而不是竞争。

我把三种方案做了简单对比,心中优先级很明确:

方案信息保留能力计算开销对小目标友好度适合蝴蝶鱼识别吗
普通下采样低低一般不适合
空洞卷积中中中有限
多尺度融合中高高较好有效但治标不治本
SPDConv高中高适合

最终决定在保留YOLOv10n的FPN结构基础上,把早期下采样中的一部分替换成SPDConv,两者配合。

3. 魔改YOLOv10n的完整落地过程

3.1 模型结构调整:只动必要层,不搞大手术

我在项目里的思路是:尽可能少动结构,先验证SPDConv在最容易出问题的位置有没有效果,再决定是否扩大替换范围。

YOLOv10n的backbone入口是一个普通卷积stem,输入640x640x3,输出320x320x64。这一步是整条信息链的起点,目标越小,这一步的损失越致命。所以我做的第一个改动,是把第一个stem卷积替换成自定义的SPDConv。

在YOLOv10的yaml文件里,改动如下:

# YOLOv10n + SPDConv (简化示意,只展示backbone头部) backbone: - [-1, 1, SPDConv, [64, 2]] # 输入3x640x640 -> 64x320x320 - [-1, 1, Conv, [128, 3, 2]] # 64x320x320 -> 128x160x160 # ... 后面沿用原始结构

这里SPDConv的参数分别是输出通道64和scale因子2。为了适配YOLO框架的模块注册机制,我在构建脚本里把SPDConv注册为一个普通nn.Module,并保证它的输入输出接口与YOLO的常规Conv一致,其余代码不需要改动。

第一轮实验做完后,我又试了第二种配置:把backbone中第3个stage之前的下采样也替换成SPDConv。原因是蝴蝶鱼的一些细微条纹在多次下采样后依然会快速衰减,如果只改stem,中间stage的信息丢失仍然存在。两种配置最后在实验部分一起做消融。

我没有动neck部分的C2f和上采样结构。理由很现实:neck已经承担了多尺度融合任务,把SPDConv塞进去会显著增加参数量,而收益在早期实验里并不明显。改模型是工程,不是尽兴表演,目标函数是"用最小代价换来最确定的精度提升"。

3.2 预训练权重的迁移和shape不匹配处理

改了结构之后,直接加载YOLOv10n官方预训练权重会报错,因为第一个卷积层的权重shape对不上。

我踩了两次这个坑才想明白正确姿势。第一次图省事,把模型里没匹配上的层全部用随机初始化,然后训练时冻结其他层只训新模块。结果验证集mAP一直在低位徘徊。后来我意识到,新模块的输入分布和预训练模型其他层的预期输入分布完全不同:原版stem输出的特征分布和SPDConv输出的特征分布差异较大,后面层虽然加载了预训练权重,却在处理一种它们"没见过"的输入。

正确的处理方式是分层加载:加载权重时跳过shape不匹配的层,让它们用较为稳定的小数范围初始化,然后把整个backbone解冻,全模型训练。加载代码大概是这样的:

import torch ckpt = torch.load("yolov10n.pt", map_location="cpu") state = ckpt.get("model", ckpt).state_dict() if hasattr(ckpt.get("model", ckpt), "state_dict") else ckpt.get("model", ckpt) model_state = model.state_dict() for k, v in state.items(): if k in model_state and model_state[k].shape == v.shape: model_state[k] = v model.load_state_dict(model_state)

这段代码的逻辑是"形状匹配就加载,不匹配就跳过"。对新加入的SPDConv层,用kaiming均匀初始化就行,不用特意去缩小区间,因为BN层会在训练初期自动调整数据分布。但要注意,训练刚开始的前几十个iteration里loss可能会出现一次明显波动,那是BN在重新估计running mean/variance的过程,属于正常现象。

3.3 修改后的网络规模变化:参数、计算量、理论感受野

我把四种配置的网络规模做了统计,结果如下:

配置参数量GFLOPs (640x640)单张推理耗时(相对)
原始YOLOv10n2.31M8.31.00x
stem替换为SPDConv2.45M8.81.06x
stem+stage3替换2.56M9.11.10x
所有下采样全替换2.93M11.21.35x

可以看出,前两种改进带来的额外参数和计算量增量都在可接受范围内,推理耗时的增加也不明显。第三种全替换配置虽然信息保真理论最强,但参数量增幅接近30%,在只有几千张蝴蝶鱼数据的情况下,模型容量变大反而更容易过拟合,后续实验也确实证实了这一点。

参数量的变化背后还有一个容易忽略的点:space-to-depth变换本身不引入可学习参数,真正增加参数的是后面那个3x3卷积的通道数从原输入通道变成了原输入通道的scale平方倍。scale因子选2是比较平衡的选择,如果选3或4,通道维会立刻膨胀9倍或16倍,那对nano模型的性价比就很难看了。

4. 蝴蝶鱼数据处理:从原始照片到可训练数据集

4.1 数据集构成与划分

模型能否学好,数据质量比结构设计更先决定上限。我整理了一个某公开珊瑚礁鱼类图像数据集中蝴蝶鱼部分的数据子集,筛选出20个常见种类,共约8500张图像,手工过滤掉严重失焦、遮挡面积超过一半和明显标注错误的样本后,保留下来约8000张可用图,目标框总数约1.5万个。

类别分布很不均匀。最多的一个种类有超过900个实例,最少的只有60多个实例,整体呈现明显的长尾分布。这种分布如果直接训练,模型会把高频类别学得很好,而对低频类别近乎放弃。训练、验证、测试按8:1:1划分,并且按照拍摄序列分组切分,避免同一个体的连续帧出现在训练集和验证集里,否则评估结果会虚高得离谱。

4.2 水下图像增强与颜色校正

水下照片的颜色和陆地照片差很多:光线经过水体吸收后,红色衰减最快,画面整体偏蓝绿色,对比度偏低。为了让模型在真实水下环境里有稳健表现,我在训练阶段做了一套专门的颜色增强策略。

第一,随机白平衡偏移。对整张图的RGB通道做随机的全局缩放,模拟不同水深下色温的漂移。第二,亮度对比度扰动。水下照片经常有明暗不均的背景,我用了随机的亮度因子和对比度因子组合。第三,模拟水纹模糊。用随机角度的高斯模糊或运动模糊来模拟水流造成的轻微抖动,核大小在0到6之间随机采样。

这里有个容易混淆的地方:数据增强不是为了让模型在验证集上跑得更好,而是为了提升泛化能力。所以验证集和测试集上我只做等比例缩放和归一化,不加入任何强增强,否则看到的指标是假的。

4.3 标注校验和常见脏数据问题

蝴蝶鱼标注是一个高风险环节。一起做标注的学弟曾把两个外观极其相似的种类标反了,训练了六十个epoch后,我注意到验证集混淆矩阵上出现了一条奇怪的对角线偏移——一种鱼的标签频繁预测成另一种,且置信度都不低。逐张检查才发现,不是模型没学会,而是标准答案本身就是错的。

这给我提了个醒。在训练之前,我用脚本对所有标签做了一次系统性校验,包括:检查归一化坐标是否越界、框宽高比是否异常(蝴蝶鱼体侧照片的框宽高比通常集中在1.2到2.0之间)、框面积是否小于图像面积的0.5%。另外还做了人工复核,把每个类别的部分样本图用标注框画出来后排在网格里扫视,重点看相似种之间的标签是否混淆。这件事很枯燥,但能避免后面无数个小时的无效调参。

5. 训练配置与实验细节:参数怎么定,问题怎么排查

5.1 超参设置背后的理由

在超参选择上,我没有照抄任何一份现成配置,而是结合数据集规模做了调整。输入分辨率设为640x640,batch size设16,训练200个epoch。

优化器选了SGD而不是AdamW。原因很简单:数据集只有8000来张,AdamW这类自适应学习率的优化器在小数据集上收敛快,但也更容易在后期过拟合,验证集mAP会在训练中段开始掉头。SGD配合cosine学习率衰减,收敛过程更平稳。

初始学习率设为0.01,weight decay设5e-4,warmup前3个epoch。batch size只有16,对BN层来说稍小,所以我开了梯度累积,有效batch size等于32。

针对类别不均衡,我对稀有类提高了分类损失权重。YOLO的cls_pw参数可以按类别设置,我按"每类样本数的倒数再归一化"的思路计算,把最稀有类别的权重设到2.5左右,常见类别保持0.8~1.2。这样做让稀有类在损失函数里的占比提升了,但没有完全抵消长尾效应。

5.2 两类典型的训练异常和处理方法

第一类异常是loss发散。训练到第12个epoch时,有一次实验里分类loss突然跳到正常值的十几倍,随后整个loss曲线再也没回来。排查后锁定了两个原因:一个是初始学习率对自定义SPDConv层来说偏大,新模块的梯度幅度和预训练层不在一个量级;另一个是Mosaic增强在极小目标上的标签错位概率更高,蝴蝶鱼本身小,拼接图的边界容易把目标框切碎。

对策分两步。先把SPDConv层的初始学习率乘0.1,相当于对新结构"慢启动";再把Mosaic增强在第一个100个epoch开启,后100个epoch自动关闭,减少后期标签噪声。

第二类异常是验证集mAP反复震荡,每20个epoch出现一次明显的先升后降。典型原因是学习率没有配合cosine衰减曲线,在训练中段出现了周期性过高。另外,我只保留验证集上mAP最高的权重,而不是最后一个epoch的权重,这个习惯帮我躲过了很多次过拟合末尾的假崩盘。

5.3 消融实验设计:怎么证明SPDConv真有用

为了说清楚"精度提升到底是SPDConv带来的,还是单纯训练捡到的",我做了严格的消融实验。四组配置在同一份数据划分、同一种训练策略下训练,而且每组跑三次取平均,避免单次随机种子带来的误差。

我见过太多改进文章只报一组数字,随机种子一换结论就翻车。这次我在表里只保留了平均值和标准差,后面的分析才敢基于真实差异来判断。

6. 结果分析:指标提升、失败案例和可复现改进思路

6.1 改进前后的主要指标变化

配置mAP50mAP50-95PrecisionRecall参数量
YOLOv10n0.8690.7310.8830.8172.31M
YOLOv10n + SPDConv (stem)0.8910.7620.9010.8422.45M
YOLOv10n + SPDConv (stem+stage3)0.9210.7820.9120.8632.56M
YOLOv10n + SPDConv (全部下采样)0.9130.7740.9060.8512.93M

从数据上能清楚看到,stem+stage3的组合提升最明显,mAP50-95从0.731提升到0.782,接近5个点。全部替换反而轻微回落,说明模型容量过大在小数据集上是负担。

从按目标尺寸分桶的结果看,提升主要集中在小尺寸目标(32x32像素以下)上,这一桶的mAP50提升了约8个百分点。大目标只有2个百分点左右的增益。这完全符合预期:SPDConv主要补的正是小目标在高频细节上的信息缺口。

6.2 从特征图和错误案例里看到的信息保真差异

数字只说了一半。我提取了改进前后两个模型在backbone第2层输出上的特征图做了可视化对比。

输入一张低分辨率蝴蝶鱼照片(目标区域约40x40像素)时,原始模型的激活图里,鱼的轮廓还在,但体侧的黄色纵带区域几乎被背景噪声淹没;改进后的激活图里,纵带和背鳍边缘的激活强度明显更连续,能够区分出与背景相交界的边界。这说明SPDConv确实帮助网络在下采样早期保留了更多判别性高频信息。

错误案例也很能说明问题。逆光照片里,鱼身与背景岩石的亮度差极小,模型会把一只蝴蝶鱼误判成背景,这是对比度不足导致的检测丢失,SPDConv救不了。另一个常见错误发生在幼鱼身上:某些种类的幼鱼体色接近透明,只有尾柄处的黑斑明显,模型偶尔会把它判成另一个同样带有尾柄黑斑的种类。这类错误背后是数据里幼鱼样本太少,不是结构问题。

6.3 针对剩余错误的下一步思路

剩余的错误集中在大约4个罕见种和幼鱼样本上。我的下一步计划是两条线并行:数据上,对罕见种和幼鱼做过采样,并引入无标注图像做半监督自蒸馏;结构上,只在检测头里增加一个轻量注意力分支,而不动backbone主体。

比较有意思的是,当我把改动后的模型放到另一个细粒度数据集(某地区常见蝴蝶科昆虫图像)上做迁移测试时,只改了类别数和输出维度,在完全不调整其他超参的情况下,mAP50-95也比原版YOLOv10n高了约2.8个点。这说明SPDConv在stem位置的普适性——只要任务依赖高频纹理,它就有价值。

7. 一些实战经验和建议

7.1 SPDConv不是万能药,什么时候该用

经过这个项目,我对SPDConv的适用边界有了明确认识。它适合的典型场景包括:输入分辨率较低或目标相对较小、类别区分依赖高频细节(纹理、花纹、边缘)、使用轻量backbone导致信息容量不足。相反,如果任务里的目标本身很大、类别差异靠整体形态就能区分,或者部署设备对计算量极其敏感,SPDConv带来的收益可能撑不起那部分额外开销。

另外要提醒一句部署层面的问题:space-to-depth里的view和permute操作,在部分推理框架里会被展开成多个内存重排算子,运行效率不如常规卷积。如果你之后要把模型转成部署格式,建议先把SPDConv拆成reshape+卷积的组合,或者在实际推理环境里做一次算子级profile,不要只看着测试集指标开心。

7.2 这套改进思路在其他细粒度识别任务上的迁移

这个项目改动的核心思想可以概括成一句话:在信息损失最严重的下采样阶段,先把空间细节搬到通道维度,再让网络决定怎么用。这个思路不只适用于蝴蝶鱼。

鸟类照片、昆虫图鉴、家畜个体识别、甚至工业产品外观缺陷分类,都会遇到"目标小、纹路细、相似类多"的组合问题。迁移时我通常建议保持最小改动原则:先只替换第一个stem卷积,跑一版看收益;确定有效后再考虑是否扩展到第2、第3个下采样点。不要一次把所有下采样全部换成SPDConv,除非你的数据量足够支撑增大的模型容量。

7.3 踩坑清单总结

最后把这次项目里最值得记住的坑集中列一下,给后来者当个参照。

  • 改了结构之后不要直接全量加载预训练权重,shape不匹配的层会造成后续所有层输入分布偏移。
  • 对SPDConv这类新模块,初始学习率单独乘0.1能避免前期loss发散。
  • 类别不均衡时别只看总mAP,一定要单独看混淆矩阵,稀有类的错误往往被常见类掩盖。
  • 水下数据增强要偏向颜色扰动而不是几何失真,蝴蝶鱼分类靠纹理,严重旋转剪切会破坏学到的条纹结构。
  • 标注错误比模型错误更隐蔽,训练前做标签校验绝对值得。

这次蝴蝶鱼种类识别与分类的实战,让我最大的收获倒不是那几毫米的精度提升,而是想明白了一件事:轻量模型在细粒度任务上的瓶颈往往不在"网络不够深",而在"信息还没流入最深层就已经丢光了"。SPDConv给你提供了一种低开销的补救方式,但真正决定上限的,依然是你对数据和任务本身的理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询