
药片缺陷检测这个活儿看着简单真正上手才知道坑有多深。缺角、裂纹、斑点、麻面这些缺陷形态小、对比度低同一片药在不同光源角度下拍出来完全是两回事而你只有十几毫秒的处理时间。我最近在做药片光检项目时把 YOLO11 的 C3k2 模块改成了动态卷积版本组合出 YOLO11-C3k2-DynamicConv在保持推理速度满足产线要求的前提下把裂纹和缺角这类最难检的缺陷 AP 拉高了将近五个点。这篇文章会把改造动机、模块代码、训练参数和部署时踩过的坑一次性讲清楚给想在视觉质检方向对 YOLO 动刀子的同学提供一套可以直接抄作业的路径。1. 药片质检为什么必须“对症下药”从场景约束反推模型改造1.1 药片缺陷的成像特征与检测难点先说缺陷类型。药片在高速压片、包衣、转运过程中常见的缺陷大致可以分为几类每类在图像上的表现完全不同缺陷类型成像特征检测难点缺角、崩边轮廓局部缺失形状突变与正常倒角、边缘反光混淆裂纹细线状、低对比度接近单像素信号常规卷积容易丢失斑点、污渍颜色不均匀大小不一光照不均时色差阈值完全失效麻面、粗糙表面纹理颗粒感变化需要高阶纹理特征普通分类头难表达粘冲、偏色整片颜色偏移与光源老化、色温漂移高度相似产线环境还有一个硬约束高速压片机每分钟能出上千片检测系统通常用多工位相机并行拍照单张图留给算法的处理时间也就是 10ms 级别。这意味着我们既不能为了精度无节制堆参数量也不能指望跑一次大规模 Transformer 推理。模型必须在“小、快、准”三个维度同时满足要求。1.2 为什么选择 YOLO11 作为基线YOLO11 相比 YOLOv8在同等计算量下把主干和检测头的结构都做了调整例如引入了 PSA 注意力模块、重新优化了 C3k2 的堆叠方式整体精度和训练收敛速度都有提升。但它的核心瓶颈模块 C3k2 内部仍然是静态卷积训练结束后权重就固定了不管来的是什么光照条件、什么批次的药片都用同一组卷积核。药片缺陷恰好是“强局部纹理 弱全局语义”的混合问题。裂纹和缺角这类缺陷特征集中在极小的局部区域同时又受到整图光照、片型、背景噪声的影响。静态卷积对全局上下文不敏感单靠加深加宽网络去拟合代价是参数量和计算量双双上涨。DynamicConv动态卷积的思路则完全不同它维护多组卷积核让网络根据输入特征图自己决定每组核的权重相当于让卷积核“看菜下饭”。把这种机制放进 C3k2 的瓶颈路径里就能在不显著增加 FLOPs 的前提下给模型增加一层自适应的特征表达能力。这不是一个为了发论文而硬凑的改进而是从产线真实痛点倒推出来的方案。如果你的场景也是高速度、小目标、弱纹理缺陷这个思路可以直接迁移到胶囊、注射剂瓶、金属零件表面检测上。2. C3k2-DynamicConv 模块改造从原理到一份能跑的代码2.1 动态卷积的本质一次带“注意力”的卷积常规卷积相当于一个固定焦点的镜头训练收敛后权重 W 就冻结了所有样本共享同一套滤波参数。动态卷积则准备 K 组卷积核输入特征图先经过一个轻量的注意力分支计算出 K 个权重系数再把这 K 组核加权融合成一份“当前样本专用”的卷积核最后执行一次普通卷积。这个过程的计算开销非常划算最终只做了一次卷积多出来的只是注意力分支的全局平均池化和两层 MLPFLOPs 基本持平。参数量确实会变成原来的 K 倍但我们可以只把动态卷积用在瓶颈中间层把增量控制在合理范围内。从效果上看它有点像给网络内置了一个“核选择器”——光照偏暗时倾斜某一组核纹理复杂时倾向另一组核比单纯的通道注意力SE多了一个空间滤波层面的自由度。2.2 DynamicConv 的 PyTorch 实现下面是完整可跑的 DynamicConv 模块。实现要点有两个一是用einsum把注意力权重和 K 组卷积核做加权聚合二是用分组卷积的方式让 batch 内每个样本各自使用自己聚合出来的卷积核。import torch import torch.nn as nn import torch.nn.functional as F class DynamicConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size3, stride1, padding1, num_kernels4, reduction4): super().__init__() self.in_channels in_channels self.out_channels out_channels self.stride stride self.padding padding self.kernel_size kernel_size self.num_kernels num_kernels # K 组静态卷积核形状 [K, out_channels, in_channels, k, k] self.weight nn.Parameter( torch.randn(num_kernels, out_channels, in_channels, kernel_size, kernel_size) * 0.05 ) self.bias nn.Parameter(torch.zeros(num_kernels, out_channels)) # 输入相关的注意力分支 hidden max(in_channels // reduction, 8) self.attention nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(in_channels, hidden), nn.ReLU(inplaceTrue), nn.Linear(hidden, num_kernels), nn.Softmax(dim1) ) def forward(self, x): b, c, h, w x.shape # 每个样本的核权重 [b, K] attn self.attention(x) # 加权聚合卷积核和偏置 agg_w torch.einsum(bk,kocij-bocij, attn, self.weight) agg_b torch.einsum(bk,ko-bo, attn, self.bias) # 分组卷积每个样本一份专属权重 x x.view(1, b * c, h, w) agg_w agg_w.reshape(b * self.out_channels, self.in_channels, self.kernel_size, self.kernel_size) out F.conv2d(x, agg_w, biasagg_b.reshape(-1), strideself.stride, paddingself.padding, groupsb) _, _, oh, ow out.shape return out.view(b, self.out_channels, oh, ow)几个细节我特别说明一下。初始化时权重乘了 0.05 这个缩放系数目的是让初始聚合结果接近“各核权重的均衡平均”避免训练初期注意力分支还没学好的时候卷积输出方差过大。reduction控制注意力分支的隐藏层宽度太小会损失表达能力太大则白白增加参数一般取 4 或者 8 都可以。整个模块没有用 batch 内样本间互相计算所以 batch size 变化不会影响正确性。2.3 把 DynamicConv 接进 C3k2 瓶颈C3k2 的结构可以理解为先经过 1x1 卷积把通道一分为二一半直接走另一半依次经过若干个 Bottleneck 模块最后把两路结果拼接再用 1x1 卷积融合。我们要做的替换很克制——只把 Bottleneck 里的第二个 3x3 卷积换成 DynamicConv第一个 1x1 降维卷积保持原样。这样改动幅度小C3k2 整体的残差结构和拼接逻辑都不变训练更容易稳定。from ultralytics.nn.modules import Conv class Bottleneck_Dynamic(nn.Module): C3k2 的瓶颈模块中间卷积替换为动态卷积 def __init__(self, c1, c2, shortcutTrue, k(3, 3), num_kernels4): super().__init__() c2_ c2 // 2 self.cv1 Conv(c1, c2_, k[0], 1) # 1x1 降通道 self.cv2 DynamicConv( c2_, c2_, kernel_sizek[1], paddingk[1] // 2, num_kernelsnum_kernels ) self.add shortcut and c1 c2 def forward(self, x): y self.cv2(self.cv1(x)) return x y if self.add else y class C3k2_DynamicConv(nn.Module): 保持 C3k2 外壳内部瓶颈替换为动态卷积版本 def __init__(self, c1, c2, n1, shortcutFalse, g1, e0.5, num_kernels4): super().__init__() self.c int(c2 * e) self.cv1 Conv(c1, 2 * self.c, 1, 1) self.cv2 Conv((2 n) * self.c, c2, 1, 1) self.m nn.ModuleList( Bottleneck_Dynamic(self.c, self.c, shortcutshortcut, num_kernelsnum_kernels) for _ in range(n) ) def forward(self, x): y list(self.cv1(x).chunk(2, 1)) y.extend(m(y[-1]) for m in self.m) return self.cv2(torch.cat(y, 1))g参数在 C3k2 原始实现中用于控制分组卷积但我们的 DynamicConv 内部已经用了分组逻辑所以这里保留参数仅用于兼容解析器实际没有在模块里使用。如果你要复刻注意别把shortcut和num_kernels的位置搞混YOLO 的 YAML 解析是按位置传参的。2.4 接入 Ultralytics 训练链路Ultralytics 的模型构建走的是parse_model函数它会读 YAML 里的字符串再映射到对应的 PyTorch 模块。接入自定义模块需要两步。第一步把C3k2_DynamicConv注册进parse_model的 C3k2 分支# 在 ultralytics/nn/tasks.py 中 # 1) 文件顶部导入 from your_module import C3k2_DynamicConv # 2) parse_model 内部把 C3k2 分支扩展 elif m in {C3k2, C3k2_DynamicConv}: args [c1, c2, *args[1:]] if m C3k2_DynamicConv: m C3k2_DynamicConv(*args) else: m C3k2(*args)第二步复制一份 YOLO11n 的 YAML把主干里的部分 C3k2 字符串替换成C3k2_DynamicConv并在参数尾部追加一个数字表示动态卷积核数量。比如backbone: - [-1, 1, Conv, [64, 3, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 2, C3k2_DynamicConv, [256, False, 0.25, 4]] - [-1, 1, Conv, [256, 3, 2]] - [-1, 2, C3k2_DynamicConv, [512, False, 0.25, 4]] - [-1, 1, Conv, [512, 3, 2]] - [-1, 2, C3k2, [512, True]] - [-1, 1, Conv, [1024, 3, 2]] - [-1, 2, C3k2_DynamicConv, [1024, True, 0.5, 4]] - [-1, 1, SPPF, [1024, 5]]这里[256, False, 0.25, 4]对应的分别是目标通道数、是否使用 shortcut、通道缩放系数 e、动态卷积核数量 K。训练命令和平常一模一样yolo detect train datatablet.yaml modelyolo11n_c3k2_dynamic.yaml epochs300 imgsz640 batch16我在实际尝试中发现替换位置的选择比数量更重要。主干浅层特征分辨率高、缺陷细节多替换前两层收益最明显检测头附近的 C3k2 主要做语义融合替换后精度提升有限反而拖慢推理速度。所以最终模型我保留了检测头部分的原始 C3k2只在主干前中段做替换。3. 药片缺陷数据集决定上限的不是模型而是数据和标注3.1 成像方案不同缺陷用不同光路很多团队上来就怼模型结果发现瓶颈在数据质量。药片缺陷有个特点表面缺陷和轮廓缺陷对光线方向的要求截然相反。缺角、崩边这类轮廓缺陷用背光或者低角度环光照最清晰缺陷处会形成明显的轮廓断裂而斑点、麻面这类表面缺陷需要前向的均匀漫射光太强的方向性光线反而会在正常表面上形成伪边缘。我建议的采集方案是两个工位并行一个背光工位拍轮廓一个前向低角度环形光工位拍表面两路图像分别送各自的检测模型最后把结果合并。不要试图把两种光路塞进一张图那只会同时伤害两类缺陷的对比度。如果用单个相机至少也要保证光源是低角度的多向环形光并且做遮光罩防止环境光干扰。3.2 样本来源与缺陷标注规范缺陷样本不能只靠产线捡漏真实产线的缺陷率往往低于万分之一纯靠收集根本攒不够训练集。更现实的做法是“真实缺陷为主、人工制备为辅、合成数据兜底”。我们在项目里用微滴溶剂模拟斑点用轻微磕碰制备缺角再用图像合成把真实缺陷贴到不同批次药片背景上三种来源按 6:3:1 混合效果不错。标注规范是另一个容易翻车的地方。同一个缺陷两个人框出来的范围可能差三倍。我们内部定了几条硬性规则同一缺陷在粘连区域断成多个小块时合并为一个框框住整体外接矩形裂纹沿主方向画最小外接框不要为了贴合纹理频繁旋转边缘反光造成的亮线、药粉残留、通道划痕一律不算缺陷单列为“干扰背景”类框边缘距缺陷本体留 2~3 像素余量避免增强时把缺陷切掉。标注完成后必须做二次复核重点看低对比度的裂纹类。这条很枯燥但直接决定模型上限我在项目里把一半的标注时间都花在了这里。3.3 数据增强与类别不平衡处理药片缺陷数据天然不平衡斑点类可能占了 60%裂纹类只有 8%。我做了三件事一是对少数类做两倍过采样训练时每个 epoch 都能看到足够多的裂纹样本二是增强策略轻量化mosaic 开到 1.0但旋转角度限制在正负 15 度以内因为药片在采集位姿上的角度变化本来就有限过强的旋转会让模型学到错误先验三是保留约 30% 的无缺陷药片作为负样本让模型必须学会拒识而不是默认框里一定有东西。HSV 增强我特意调低了饱和度变化幅度。原本 HSV 增强是通用检测的标准配置但对药片色差类缺陷来说过度增强会把正常片的颜色扰动成缺陷的样子模型反而学糊涂了。实测中把hsv_h从默认 0.015 降到 0.01hsv_s从 0.7 降到 0.3色差类的误检明显减少。4. 训练配置与消融实验参数怎么调、效果怎么看4.1 环境与基础超参数我用的环境是 PyTorch 2.1.1、CUDA 12.1、Ultralytics 8.2.x单卡 RTX 3080 Ti 训练。数据集是自采的约 4200 张药片图像缺陷类别 5 类。超参数取值选择理由imgsz640平衡小裂纹细节与显存占用后续可用 960 续训batch16单卡显存上限内取整兼顾 BN 统计稳定性epochs300小数据集需要更长训练周期充分收敛optimizerSGD检测小模型上 SGDmomentum 比 AdamW 更稳lr00.01初始学习率配合 warmup 稳定早期 BNlrf0.01余弦退火末段学习率为初始的 1%momentum0.937YOLO 系训练标准配置weight_decay5e-4抑制动态卷积 K 组核之间的冗余warmup_epochs3前三个 epoch 线性升 lr稳定统计量hsv_h / hsv_s / hsv_v0.01 / 0.3 / 0.3低扰动避免色差缺陷被增强掩盖这里我想多说一句优化器选择。很多读者习惯性用 AdamW但 YOLO 的检测头输出是密集预测配上动态卷积这种参数较多的结构AdamW 在小数据集上很容易出现过拟合且对学习率敏感。SGD 加动量虽然收敛慢但泛化更稳配合 300 epoch 和 EMA 权重更新最终效果明显更好。EMA decay 我保持在默认 0.9992不再手动调。另外一个很有效的操作是“尺度续训”先用 640 分辨率训 200 epoch再把 imgsz 提到 960 续训 100 epoch。药片的裂纹和麻面属于高频细节大尺度下模型能看到的纹路更完整。DynamicConv 的注意力分支对输入分辨率不敏感所以这个操作在改进版上收益比原版更明显裂纹 AP 又涨了约 1.5 个点。4.2 消融实验到底改了什么、提升了多少我做了四组对比模型均用相同超参和数据集训练只改动主干中前段模块。表中耗时是 RTX 3080 Ti 上 FP16、batch1、640 输入的单图推理耗时包含 NMS。模型mAP50mAP50-95裂纹 AP参数量单图耗时YOLO11n 基线93.274.168.52.6M7.0msYOLO11n SE 通道注意力93.575.069.82.7M7.3ms本文方法K293.975.872.12.9M8.1ms本文方法K494.677.274.93.2M8.6ms本文方法K894.276.573.24.1M9.4ms表格里的数值是私有数据集上的自测结果趋势比绝对值更重要。可以看到几个明确结论第一动态卷积对裂纹和缺角这类弱纹理缺陷的增益最大比 SE 通道注意力多出约 5 个点的裂纹 AP。原因是 SE 只做通道重标定滤波器本身不变而动态卷积能根据输入自适应调整卷积核的空域响应方向对细线状边缘更友好。第二K4 是甜点值。K2 时表达自由度不够K8 时注意力分支参数量翻倍在 4200 张的小数据集上开始出现过拟合mAP50-95 反而回落。所以不要盲目加核数如果你的数据量更大可以试试 K8 会不会继续涨。第三耗时增加主要来自注意力分支和einsum聚合8.6ms 对单工位相机完全够用。如果产线要求更苛刻可以把检测头的快路径也恢复成原始 C3k2只保留主干浅层替换速度能回到 7.8ms 左右精度损失不到 0.5 个点。4.3 为什么提升集中在裂纹和缺角这个现象值得展开讲。裂纹接近单像素宽普通 3x3 卷积的有效感受野里裂纹只占很少几个像素特征很容易被周围大面积背景稀释。动态卷积提供的是“按输入切换卷积核”的能力注意力分支吃整图信息一旦判断当前区域可能包含细纹理或边缘阴影就会让聚合权重更偏向那些对方向性边缘更敏感的核。这不是幻觉而是网络在数据驱动下学会了在不同图像上下文之间切换滤波器。缺角和崩边也有类似特点所以这两类提升最明显。斑点类提升较小因为斑点本质是颜色分布异常普通卷积配合通道注意力已经够用动态卷积的增益不显著。这也说明如果你的场景以大面积色差缺陷为主简单加 SE 或者 CBAM 就足够了不必引入动态卷积的额外开销。5. 部署到产线的五件事导出、量化与误检治理5.1 导出与量化DynamicConv 的兼容性陷阱动态卷积的einsum和注意力分支在 ONNX 导出时会转换成 Gemm 和 Softmax 等标准算子opset 13 以上兼容性没有问题。真正的坑在 BN 融合阶段。Ultralytics 的export默认会执行fuse()把 BN 的缩放偏移吸收进前面的卷积权重。但 DynamicConv 的权重是在推理时按输入动态聚合出来的普通 fuse 机制拿不到最终权重直接融合会出错。我的处理办法是导出时不要依赖默认融合逻辑先把模型设成 eval手动对普通 Conv 做 BN 融合DynamicConv 层保持原样导出放入 TensorRT 再做卷积和 BN 的图融合。TensorRT 对这类结构支持得不错最终推理耗时和原生 PyTorch 基本持平甚至更快。INT8 量化时有一个必须注意的点校准集里一定要包含足够多的缺陷样本尤其是裂纹。校准集如果全是正常药片量化后模型会对低对比度细节严重失敏裂纹 AP 可能直接掉 15 个点以上。我后来校准集固定用 1000 张图、其中至少 100 张含缺陷按类别分层抽样量化损失才控制到 2 个点以内。5.2 误检治理缺陷与光照的边界问题模型上线后最常见的误检不是来自模型本身而是来自标签外的“伪缺陷”。药片正常倒角在某种光照角度下会形成一条亮边模型很容易把它当缺角药粉残留在料道上流动会被当成斑点。这类问题靠调 confidence 阈值是压不下去的因为正确缺陷的分数和伪缺陷的分数分布高度重叠。正确的做法是持续收集误检图把“伪缺陷”作为背景负样本回流训练集每星期做一次增量训练。经过三轮回流后误检率基本能降一个数量级。我项目里的实际教训是第一次回流时只加了 30 张误检图效果不明显后来一次加入 300 张同时把无缺陷负样本比例从 15% 提到 30%模型才真正学会区分“反光亮线”和“缺角”。现场的光强会衰减、色温会漂移建议给光源配在线亮度监控至少每周做一次光学校准。设备刚开机时也会有热漂移前十分钟的检测结果往往不稳可以用软件做预热等待避免把热漂移误判成模型退化。5.3 车速与多线程处理DynamicConv 比原始静态卷积单张慢 1.6ms 左右在满线速运行时可能就是压垮实时性的最后一根稻草。如果出现丢帧先别急着砍层检查一下采集和推理是否串行相机采图、GPU 推理、结果回传三件事应该用三套线程流水线跑。实测把单线程串行改成三线程流水线后整机吞吐提升了约 35%比把 K 从 4 降到 2 的收益大得多。5.4 在线统计与人工复核闭环药片缺陷检测不像通用目标检测漏掉一片问题药片可能导致整批返工。因此输出端不要只给一个检测结果建议输出每个缺陷框的置信度并划分“低风险直接剔除”和“高风险人工复核”两个区间。置信度落在 0.5 到 0.8 之间的目标自动滑到复核队列由质检员在双屏界面快速确认。这个机制看起来笨但在真实产线上能极大降低分险也让增量数据的采集有了稳定来源。最后说一个我印象最深的体会这类模型改造最难的不是把 DynamicConv 接进去而是后面那一个月的数据迭代闭环。当初我为了追求精度把 K 调到 8用了两周时间调参才勉强追平 K4后来发现产线持续回流的真实缺陷图让 K4 的模型在增量数据上越滚越稳K8 反而因为注意力分支过拟合、对新数据的适应性越来越差。所以如果你也想做类似的检测模型改进先想清楚数据回流机制再回头定卷积核数量能少走很多弯路。