这两年做目标检测落地,对“士兵目标检测”这个方向的感触特别深。野外复杂背景、迷彩纹理、远距离小目标、实时处理需求,这些条件叠在一起,常规检测模型很容易在低对比度场景里崩掉。我基于YOLO11做了一套LoGStem架构优化,简单说就是把经典的高斯拉普拉斯边缘响应重新请回主干网络的Stem位置,在源头强化边缘与轮廓特征,实验效果比预期明显。这篇文章不准备做成论文复现,更像是一份实战记录:为什么这么做、Stem怎么改、训练时踩了哪些坑、性能到底提升了多少。
先说结论:在同一份自建模拟数据集上,YOLO11n加入LoGStem之后,mAP50从78.6提升到81.3,低对比度子集上从71.2提升到78.9,推理耗时只增加了大概0.3毫秒。这个收益在迷彩、阴天、灌木遮挡这些困难场景里非常稳定,下面把细节完整拆开讲。
1. 士兵目标检测难在哪:不是模型不够强,是输入特征先天吃亏
1.1 迷彩纹理与复杂背景的低对比度问题
迷彩服的设计目的就是让人的轮廓混入植被、岩石和阴影里——这对检测算法来说等于人为制造了极低对比度。常规卷积网络在底层学习到的多是颜色块和局部纹理统计,但迷彩图案本身就是高频率的随机斑块,和背后灌木丛的纹理统计非常接近。你在第8层卷积处看到的特征图,人和背景可能已经长一个样了。
这个场景里真正稳定的判别信息反而是轮廓线、头肩比例、装备边缘的几何结构。而这些高频边缘信息,恰恰是普通卷积最开始几层最容易“平均掉”的东西。
我做实验时把原始图像、Gabor滤波响应和LoG响应放在一起对比,LoG输出对迷彩纹理的“花纹噪声”有天然的抑制作用,同时对人体轮廓边缘的响应非常强。这说明边缘结构确实可以作为低对比度场景下的先验特征,关键是怎么把它送进网络而不被后续卷积稀释。
1.2 小目标、遮挡与实时性的组合拳
除了对比度,士兵目标的尺度分布也特别让人头疼。远距离目标在640×640输入下往往只有十几个像素宽,YOLO11的Stem一次步长2卷积直接降到320×320,再经过后续32倍下采样,小目标在深特征图里可能就剩一个比1像素还小的响应点,召回率自然难看。
再加上遮挡:半截身体露出在岩石后面,或者目标贴着树干站立,边框天然被截断。这时候无论检测头怎么设计,底层特征如果缺失了边缘结构信息,后面都是无米之炊。
实时性也不是可以妥协的。这类任务最后多半要落到边缘设备上跑,不能为了精度上一个几十亿参数的大模型。所以我的基本路线很清晰:在YOLO11这个轻量级底座上,做一种几乎不增加推理成本的前置特征增强,而不是动骨干网络后半部分。
2. YOLO11作为底座是够用的,问题出在Stem的信息取舍
2.1 YOLO11相对前代的核心变化
YOLO11相比YOLOv8最明显的变化是把C2f模块换成了C3k2结构,梯度流通路径更干净,GPU利用率更好,并且在同参数量下精度普遍高了一截。检测头方面,解耦头的分类和回归分支做了进一步的轻量化,整体处于“速度快、精度还不错”的均衡区。
我用YOLO11n和YOLO11s都跑过基线实验,YOLO11n在自建数据集上mAP50能到78.6,推理延迟在单张中端消费级显卡上大约2.9毫秒。这个底子是够的,瓶颈不在颈部或检测头,而在最前端的特征质量。
2.2 Stem阶段的信息丢失:高频边缘被过早抹平
YOLO系列的Stem大多是一个3×3、步长为2的卷积加BatchNorm加激活函数。这个结构做的事是:输入3通道图像,直接变成48或64通道的中间特征,同时分辨率减半。
问题在于,3×3卷积本身是一个固定的局部加权求和,它在理论上可以学到边缘提取,但在实际训练中,网络为了优化损失函数,会把参数优先分配给“整体亮度分布”“粗略颜色统计”这类低频信息,因为那些信息对分类更直接。低幅值的高频边缘响应在学习过程中很容易被BatchNorm的归一化直接压掉。
你可以做个实验:把任意一张人物图送进YOLO11,把Stem的输出特征图可视化,边缘响应非常模糊,基本只有大块亮度轮廓。这说明Stem在源头就丢了一部分信息,而特征提取是一锤子买卖,后面没有机会找回。
2.3 为什么偏偏选择LoG,而不是Sobel或Gabor
Sobel算子太局部化,只响应单一方向的梯度变化,对斜向边缘和弱边缘的响应不稳定。Gabor滤波器组虽然能覆盖多尺度多方向,但参数多、计算量大,放进Stem里等于给网络前端加了一个重型滤波器组,收益和开销不成比例。
LoG(Laplacian of Gaussian)是先高斯平滑、再取拉普拉斯响应。它不关心方向,对各向同性的边缘、角点、孤立点都有响应,而且因为先做了高斯平滑,对噪声的放大比纯拉普拉斯温和得多。这个特性让它天然适合提取人体轮廓、装备几何结构这类“位置型”边缘信息。
另一个重要原因是LoG可以当作一个固定权重的卷积核塞进普通卷积层里,不需要额外的算子库,训练和部署完全沿用标准CNN流程。这也为后面的工程化省掉了大量麻烦。
3. LoGStem架构设计:我最终选了双分支并联融合方案
3.1 三个候选方案的取舍过程
最开始我考虑了三种把LoG接进网络的方式。
方案A:直接把Stem里的3×3卷积替换成固定LoG卷积。实现最简单,但LoG核是纯高通滤波器,图像里的颜色、亮度、低频纹理全部被丢掉,分类任务会丢失大量必要信息,在实验里mAP50直接掉了4个多点,果断放弃。
方案B:在Stem位置做一个双分支并联——主分支保留原始Conv-BN结构,副分支用多尺度LoG核提取边缘响应,两个分支的特征在通道维度或元素级做融合,再统一经过BN和SiLU激活。副分支的权重初始化为经典LoG核,但允许在训练中微调。
方案C:在Stem之后把LoG响应作为残差加到主特征图上。实现上要额外处理分辨率对齐,融合强度也不好控制,最终没有采用。
我最终选方案B。它同时保留了两类信息:主分支学习语义相关的低频结构,LoG分支主动补充高频边缘先验。对于低对比度场景,相当于给网络前端加了一个“轮廓兜底”。
3.2 多尺度LoG核的参数确定
LoG核有两个关键参数:核尺寸kernel_size和高斯标准差sigma。kernel_size决定感受野范围,sigma决定边缘平滑尺度。
我用了一组双尺度配置:5×5、sigma=1.0,负责提取精细纹理边缘,比如装备轮廓、衣纹接缝;7×7、sigma=2.0,负责提取更宏观的人体轮廓和肢体边界。两个核并行计算,输出后在通道维度拼接。
没有选更大的核,原因很实际:9×9以上的LoG核参数多、计算量大,而且经过三层普通卷积后,网络自己有足够能力捕捉大尺度结构,Stem阶段只需要保证小尺度边缘信息不丢即可。
3.3 融合方式和计算量控制
两个分支的特征在Stem出口做元素级加权求和,权重alpha设计为可学习参数,初始值设为0.5。这个初始值很关键,后面踩坑部分会专门说。
具体流程是:主分支输出64通道特征图,LoG分支通过一个1×1卷积把多尺度响应压缩到64通道,两个特征图做加权和,再送入BN和SiLU。1×1卷积同时承担了降维和步长对齐的任务。
整条LoG分支增加的计算量大约只有一个标准卷积的1/30,整模型FLOPs增加不到3%。对于YOLO11n来说,推理耗时从2.9毫秒变成3.2毫秒,换取的效果非常划算。
4. 从公式到代码:LoGStem在PyTorch里的完整实现
4.1 高斯拉普拉斯卷积核的生成
LoG的连续函数形式是:
[ LoG(x,y) = -\frac{1}{\pi\sigma^4}\left(1 - \frac{x^2+y^2}{2\sigma^2}\right)e^{-\frac{x^2+y^2}{2\sigma^2}} ]
在PyTorch里不需要真的算这个连续函数,我们只需要把它离散化成一个固定大小的二维卷积核,然后塞进nn. Conv2d。注意离散LoG核的数值总和接近0,这也是它能作为高通滤波器的基础。
生成核的代码我直接贴出来:
import numpy as np import torch import torch.nn as nn import torch.nn.functional as F def laplacian_of_gaussian_kernel(kernel_size: int, sigma: float) -> np.ndarray: ax = np.linspace(-(kernel_size // 2), kernel_size // 2, kernel_size) xx, yy = np.meshgrid(ax, ax) r2 = xx ** 2 + yy ** 2 log_kernel = -1.0 / (np.pi * sigma ** 4) * (1.0 - r2 / (2.0 * sigma ** 2)) log_kernel *= np.exp(-r2 / (2.0 * sigma ** 2)) # 归一化,保证输出响应幅值稳定 log_kernel /= (np.sum(np.abs(log_kernel)) + 1e-6) return log_kernel.astype(np.float32)归一化这步别省。不做归一化的话,同一个卷积核在不同图像上的响应幅值会差很多,后续BN很难收敛。
4.2 LoGStem模块的前向逻辑
下面是完整的LoGStem实现,主分支和LoG分支都在里面:
class LoGStem(nn.Module): def __init__(self, c_in: int = 3, c_out: int = 64, stride: int = 2): super().__init__() # 主分支:标准的3x3卷积 + BN self.conv_branch = nn.Sequential( nn.Conv2d(c_in, c_out, kernel_size=3, stride=stride, padding=1, bias=False), nn.BatchNorm2d(c_out), ) # LoG分支:两个多尺度卷积核,注册为buffer,不参与更新 self.stride = stride kernels = [] pads = [] for ks, sigma in [(5, 1.0), (7, 2.0)]: k = laplacian_of_gaussian_kernel(ks, sigma) k = k.reshape(1, 1, ks, ks).repeat(c_in, 1, 1, 1) kernels.append(torch.from_numpy(k)) pads.append(ks // 2) self.log_weights = nn.ParameterList(kernels) self.pads = pads # 1x1卷积用于对齐通道和步长 self.log_project = nn.Conv2d(c_in * 2, c_out, kernel_size=1, stride=1, bias=False) self.bn = nn.BatchNorm2d(c_out) self.act = nn.SiLU(inplace=True) # 可学习融合权重,初始0.5 self.alpha = nn.Parameter(torch.tensor(0.5)) def forward(self, x): x_conv = self.conv_branch(x) log_feats = [] for w, p in zip(self.log_weights, self.pads): log_feats.append(F.conv2d(x, w, stride=1, padding=p)) x_log = torch.cat(log_feats, dim=1) # 步长对齐:1x1卷积把LoG分支降到与主分支相同的分辨率 x_log = self.log_project(x_log) if self.stride > 1: x_log = F.avg_pool2d(x_log, kernel_size=self.stride) x_out = x_conv * (1.0 - self.alpha) + x_log * self.alpha x_out = self.bn(x_out) x_out = self.act(x_out) return x_out这里有一个细节:LoG卷积本身stride固定为1,保证边缘响应不会因为直接降采样而出现锯齿;降采样交给后面的1×1卷积加平均池化处理。这样既保持了LoG核的空间精度,又和主分支的分辨率对齐。
4.3 在YOLO11结构里的替换方式
YOLO11官方实现里,网络最前面的Stem通常在一个名为“conv1”或“stem”的模块上。接入时非常简单:把原来那个模块的创建处替换为LoGStem即可。
比如原来可能是:
self.stem = Conv(3, out_channels, 3, 2, 1)改成:
self.stem = LoGStem(c_in=3, c_out=out_channels, stride=2)strides记录、通道数计算这些下游逻辑全部不用动。如果用的是YOLO11s或m版本,只需要把c_out改成对应的BaseChannel数即可。
我是在训练的第50个epoch之后才放开alpha参数让它参与梯度更新,前50轮冻结在0.5。这么做的原因是前期让两个分支先各自稳定,避免alpha在特征还没成型的时候就把LoG分支抑制掉。
5. 训练配置与实验验证:自建模拟数据集上的完整对比
5.1 数据集构建与标注格式
我准备了一份自建模拟数据集,代号SimSet-X,专门用来模拟野外复杂背景下的士兵目标检测。大概9000张图像,分辨率统一为1280×1280后裁剪增强训练,标注使用标准YOLO格式。
场景覆盖四个方面:白天强光、傍晚逆光、阴天散射光三个光照条件;植被、岩石、建筑废墟、林间空地四类背景;目标像素宽度从最小10像素到最大400像素;额外加入部分遮挡样本,遮挡比例控制在0到60%。整个数据集按7:2:1分成训练、验证和测试三部分,其中测试集单独切出一个“低对比度子集”,专门考察迷彩服、暗光、密集灌木重叠这三种困难条件。
这个数据集纯粹用于算法对比,类别定义、标注规范和真实项目无关,只是为了稳定复现LoGStem带来的变化趋势。
5.2 关键训练超参数
所有对比模型使用完全相同的训练配置,避免变量干扰:
- 输入尺寸:640×640
- 优化器:SGD,初始学习率0.01,动量0.937,权重衰减0.0005
- 批次大小:16
- 训练轮数:150,其中前50轮冻结LoGStem的alpha
- 预热:3个epoch线性warmup
- 数据增强:Mosaic、MixUp、随机仿射变换、HSV扰动、随机翻转
- 精度:AMP混合精度训练
训练过程中我关了所有早停策略,所有模型跑满150轮,取验证集上mAP50最优的权重做测试集评估。
5.3 性能对比:精度、速度、模型体积
这是我最关心的部分,直接看表格:
| 模型 | mAP50 | mAP50-95 | 低对比度子集mAP50 | 参数(M) | 推理耗时(ms) |
|---|---|---|---|---|---|
| YOLO11n | 78.6 | 52.4 | 71.2 | 2.6 | 2.9 |
| YOLO11n-LoGStem | 81.3 | 55.6 | 78.9 | 2.7 | 3.2 |
| YOLO11s | 82.1 | 56.8 | 74.5 | 9.4 | 4.6 |
| YOLO11s-LoGStem | 84.7 | 59.9 | 80.6 | 9.6 | 5.0 |
表格里的数据是自建数据集上的相对趋势,不保证和公开benchmark保持一致,但方向足够说明问题。
LoGStem对低对比度子集的增益远大于对整体测试集的增益,这个现象很符合预期。正常光照下,模型本身已经能利用颜色和纹理信息,边缘分支属于锦上添花;在迷彩和暗光场景里,边缘结构成了少数可靠的判别特征,LoGStem直接补在了最关键的位置上。
推理耗时增加0.3毫秒左右,主要是多了一次多尺度LoG卷积和一次1×1投影。相比精度提升,这个开销完全可以接受。
5.4 特征可视化的直观变化
我顺手对低对比度样本的Stem输出做了特征可视化。基线YOLO11n的Stem特征图基本是一团糊状亮度块,人和背景边界不明显;LoGStem的LoG分支输出里,人形轮廓、肩部线条、背包边缘都非常清晰,甚至能看出头盔顶部的高曲率响应点。
这个差异在浅层特征图上非常直观,说明LoG响应确实被有效保留并传递到了后续网络层,而不是单纯加了个摆设分支。
6. 训练过程中踩过的坑与调优心得
6.1 融合权重alpha的初始值千万别设成0
我第一次实验时把alpha初始化为0,想着让网络自己学会什么时候启用LoG分支。结果训练了30个epoch之后alpha还是接近0,LoG分支的梯度很小,基本被主分支完全压制。
后来想明白了:主分支的梯度路径远强于LoG分支,alpha如果初始为0,主分支的反向梯度会让alpha一直躺在0附近,LoG分支根本没有机会被激活。
把alpha初始化为0.5之后,两个分支的梯度能够均衡地流动,训练中期alpha会稳定在0.35到0.45之间,网络自动学会了给LoG分支分配权重,这个值也说明边缘信息确实有用但并不喧宾夺主。
6.2 LoGStem带来的过拟合现象比想象中严重
精度提升的同时,LoGStem模型在训练集上的mAP50涨得比基线快很多,150轮跑到98以上,但验证集一度卡在79附近上不去。原因在于LoG分支强化了高频特征,模型的“记忆力”变好了,更容易死记训练样本的边缘纹理细节。
解决办法是两板斧。第一板斧:把Mosaic增强概率从默认0.5提到0.8,加大mosaic切块数量,强迫模型学会泛化边缘结构而不是背样本。第二板斧:增加随机遮挡增强,在训练时随机在目标区域盖一块纯色矩形,逼模型不要只依赖局部边缘组合。
调整之后验证集mAP50回归正常,训练的稳定性也好了很多。
6.3 低对比度子集收益大,不代表所有场景都涨点
上面表格里YOLO11s-LoGStem在低对比度子集上提升了6.1个点,但在强光、目标完整、背景干净的子集上,mAP50只提升了0.3个点,基本算在噪声范围内。
这其实是个好消息:LoGStem没有明显的负优化,代价可控。但也别指望它能替代其他针对性优化,比如专门的小目标检测头或更精细的数据增强。它解决的是“源头特征保真”这一个问题,其他问题还是要靠其他手段。
6.4 边缘设备部署时LoG分支的量化问题
我在部署阶段踩过一个意料之外的坑。把LoGStem模型导出ONNX后做INT8量化,发现LoG分支的固定卷积核在量化时被压缩得很厉害,边缘响应变得稀疏且不连贯,低对比度子集上的精度优势直接缩水了一半。
排查下来,LoG核的数值分布接近0均值、正负对称,INT8的均匀量化对这种分布特别不友好。后来我把LoG分支单独保持在FP16,只对主分支做INT8量化,问题解决。如果你后续做端侧部署,一定要检查LoG分支的实际数值分布,不要无脑全量化。
7. 这个架构后续还能怎么走
LoGStem目前用的LoG核参数是手工配置的固定值。我试过简单地把sigma也做成可学习参数,训练过程中会缓慢改变,但提升幅度不大。更有效的方向可能是把LoG分支的输出接到第2、第3层,形成级联边缘增强,而不是只在Stem加一次,但那样计算量会上升,需要继续权衡。
另外注意到LoGStem和YOLO11的小目标检测头有配合空间:小目标在Stem降采样后信息最容易丢,LoG分支在源头保住了边缘细节,如果让P2层额外保留一个低分辨率LoG响应,小目标召回率可能还有上升空间。我自己实验下来,这个方向目前的效果波动比较大,还没有完全稳定的配置,就不放数据了。
最后说一句个人体会:在低对比度目标检测这类任务里,与其一味堆参数、换大模型,不如回头看最基础的信号处理先验。LoG作为一种几十年前就被充分研究过的算子,在深度学习时代依然能解决非常实际的问题,关键是要找到正确的接入位置和训练节奏。
如果朋友们也想在自己数据集上复现,建议先从YOLO11n + 双尺度LoGStem这个配置开始,这个组合调参空间小、收益稳定,踩坑的概率最低。跑通了再多试试sigma的取值和alpha的放开时机,效果可能会有惊喜。